diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..cefa5e4 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @grafana/sigil diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..32c0974 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,131 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + go: + name: Go SDK + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 + with: + go-version-file: go/go.mod + + - name: golangci-lint + uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 + with: + args: --timeout=5m + working-directory: go + + - name: Test core + run: cd go && GOWORK=off go test ./... + + - name: Test anthropic provider + run: cd go-providers/anthropic && GOWORK=off go test ./... + + - name: Test openai provider + run: cd go-providers/openai && GOWORK=off go test ./... + + - name: Test gemini provider + run: cd go-providers/gemini && GOWORK=off go test ./... + + - name: Test google-adk framework + run: cd go-frameworks/google-adk && GOWORK=off go test ./... + + typescript: + name: TypeScript SDK + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '24' + + - run: pnpm install + + - name: Typecheck + run: pnpm --filter @grafana/sigil-sdk-js run typecheck + + - name: Test + run: pnpm --filter @grafana/sigil-sdk-js run test:ci + + python: + name: Python SDK + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + suite: + - { name: core, cmd: "uv run --with '.[dev]' --directory python pytest tests" } + - { name: openai, cmd: "uv run --with './python[dev]' --with './python-providers/openai[dev]' pytest python-providers/openai/tests" } + - { name: anthropic, cmd: "uv run --with './python[dev]' --with './python-providers/anthropic[dev]' pytest python-providers/anthropic/tests" } + - { name: gemini, cmd: "uv run --with './python[dev]' --with './python-providers/gemini[dev]' pytest python-providers/gemini/tests" } + - { name: langchain, cmd: "uv run --with './python[dev]' --with './python-frameworks/langchain[dev]' pytest python-frameworks/langchain/tests" } + - { name: langgraph, cmd: "uv run --with './python[dev]' --with './python-frameworks/langgraph[dev]' pytest python-frameworks/langgraph/tests" } + - { name: openai-agents, cmd: "uv run --with './python[dev]' --with './python-frameworks/openai-agents[dev]' pytest python-frameworks/openai-agents/tests" } + - { name: llamaindex, cmd: "uv run --with './python[dev]' --with './python-frameworks/llamaindex[dev]' pytest python-frameworks/llamaindex/tests" } + - { name: google-adk, cmd: "uv run --with './python[dev]' --with './python-frameworks/google-adk[dev]' pytest python-frameworks/google-adk/tests" } + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.11' + + - name: Install uv + uses: astral-sh/setup-uv@6b9c6063abd6010835644d4c2e1bef4cf5cd0fca # v6.0.1 + + - name: Test ${{ matrix.suite.name }} + run: ${{ matrix.suite.cmd }} + + dotnet: + name: .NET SDK + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: '8.0.x' + + - name: Format check + run: dotnet format dotnet/Sigil.DotNet.sln --verify-no-changes + + - name: Test + run: dotnet test dotnet/Sigil.DotNet.sln -c Release + + java: + name: Java SDK + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + with: + distribution: temurin + java-version: '21' + + - name: Test + working-directory: java + run: ./gradlew --no-daemon test diff --git a/.github/workflows/dotnet-publish.yml b/.github/workflows/dotnet-publish.yml new file mode 100644 index 0000000..9027705 --- /dev/null +++ b/.github/workflows/dotnet-publish.yml @@ -0,0 +1,59 @@ +name: Publish .NET SDK to NuGet + +on: + workflow_dispatch: + inputs: + version: + description: 'Package version (e.g. 0.2.0)' + required: true + type: string + +permissions: + contents: read + +jobs: + publish: + if: github.repository == 'grafana/sigil-sdk' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Get secrets from Vault + id: get-secrets + uses: grafana/shared-workflows/actions/get-vault-secrets@f1614b210386ac420af6807a997ac7f6d96e477a # get-vault-secrets/v1.3.1 + env: + VAULT_INSTANCE: ops + with: + vault_instance: ${{ env.VAULT_INSTANCE }} + common_secrets: | + NUGET_API_KEY=sigil-nuget:api-key + export_env: false + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: '8.0.x' + + - name: Pack + env: + PKG_VERSION: ${{ inputs.version }} + run: | + dotnet pack dotnet/Sigil.DotNet.sln -c Release \ + -p:PackageVersion="${PKG_VERSION}" \ + -o nupkgs/ + + - name: Publish + env: + NUGET_API_KEY: ${{ fromJSON(steps.get-secrets.outputs.secrets).NUGET_API_KEY }} + run: | + for pkg in nupkgs/*.nupkg; do + dotnet nuget push "$pkg" \ + --api-key "${NUGET_API_KEY}" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate + done diff --git a/.github/workflows/go-sdk-tag.yml b/.github/workflows/go-sdk-tag.yml new file mode 100644 index 0000000..850077a --- /dev/null +++ b/.github/workflows/go-sdk-tag.yml @@ -0,0 +1,67 @@ +name: Tag Go SDK modules + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to tag (e.g. 0.2.0)' + required: true + type: string + +permissions: + contents: write + +jobs: + tag: + if: github.repository == 'grafana/sigil-sdk' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + + steps: + - name: Get secrets from Vault + id: get-secrets + uses: grafana/shared-workflows/actions/get-vault-secrets@f1614b210386ac420af6807a997ac7f6d96e477a # get-vault-secrets/v1.3.1 + env: + VAULT_INSTANCE: ops + with: + vault_instance: ${{ env.VAULT_INSTANCE }} + common_secrets: | + GITHUB_APP_ID=plugins-platform-bot-app:app-id + GITHUB_APP_PRIVATE_KEY=plugins-platform-bot-app:private-key + export_env: false + + - name: Generate GitHub token + id: generate-github-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ fromJSON(steps.get-secrets.outputs.secrets).GITHUB_APP_ID }} + private-key: ${{ fromJSON(steps.get-secrets.outputs.secrets).GITHUB_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + token: ${{ steps.generate-github-token.outputs.token }} + persist-credentials: true + + - name: Setup Git + run: | + git config user.name 'grafana-plugins-platform-bot[bot]' + git config user.email '144369747+grafana-plugins-platform-bot[bot]@users.noreply.github.com' + + - name: Tag all Go modules + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + VERSION="v${INPUT_VERSION}" + MODULES=( + go + go-providers/anthropic + go-providers/openai + go-providers/gemini + go-frameworks/google-adk + ) + for mod in "${MODULES[@]}"; do + TAG="${mod}/${VERSION}" + echo "Tagging ${TAG}" + git tag -a "${TAG}" -m "${mod} ${VERSION}" + done + git push origin --tags diff --git a/.github/workflows/java-publish.yml b/.github/workflows/java-publish.yml new file mode 100644 index 0000000..7f6a93d --- /dev/null +++ b/.github/workflows/java-publish.yml @@ -0,0 +1,60 @@ +name: Publish Java SDK to Maven Central + +on: + workflow_dispatch: + inputs: + version: + description: 'Package version (e.g. 0.2.0)' + required: true + type: string + +permissions: + contents: read + +jobs: + publish: + if: github.repository == 'grafana/sigil-sdk' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Get secrets from Vault + id: get-secrets + uses: grafana/shared-workflows/actions/get-vault-secrets@f1614b210386ac420af6807a997ac7f6d96e477a # get-vault-secrets/v1.3.1 + env: + VAULT_INSTANCE: ops + with: + vault_instance: ${{ env.VAULT_INSTANCE }} + common_secrets: | + MAVEN_USERNAME=sigil-maven:username + MAVEN_PASSWORD=sigil-maven:password + GPG_PRIVATE_KEY=sigil-maven:gpg-private-key + GPG_PASSPHRASE=sigil-maven:gpg-passphrase + export_env: false + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + with: + distribution: temurin + java-version: '21' + + - name: Publish + working-directory: java + env: + PKG_VERSION: ${{ inputs.version }} + OSSRH_USERNAME: ${{ fromJSON(steps.get-secrets.outputs.secrets).MAVEN_USERNAME }} + OSSRH_PASSWORD: ${{ fromJSON(steps.get-secrets.outputs.secrets).MAVEN_PASSWORD }} + SIGNING_KEY: ${{ fromJSON(steps.get-secrets.outputs.secrets).GPG_PRIVATE_KEY }} + SIGNING_PASSWORD: ${{ fromJSON(steps.get-secrets.outputs.secrets).GPG_PASSPHRASE }} + run: | + ./gradlew --no-daemon publish \ + -Pversion="${PKG_VERSION}" \ + -PossrhUsername="${OSSRH_USERNAME}" \ + -PossrhPassword="${OSSRH_PASSWORD}" \ + -Psigning.key="${SIGNING_KEY}" \ + -Psigning.password="${SIGNING_PASSWORD}" diff --git a/.github/workflows/js-sdk-publish.yml b/.github/workflows/js-sdk-publish.yml new file mode 100644 index 0000000..96fc10b --- /dev/null +++ b/.github/workflows/js-sdk-publish.yml @@ -0,0 +1,87 @@ +name: Publish JS SDK to npm + +on: + workflow_dispatch: + inputs: + version: + description: 'Semver bump type (major / minor / patch)' + required: true + type: choice + options: + - patch + - minor + - major + +permissions: + contents: read + +jobs: + publish: + if: github.repository == 'grafana/sigil-sdk' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Get secrets from Vault + id: get-secrets + uses: grafana/shared-workflows/actions/get-vault-secrets@f1614b210386ac420af6807a997ac7f6d96e477a # get-vault-secrets/v1.3.1 + env: + VAULT_INSTANCE: ops + with: + vault_instance: ${{ env.VAULT_INSTANCE }} + common_secrets: | + GITHUB_APP_ID=plugins-platform-bot-app:app-id + GITHUB_APP_PRIVATE_KEY=plugins-platform-bot-app:private-key + NPM_TOKEN=sigil-npm:token + export_env: false + + - name: Generate GitHub token + id: generate-github-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ fromJSON(steps.get-secrets.outputs.secrets).GITHUB_APP_ID }} + private-key: ${{ fromJSON(steps.get-secrets.outputs.secrets).GITHUB_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + token: ${{ steps.generate-github-token.outputs.token }} + persist-credentials: true + + - name: Setup Git + run: | + git config user.name 'grafana-plugins-platform-bot[bot]' + git config user.email '144369747+grafana-plugins-platform-bot[bot]@users.noreply.github.com' + + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: js/package.json + cache: pnpm + registry-url: https://registry.npmjs.org + + - run: pnpm install + + - name: Bump version + working-directory: js + run: npm version ${{ inputs.version }} --no-git-tag-version + + - name: Build + run: pnpm --filter @grafana/sigil-sdk-js run build + + - name: Publish + working-directory: js + run: pnpm publish --no-git-checks --access public + env: + NODE_AUTH_TOKEN: ${{ fromJSON(steps.get-secrets.outputs.secrets).NPM_TOKEN }} + + - name: Commit and tag + run: | + VERSION=$(node -p "require('./js/package.json').version") + git add js/package.json + git commit -m "chore(sdk-js): bump version to ${VERSION}" + git tag -a "sdk-js/v${VERSION}" -m "JS SDK ${VERSION}" + git push origin HEAD --tags diff --git a/.github/workflows/python-sdks-publish.yml b/.github/workflows/python-sdks-publish.yml new file mode 100644 index 0000000..e25f87a --- /dev/null +++ b/.github/workflows/python-sdks-publish.yml @@ -0,0 +1,195 @@ +name: Publish Python SDKs to PyPI + +on: + workflow_dispatch: + inputs: + version: + description: 'Semver bump type (major / minor / patch)' + required: true + type: choice + options: + - patch + - minor + - major + +permissions: + contents: read + +jobs: + build: + if: github.repository == 'grafana/sigil-sdk' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + outputs: + new-version: ${{ steps.bump.outputs.new-version }} + + steps: + - name: Get secrets from Vault + id: get-secrets + uses: grafana/shared-workflows/actions/get-vault-secrets@f1614b210386ac420af6807a997ac7f6d96e477a # get-vault-secrets/v1.3.1 + env: + VAULT_INSTANCE: ops + with: + vault_instance: ${{ env.VAULT_INSTANCE }} + common_secrets: | + GITHUB_APP_ID=plugins-platform-bot-app:app-id + GITHUB_APP_PRIVATE_KEY=plugins-platform-bot-app:private-key + export_env: false + + - name: Generate GitHub token + id: generate-github-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ fromJSON(steps.get-secrets.outputs.secrets).GITHUB_APP_ID }} + private-key: ${{ fromJSON(steps.get-secrets.outputs.secrets).GITHUB_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + token: ${{ steps.generate-github-token.outputs.token }} + persist-credentials: true + + - name: Setup Git + run: | + git config user.name 'grafana-plugins-platform-bot[bot]' + git config user.email '144369747+grafana-plugins-platform-bot[bot]@users.noreply.github.com' + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.11' + + - name: Install build tools + run: pip install build + + - name: Compute and apply version bump + id: bump + shell: bash + run: | + CURRENT=$(grep '^version = ' python/pyproject.toml | head -1 | sed 's/version = "\(.*\)"/\1/') + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" + + case "${{ inputs.version }}" in + major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; + minor) MINOR=$((MINOR + 1)); PATCH=0 ;; + patch) PATCH=$((PATCH + 1)) ;; + esac + + NEW="${MAJOR}.${MINOR}.${PATCH}" + echo "new-version=${NEW}" >> "$GITHUB_OUTPUT" + echo "Bumping Python SDK versions: ${CURRENT} -> ${NEW}" + + PACKAGE_DIRS=( + python + python-providers/openai + python-providers/anthropic + python-providers/gemini + python-frameworks/langchain + python-frameworks/langgraph + python-frameworks/openai-agents + python-frameworks/llamaindex + python-frameworks/google-adk + ) + + for dir in "${PACKAGE_DIRS[@]}"; do + file="${dir}/pyproject.toml" + sed -i "s/^version = \".*\"/version = \"${NEW}\"/" "$file" + if [[ "$dir" != "python" ]]; then + sed -i "s/\"sigil-sdk>=.*\"/\"sigil-sdk>=${NEW}\"/" "$file" + fi + echo " updated ${file}" + done + + - name: Build all packages + shell: bash + run: | + PACKAGE_DIRS=( + python + python-providers/openai + python-providers/anthropic + python-providers/gemini + python-frameworks/langchain + python-frameworks/langgraph + python-frameworks/openai-agents + python-frameworks/llamaindex + python-frameworks/google-adk + ) + + for dir in "${PACKAGE_DIRS[@]}"; do + pkg_name=$(grep '^name = ' "${dir}/pyproject.toml" | head -1 | sed 's/name = "\(.*\)"/\1/') + echo "Building ${pkg_name} from ${dir}..." + python -m build "$dir" --outdir "dist/${pkg_name}" + done + + - name: Commit and push + shell: bash + run: | + git add \ + python/pyproject.toml \ + python-providers/*/pyproject.toml \ + python-frameworks/*/pyproject.toml + git commit -m "chore(sdk-python): bump version to ${NEW_VERSION}" + git tag -a "sdk-python/v${NEW_VERSION}" -m "Python SDK ${NEW_VERSION}" + git push origin HEAD --tags + env: + NEW_VERSION: ${{ steps.bump.outputs.new-version }} + + - name: Upload built distributions + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: python-sdk-dists + path: dist/ + retention-days: 5 + + publish-core: + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + + steps: + - name: Download distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-sdk-dists + path: dist/ + + - name: Publish sigil-sdk to PyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + with: + packages-dir: dist/sigil-sdk/ + + publish-dependents: + needs: [build, publish-core] + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + strategy: + fail-fast: false + matrix: + package: + - sigil-sdk-openai + - sigil-sdk-anthropic + - sigil-sdk-gemini + - sigil-sdk-langchain + - sigil-sdk-langgraph + - sigil-sdk-openai-agents + - sigil-sdk-llamaindex + - sigil-sdk-google-adk + + steps: + - name: Download distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-sdk-dists + path: dist/ + + - name: Publish ${{ matrix.package }} to PyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + with: + packages-dir: dist/${{ matrix.package }}/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..365244c --- /dev/null +++ b/.gitignore @@ -0,0 +1,59 @@ +# mise +.mise.local.toml + +# dependencies +node_modules/ +.pnpm-store/ + +# logs +logs/ +*.log + +# build output +dist/ +coverage/ +.storybook-static/ +*.tsbuildinfo +js/.test-dist/ + +*.db +coverage.out + +# dotnet artifacts +dotnet/**/bin/ +dotnet/**/obj/ + +# docker and local env +.env +.env.* +!*.example +WORKFLOW.local.md +.venv/ +.venv*/ +.venv-*/ + +# python artifacts +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.mypy_cache/ + +# e2e outputs +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ +/output/playwright/ + +# superpowers brainstorm artifacts +.superpowers/ + +# editor/OS +.DS_Store +.idea/ +.vscode/ +.eslintcache +.turbo/ +scripts/mysql-port-forward.sh diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..57d6aec --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# Grafana Sigil SDK + +Client SDKs for [Grafana Sigil](https://github.com/grafana/sigil) — AI observability for LLM applications. + +## SDKs + +| Language | Package | Path | +|----------|---------|------| +| Go | `github.com/grafana/sigil-sdk/go` | [`go/`](go/) | +| Python | `sigil-sdk` | [`python/`](python/) | +| TypeScript/JavaScript | `@grafana/sigil-sdk-js` | [`js/`](js/) | +| .NET/C# | `Grafana.Sigil` | [`dotnet/`](dotnet/) | +| Java | `com.grafana.sigil` | [`java/`](java/) | + +## Provider Adapters + +| Language | Providers | Path | +|----------|-----------|------| +| Go | Anthropic, OpenAI, Gemini | [`go-providers/`](go-providers/) | +| Python | Anthropic, OpenAI, Gemini | [`python-providers/`](python-providers/) | + +## Framework Integrations + +| Language | Frameworks | Path | +|----------|------------|------| +| Go | Google ADK | [`go-frameworks/`](go-frameworks/) | +| Python | LangChain, LangGraph, OpenAI Agents, LlamaIndex, Google ADK | [`python-frameworks/`](python-frameworks/) | + +## Plugins + +- [OpenCode](plugins/opencode/) — Sigil integration for OpenCode + +## Proto + +Vendored protobuf definitions used by SDKs live in [`proto/`](proto/). + +## License + +[Apache License 2.0](LICENSE) diff --git a/dotnet/src/Grafana.Sigil.Anthropic/Grafana.Sigil.Anthropic.csproj b/dotnet/src/Grafana.Sigil.Anthropic/Grafana.Sigil.Anthropic.csproj index c1c9717..e99da1a 100644 --- a/dotnet/src/Grafana.Sigil.Anthropic/Grafana.Sigil.Anthropic.csproj +++ b/dotnet/src/Grafana.Sigil.Anthropic/Grafana.Sigil.Anthropic.csproj @@ -11,7 +11,7 @@ - + diff --git a/dotnet/src/Grafana.Sigil.Gemini/Grafana.Sigil.Gemini.csproj b/dotnet/src/Grafana.Sigil.Gemini/Grafana.Sigil.Gemini.csproj index 1b20bfe..e283af4 100644 --- a/dotnet/src/Grafana.Sigil.Gemini/Grafana.Sigil.Gemini.csproj +++ b/dotnet/src/Grafana.Sigil.Gemini/Grafana.Sigil.Gemini.csproj @@ -11,7 +11,7 @@ - + diff --git a/dotnet/src/Grafana.Sigil.Gemini/README.md b/dotnet/src/Grafana.Sigil.Gemini/README.md index 49c9a4e..955098c 100644 --- a/dotnet/src/Grafana.Sigil.Gemini/README.md +++ b/dotnet/src/Grafana.Sigil.Gemini/README.md @@ -2,6 +2,15 @@ Google Gemini GenerateContent instrumentation helpers for `Grafana.Sigil`. +## Public API + +- `GeminiRecorder.GenerateContentAsync(...)` +- `GeminiRecorder.GenerateContentStreamAsync(...)` +- `GeminiRecorder.EmbedContentAsync(...)` +- `GeminiGenerationMapper.FromRequestResponse(...)` +- `GeminiGenerationMapper.FromStream(...)` +- `GeminiGenerationMapper.EmbeddingFromResponse(...)` + ## Install ```bash @@ -106,6 +115,29 @@ foreach (var update in summary.Responses) The wrapper records mode as `STREAM` and aggregates the normalized generation from collected responses. +## Embedding wrapper (`EmbedContentAsync`) + +```csharp +EmbedContentResponse embeddingResponse = await GeminiRecorder.EmbedContentAsync( + sigil, + gemini, + "gemini-embedding-001", + new List + { + new() { Parts = new List { new() { Text = "hello" } } }, + new() { Parts = new List { new() { Text = "world" } } }, + }, + config: null, + options: new GeminiSigilOptions + { + ConversationId = "conv-gemini-embeddings-1", + AgentName = "assistant-core", + AgentVersion = "1.0.0", + }, + cancellationToken: CancellationToken.None +); +``` + ## Raw artifacts (debug opt-in) ```csharp diff --git a/dotnet/src/Grafana.Sigil.OpenAI/Grafana.Sigil.OpenAI.csproj b/dotnet/src/Grafana.Sigil.OpenAI/Grafana.Sigil.OpenAI.csproj index 922fa88..f697203 100644 --- a/dotnet/src/Grafana.Sigil.OpenAI/Grafana.Sigil.OpenAI.csproj +++ b/dotnet/src/Grafana.Sigil.OpenAI/Grafana.Sigil.OpenAI.csproj @@ -12,7 +12,7 @@ - + diff --git a/dotnet/src/Grafana.Sigil.OpenAI/OpenAIRecorder.cs b/dotnet/src/Grafana.Sigil.OpenAI/OpenAIRecorder.cs index f6f5e82..e356788 100644 --- a/dotnet/src/Grafana.Sigil.OpenAI/OpenAIRecorder.cs +++ b/dotnet/src/Grafana.Sigil.OpenAI/OpenAIRecorder.cs @@ -21,7 +21,7 @@ public static async Task CompleteChatAsync( } var effective = options ?? new OpenAISigilOptions(); - var modelName = ResolveInitialModelName(effective, provider.GetType().GetProperty("Model")?.GetValue(provider) as string); + var modelName = ResolveInitialModelName(effective, provider.Model); return await CompleteChatAsync( client, @@ -125,7 +125,7 @@ public static async Task CompleteChatStreami } var effective = options ?? new OpenAISigilOptions(); - var modelName = ResolveInitialModelName(effective, provider.GetType().GetProperty("Model")?.GetValue(provider) as string); + var modelName = ResolveInitialModelName(effective, provider.Model); return await CompleteChatStreamingAsync( client, @@ -228,7 +228,7 @@ public static async Task CreateResponseAsync( } var effective = options ?? new OpenAISigilOptions(); - var modelName = ResolveInitialModelName(effective, provider.Model); + var modelName = ResolveInitialModelName(effective, provider.GetType().GetProperty("Model")?.GetValue(provider) as string); return await CreateResponseAsync( client, @@ -333,7 +333,7 @@ public static async Task CreateResponseStreamingAs } var effective = options ?? new OpenAISigilOptions(); - var modelName = ResolveInitialModelName(effective, provider.Model); + var modelName = ResolveInitialModelName(effective, provider.GetType().GetProperty("Model")?.GetValue(provider) as string); return await CreateResponseStreamingAsync( client, diff --git a/dotnet/src/Grafana.Sigil.OpenAI/README.md b/dotnet/src/Grafana.Sigil.OpenAI/README.md index 3fbdaff..909e9ce 100644 --- a/dotnet/src/Grafana.Sigil.OpenAI/README.md +++ b/dotnet/src/Grafana.Sigil.OpenAI/README.md @@ -17,11 +17,13 @@ OpenAI instrumentation helpers for `Grafana.Sigil` with strict official OpenAI . - `OpenAIRecorder.CompleteChatStreamingAsync(...)` - `OpenAIRecorder.CreateResponseAsync(...)` - `OpenAIRecorder.CreateResponseStreamingAsync(...)` + - `OpenAIRecorder.GenerateEmbeddingsAsync(...)` - Mappers: - `OpenAIGenerationMapper.ChatCompletionsFromRequestResponse(...)` - `OpenAIGenerationMapper.ChatCompletionsFromStream(...)` - `OpenAIGenerationMapper.ResponsesFromRequestResponse(...)` - `OpenAIGenerationMapper.ResponsesFromStream(...)` + - `OpenAIGenerationMapper.EmbeddingsFromRequestResponse(...)` ## Install @@ -160,6 +162,27 @@ OpenAIChatCompletionsStreamSummary streamSummary = await OpenAIRecorder.Complete ); ``` +## Embeddings Wrapper + +```csharp +using OpenAI.Embeddings; + +OpenAIEmbeddingCollection embeddingResponse = await OpenAIRecorder.GenerateEmbeddingsAsync( + sigil, + new EmbeddingClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")!), + new[] { "hello", "world" }, + requestOptions: new EmbeddingGenerationOptions { Dimensions = 256 }, + options: new OpenAISigilOptions + { + ConversationId = "conv-openai-embeddings-1", + AgentName = "assistant-core", + AgentVersion = "1.0.0", + ModelName = "text-embedding-3-small", + }, + cancellationToken: CancellationToken.None +); +``` + ## Manual instrumentation example (strict mapper) ```csharp diff --git a/dotnet/src/Grafana.Sigil/Config.cs b/dotnet/src/Grafana.Sigil/Config.cs index 073f263..1a021fb 100644 --- a/dotnet/src/Grafana.Sigil/Config.cs +++ b/dotnet/src/Grafana.Sigil/Config.cs @@ -11,7 +11,8 @@ public enum ExportAuthMode { None, Tenant, - Bearer + Bearer, + Basic } public sealed class AuthConfig @@ -19,6 +20,10 @@ public sealed class AuthConfig public ExportAuthMode Mode { get; set; } = ExportAuthMode.None; public string TenantId { get; set; } = string.Empty; public string BearerToken { get; set; } = string.Empty; + /// Username for basic auth. When empty, TenantId is used. + public string BasicUser { get; set; } = string.Empty; + /// Password/token for basic auth. + public string BasicPassword { get; set; } = string.Empty; } public sealed class GenerationExportConfig @@ -152,9 +157,11 @@ string label switch (auth.Mode) { case ExportAuthMode.None: - if (tenantId.Length > 0 || bearerToken.Length > 0) + var noneBasicUser = auth.BasicUser?.Trim() ?? string.Empty; + var noneBasicPassword = auth.BasicPassword?.Trim() ?? string.Empty; + if (tenantId.Length > 0 || bearerToken.Length > 0 || noneBasicUser.Length > 0 || noneBasicPassword.Length > 0) { - throw new ArgumentException($"{label} auth mode 'none' does not allow tenant_id or bearer_token"); + throw new ArgumentException($"{label} auth mode 'none' does not allow credentials"); } return resolved; case ExportAuthMode.Tenant: @@ -190,6 +197,37 @@ string label resolved[AuthorizationHeaderName] = FormatBearerTokenValue(bearerToken); } + return resolved; + case ExportAuthMode.Basic: + var basicPassword = auth.BasicPassword?.Trim() ?? string.Empty; + if (basicPassword.Length == 0) + { + throw new ArgumentException($"{label} auth mode 'basic' requires basic_password"); + } + + var basicUser = auth.BasicUser?.Trim() ?? string.Empty; + if (basicUser.Length == 0) + { + basicUser = tenantId; + } + + if (basicUser.Length == 0) + { + throw new ArgumentException($"{label} auth mode 'basic' requires basic_user or tenant_id"); + } + + if (!resolved.ContainsKey(AuthorizationHeaderName)) + { + var encoded = Convert.ToBase64String( + System.Text.Encoding.UTF8.GetBytes($"{basicUser}:{basicPassword}")); + resolved[AuthorizationHeaderName] = $"Basic {encoded}"; + } + + if (tenantId.Length > 0 && !resolved.ContainsKey(TenantHeaderName)) + { + resolved[TenantHeaderName] = tenantId; + } + return resolved; default: throw new ArgumentException($"unsupported {label} auth mode '{auth.Mode}'"); diff --git a/dotnet/src/Grafana.Sigil/Grafana.Sigil.csproj b/dotnet/src/Grafana.Sigil/Grafana.Sigil.csproj index 7fff535..c9b6d11 100644 --- a/dotnet/src/Grafana.Sigil/Grafana.Sigil.csproj +++ b/dotnet/src/Grafana.Sigil/Grafana.Sigil.csproj @@ -10,18 +10,18 @@ - + - - - - + + + + - diff --git a/dotnet/src/Grafana.Sigil/Models.cs b/dotnet/src/Grafana.Sigil/Models.cs index 26a02c3..0f3a1c7 100644 --- a/dotnet/src/Grafana.Sigil/Models.cs +++ b/dotnet/src/Grafana.Sigil/Models.cs @@ -203,6 +203,8 @@ public sealed class GenerationStart { public string Id { get; set; } = string.Empty; public string ConversationId { get; set; } = string.Empty; + public string ConversationTitle { get; set; } = string.Empty; + public string UserId { get; set; } = string.Empty; public string AgentName { get; set; } = string.Empty; public string AgentVersion { get; set; } = string.Empty; public GenerationMode? Mode { get; set; } @@ -245,6 +247,8 @@ public sealed class Generation { public string Id { get; set; } = string.Empty; public string ConversationId { get; set; } = string.Empty; + public string ConversationTitle { get; set; } = string.Empty; + public string UserId { get; set; } = string.Empty; public string AgentName { get; set; } = string.Empty; public string AgentVersion { get; set; } = string.Empty; public GenerationMode? Mode { get; set; } @@ -280,8 +284,13 @@ public sealed class ToolExecutionStart public string ToolType { get; set; } = string.Empty; public string ToolDescription { get; set; } = string.Empty; public string ConversationId { get; set; } = string.Empty; + public string ConversationTitle { get; set; } = string.Empty; public string AgentName { get; set; } = string.Empty; public string AgentVersion { get; set; } = string.Empty; + /// The model that requested the tool call (e.g. "gpt-5"). + public string RequestModel { get; set; } = string.Empty; + /// The provider that served the model (e.g. "openai"). + public string RequestProvider { get; set; } = string.Empty; public bool IncludeContent { get; set; } public DateTimeOffset? StartedAt { get; set; } } diff --git a/dotnet/src/Grafana.Sigil/README.md b/dotnet/src/Grafana.Sigil/README.md index 786e341..02d1ce5 100644 --- a/dotnet/src/Grafana.Sigil/README.md +++ b/dotnet/src/Grafana.Sigil/README.md @@ -260,9 +260,35 @@ Per export path, supported auth modes are: - `ExportAuthMode.None` - `ExportAuthMode.Tenant` (`X-Scope-OrgID`) - `ExportAuthMode.Bearer` (`Authorization: Bearer `) +- `ExportAuthMode.Basic` (requires `BasicPassword` + `BasicUser` or `TenantId`, injects `Authorization: Basic `; also injects `X-Scope-OrgID` when `TenantId` is set — for self-hosted multi-tenancy only, not needed for Grafana Cloud) Explicit transport headers take precedence over auth-derived headers (`Authorization`, `X-Scope-OrgID`, case-insensitive). +### Grafana Cloud auth (basic) + +For Grafana Cloud, use `Basic` auth mode. The username is your Grafana Cloud instance/tenant ID and the password is your Grafana Cloud API key: + +```csharp +Auth = new AuthConfig +{ + Mode = ExportAuthMode.Basic, + TenantId = Environment.GetEnvironmentVariable("GRAFANA_CLOUD_INSTANCE_ID") ?? "", + BasicPassword = Environment.GetEnvironmentVariable("GRAFANA_CLOUD_API_KEY") ?? "", +}, +``` + +If your deployment requires a distinct username, set `BasicUser` explicitly: + +```csharp +Auth = new AuthConfig +{ + Mode = ExportAuthMode.Basic, + TenantId = Environment.GetEnvironmentVariable("GRAFANA_CLOUD_INSTANCE_ID") ?? "", + BasicUser = Environment.GetEnvironmentVariable("GRAFANA_CLOUD_INSTANCE_ID") ?? "", + BasicPassword = Environment.GetEnvironmentVariable("GRAFANA_CLOUD_API_KEY") ?? "", +}, +``` + ## Lifecycle and performance guidance - Reuse one `SigilClient` for the process lifetime. diff --git a/dotnet/src/Grafana.Sigil/SigilClient.cs b/dotnet/src/Grafana.Sigil/SigilClient.cs index a495df1..43e6ce2 100644 --- a/dotnet/src/Grafana.Sigil/SigilClient.cs +++ b/dotnet/src/Grafana.Sigil/SigilClient.cs @@ -20,6 +20,8 @@ public sealed class SigilClient : IAsyncDisposable internal const string SpanAttrGenerationId = "sigil.generation.id"; internal const string SpanAttrSdkName = "sigil.sdk.name"; internal const string SpanAttrConversationId = "gen_ai.conversation.id"; + internal const string SpanAttrConversationTitle = "sigil.conversation.title"; + internal const string SpanAttrUserId = "user.id"; internal const string SpanAttrAgentName = "gen_ai.agent.name"; internal const string SpanAttrAgentVersion = "gen_ai.agent.version"; internal const string SpanAttrErrorType = "error.type"; @@ -74,6 +76,8 @@ public sealed class SigilClient : IAsyncDisposable private static readonly Regex StatusCodeRegex = new(@"\b([1-5][0-9][0-9])\b", RegexOptions.Compiled); internal const string SdkName = "sdk-dotnet"; + internal const string MetadataUserIdKey = "sigil.user.id"; + internal const string MetadataLegacyUserIdKey = "user.id"; internal readonly SigilClientConfig _config; private readonly IGenerationExporter _generationExporter; @@ -204,6 +208,11 @@ public ToolExecutionRecorder StartToolExecution(ToolExecutionStart start) seed.ConversationId = SigilContext.ConversationIdFromContext() ?? string.Empty; } + if (string.IsNullOrWhiteSpace(seed.ConversationTitle)) + { + seed.ConversationTitle = SigilContext.ConversationTitleFromContext() ?? string.Empty; + } + if (string.IsNullOrWhiteSpace(seed.AgentName)) { seed.AgentName = SigilContext.AgentNameFromContext() ?? string.Empty; @@ -414,6 +423,16 @@ private GenerationRecorder StartGenerationInternal(GenerationStart start, Genera seed.ConversationId = SigilContext.ConversationIdFromContext() ?? string.Empty; } + if (string.IsNullOrWhiteSpace(seed.ConversationTitle)) + { + seed.ConversationTitle = SigilContext.ConversationTitleFromContext() ?? string.Empty; + } + + if (string.IsNullOrWhiteSpace(seed.UserId)) + { + seed.UserId = SigilContext.UserIdFromContext() ?? string.Empty; + } + if (string.IsNullOrWhiteSpace(seed.AgentName)) { seed.AgentName = SigilContext.AgentNameFromContext() ?? string.Empty; @@ -443,6 +462,8 @@ private GenerationRecorder StartGenerationInternal(GenerationStart start, Genera { Id = seed.Id, ConversationId = seed.ConversationId, + ConversationTitle = seed.ConversationTitle, + UserId = seed.UserId, AgentName = seed.AgentName, AgentVersion = seed.AgentVersion, Mode = seed.Mode, @@ -1063,6 +1084,16 @@ internal static void ApplyGenerationSpanAttributes(Activity activity, Generation activity.SetTag(SpanAttrConversationId, generation.ConversationId); } + if (!string.IsNullOrWhiteSpace(generation.ConversationTitle)) + { + activity.SetTag(SpanAttrConversationTitle, generation.ConversationTitle); + } + + if (!string.IsNullOrWhiteSpace(generation.UserId)) + { + activity.SetTag(SpanAttrUserId, generation.UserId); + } + if (!string.IsNullOrWhiteSpace(generation.AgentName)) { activity.SetTag(SpanAttrAgentName, generation.AgentName); @@ -1245,6 +1276,11 @@ internal static void ApplyToolSpanAttributes(Activity activity, ToolExecutionSta activity.SetTag(SpanAttrConversationId, tool.ConversationId); } + if (!string.IsNullOrWhiteSpace(tool.ConversationTitle)) + { + activity.SetTag(SpanAttrConversationTitle, tool.ConversationTitle); + } + if (!string.IsNullOrWhiteSpace(tool.AgentName)) { activity.SetTag(SpanAttrAgentName, tool.AgentName); @@ -1254,6 +1290,14 @@ internal static void ApplyToolSpanAttributes(Activity activity, ToolExecutionSta { activity.SetTag(SpanAttrAgentVersion, tool.AgentVersion); } + if (!string.IsNullOrWhiteSpace(tool.RequestProvider)) + { + activity.SetTag(SpanAttrProviderName, tool.RequestProvider); + } + if (!string.IsNullOrWhiteSpace(tool.RequestModel)) + { + activity.SetTag(SpanAttrRequestModel, tool.RequestModel); + } } internal static string OperationName(Generation generation) @@ -1381,8 +1425,9 @@ internal void RecordToolExecutionMetrics( new KeyValuePair[] { new(SpanAttrOperationName, "execute_tool"), - new(SpanAttrProviderName, string.Empty), - new(SpanAttrRequestModel, seed.ToolName ?? string.Empty), + new(SpanAttrProviderName, (seed.RequestProvider ?? string.Empty).Trim()), + new(SpanAttrRequestModel, (seed.RequestModel ?? string.Empty).Trim()), + new(SpanAttrToolName, (seed.ToolName ?? string.Empty).Trim()), new(SpanAttrAgentName, seed.AgentName ?? string.Empty), new(SpanAttrErrorType, errorType), new(SpanAttrErrorCategory, errorCategory), @@ -1941,6 +1986,8 @@ private Generation NormalizeGeneration(Generation raw, DateTimeOffset completedA generation.Id = FirstNonEmpty(generation.Id, _seed.Id, InternalUtils.NewRandomId("gen")); generation.ConversationId = FirstNonEmpty(generation.ConversationId, _seed.ConversationId); + generation.ConversationTitle = FirstNonEmpty(generation.ConversationTitle, _seed.ConversationTitle); + generation.UserId = FirstNonEmpty(generation.UserId, _seed.UserId); generation.AgentName = FirstNonEmpty(generation.AgentName, _seed.AgentName); generation.AgentVersion = FirstNonEmpty(generation.AgentVersion, _seed.AgentVersion); generation.Mode ??= _seed.Mode ?? GenerationMode.Sync; @@ -1967,6 +2014,27 @@ private Generation NormalizeGeneration(Generation raw, DateTimeOffset completedA generation.Tags = Merge(_seed.Tags, generation.Tags); generation.Metadata = Merge(_seed.Metadata, generation.Metadata); + generation.ConversationTitle = FirstNonEmpty( + generation.ConversationTitle, + MetadataString(generation.Metadata, SigilClient.SpanAttrConversationTitle) + ); + generation.ConversationTitle = NormalizeResolvedString(generation.ConversationTitle); + if (!string.IsNullOrWhiteSpace(generation.ConversationTitle)) + { + generation.Metadata[SigilClient.SpanAttrConversationTitle] = generation.ConversationTitle; + } + + generation.UserId = FirstNonEmpty( + generation.UserId, + MetadataString(generation.Metadata, SigilClient.MetadataUserIdKey), + MetadataString(generation.Metadata, SigilClient.MetadataLegacyUserIdKey) + ); + generation.UserId = NormalizeResolvedString(generation.UserId); + if (!string.IsNullOrWhiteSpace(generation.UserId)) + { + generation.Metadata[SigilClient.MetadataUserIdKey] = generation.UserId; + } + generation.StartedAt = generation.StartedAt.HasValue ? InternalUtils.Utc(generation.StartedAt.Value) : _startedAt; @@ -2002,6 +2070,22 @@ private static string FirstNonEmpty(params string[] values) return string.Empty; } + private static string MetadataString(IReadOnlyDictionary metadata, string key) + { + if (!metadata.TryGetValue(key, out var value) || value == null) + { + return string.Empty; + } + + var text = value.ToString()?.Trim() ?? string.Empty; + return text; + } + + private static string NormalizeResolvedString(string value) + { + return value?.Trim() ?? string.Empty; + } + private static Dictionary Merge( IReadOnlyDictionary left, IReadOnlyDictionary right diff --git a/dotnet/src/Grafana.Sigil/SigilContext.cs b/dotnet/src/Grafana.Sigil/SigilContext.cs index 1883e43..c89a413 100644 --- a/dotnet/src/Grafana.Sigil/SigilContext.cs +++ b/dotnet/src/Grafana.Sigil/SigilContext.cs @@ -5,6 +5,8 @@ namespace Grafana.Sigil; public static class SigilContext { private static readonly AsyncLocal ConversationIdSlot = new(); + private static readonly AsyncLocal ConversationTitleSlot = new(); + private static readonly AsyncLocal UserIdSlot = new(); private static readonly AsyncLocal AgentNameSlot = new(); private static readonly AsyncLocal AgentVersionSlot = new(); @@ -13,6 +15,16 @@ public static IDisposable WithConversationId(string conversationId) return new AsyncLocalScope(ConversationIdSlot, conversationId); } + public static IDisposable WithConversationTitle(string conversationTitle) + { + return new AsyncLocalScope(ConversationTitleSlot, conversationTitle); + } + + public static IDisposable WithUserId(string userId) + { + return new AsyncLocalScope(UserIdSlot, userId); + } + public static IDisposable WithAgentName(string agentName) { return new AsyncLocalScope(AgentNameSlot, agentName); @@ -28,6 +40,16 @@ public static IDisposable WithAgentVersion(string agentVersion) return ConversationIdSlot.Value; } + public static string? ConversationTitleFromContext() + { + return ConversationTitleSlot.Value; + } + + public static string? UserIdFromContext() + { + return UserIdSlot.Value; + } + public static string? AgentNameFromContext() { return AgentNameSlot.Value; diff --git a/dotnet/tests/Grafana.Sigil.Anthropic.Tests/AnthropicMappingAndRecorderTests.cs b/dotnet/tests/Grafana.Sigil.Anthropic.Tests/AnthropicConformanceTests.cs similarity index 84% rename from dotnet/tests/Grafana.Sigil.Anthropic.Tests/AnthropicMappingAndRecorderTests.cs rename to dotnet/tests/Grafana.Sigil.Anthropic.Tests/AnthropicConformanceTests.cs index cdeed8e..731651d 100644 --- a/dotnet/tests/Grafana.Sigil.Anthropic.Tests/AnthropicMappingAndRecorderTests.cs +++ b/dotnet/tests/Grafana.Sigil.Anthropic.Tests/AnthropicConformanceTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Diagnostics; using System.Reflection; using Anthropic.Models.Messages; using Xunit; @@ -6,7 +7,7 @@ namespace Grafana.Sigil.Anthropic.Tests; -public sealed class AnthropicMappingAndRecorderTests +public sealed class AnthropicConformanceTests { [Fact] public void FromRequestResponse_MapsSyncModeAndDefaultsRawArtifactsOff() @@ -119,6 +120,69 @@ await Assert.ThrowsAsync(() => AnthropicRecorder.Mess Assert.Contains(generations, generation => generation.Mode == GenerationMode.Stream); } + [Fact] + public async Task Recorder_StreamMappingErrors_PreserveReturnedSummaries_AndMarkSpans() + { + var exporter = new CapturingExporter(); + var spans = new List(); + using var listener = NewGenerationListener(spans); + ActivitySource.AddActivityListener(listener); + + await using var client = new SigilClient(new SigilClientConfig + { + GenerationExporter = exporter, + GenerationExport = new GenerationExportConfig + { + BatchSize = 1, + QueueSize = 10, + FlushInterval = TimeSpan.FromHours(1), + }, + }); + + var summary = await AnthropicRecorder.MessageStreamAsync( + client, + CreateRequest(), + (_, _) => EmptyStreamEvents(), + new AnthropicSigilOptions + { + ModelName = "claude-sonnet-4-5", + } + ); + + Assert.Empty(summary.Events); + + await client.FlushAsync(); + await client.ShutdownAsync(); + + var generations = exporter.Requests.SelectMany(request => request.Generations).ToList(); + Assert.Single(generations); + Assert.Equal(GenerationMode.Stream, generations[0].Mode); + Assert.Equal(string.Empty, generations[0].CallError); + Assert.Single(spans, span => span.GetTagItem("error.type")?.ToString() == "mapping_error"); + } + + [Fact] + public void MapperRejectsMissingOrMalformedResponses() + { + Assert.Throws(() => AnthropicGenerationMapper.FromRequestResponse( + CreateRequest(), + response: null!, + new AnthropicSigilOptions() + )); + Assert.Throws(() => AnthropicGenerationMapper.FromStream( + CreateRequest(), + new AnthropicStreamSummary(), + new AnthropicSigilOptions() + )); + } + + [Fact] + public void EmbeddingConformance_IsExplicitlyUnsupportedWithoutPublicSurface() + { + Assert.NotNull(typeof(AnthropicRecorder)); + Assert.Null(typeof(AnthropicRecorder).Assembly.GetType("Grafana.Sigil.Anthropic.AnthropicEmbeddings")); + } + private static MessageCreateParams CreateRequest() { var request = new MessageCreateParams @@ -199,6 +263,12 @@ private static long ReadMetadataLong(Generation generation, string key) }; } + private static async IAsyncEnumerable EmptyStreamEvents() + { + await Task.CompletedTask; + yield break; + } + private static AnthropicMessage CreateResponse() { var usage = new Usage @@ -228,6 +298,7 @@ private static AnthropicMessage CreateResponse() return new AnthropicMessage { + Container = default!, ID = "msg_1", Content = new List { @@ -265,6 +336,7 @@ private static RawMessageStreamEvent CreateMessageStartEvent(string id, string t Type = JsonSerializer.SerializeToElement("message_start"), Message = new AnthropicMessage { + Container = default!, ID = id, Model = Model.ClaudeSonnet4_5, Content = new List @@ -335,6 +407,7 @@ private static RawMessageStreamEvent CreateMessageDeltaEvent( Type = JsonSerializer.SerializeToElement("message_delta"), Delta = new Delta { + Container = default!, StopReason = StopReason.EndTurn, StopSequence = null, }, @@ -486,4 +559,20 @@ public Task ShutdownAsync(CancellationToken cancellationToken) return Task.CompletedTask; } } + + private static ActivityListener NewGenerationListener(List spans) + { + return new ActivityListener + { + ShouldListenTo = source => source.Name == "github.com/grafana/sigil/sdks/dotnet", + Sample = static (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => + { + if (activity.GetTagItem("gen_ai.operation.name")?.ToString() != "execute_tool") + { + spans.Add(activity); + } + }, + }; + } } diff --git a/dotnet/tests/Grafana.Sigil.Anthropic.Tests/Grafana.Sigil.Anthropic.Tests.csproj b/dotnet/tests/Grafana.Sigil.Anthropic.Tests/Grafana.Sigil.Anthropic.Tests.csproj index 62a5d95..cf3d66c 100644 --- a/dotnet/tests/Grafana.Sigil.Anthropic.Tests/Grafana.Sigil.Anthropic.Tests.csproj +++ b/dotnet/tests/Grafana.Sigil.Anthropic.Tests/Grafana.Sigil.Anthropic.Tests.csproj @@ -8,7 +8,7 @@ - + diff --git a/dotnet/tests/Grafana.Sigil.Gemini.Tests/GeminiMappingAndRecorderTests.cs b/dotnet/tests/Grafana.Sigil.Gemini.Tests/GeminiConformanceTests.cs similarity index 88% rename from dotnet/tests/Grafana.Sigil.Gemini.Tests/GeminiMappingAndRecorderTests.cs rename to dotnet/tests/Grafana.Sigil.Gemini.Tests/GeminiConformanceTests.cs index d092a03..644f2a2 100644 --- a/dotnet/tests/Grafana.Sigil.Gemini.Tests/GeminiMappingAndRecorderTests.cs +++ b/dotnet/tests/Grafana.Sigil.Gemini.Tests/GeminiConformanceTests.cs @@ -1,11 +1,12 @@ using Google.GenAI.Types; +using System.Diagnostics; using System.Reflection; using Xunit; using GPart = Google.GenAI.Types.Part; namespace Grafana.Sigil.Gemini.Tests; -public sealed class GeminiMappingAndRecorderTests +public sealed class GeminiConformanceTests { private const string DefaultModel = "gemini-2.5-pro"; @@ -186,6 +187,68 @@ await Assert.ThrowsAsync(() => GeminiRecorder.Generat Assert.Contains(generations, generation => generation.Mode == GenerationMode.Stream); } + [Fact] + public async Task Recorder_StreamMappingErrors_PreserveReturnedSummaries_AndMarkSpans() + { + var exporter = new CapturingExporter(); + var spans = new List(); + using var listener = NewGenerationListener(spans); + ActivitySource.AddActivityListener(listener); + + await using var client = new SigilClient(new SigilClientConfig + { + GenerationExporter = exporter, + GenerationExport = new GenerationExportConfig + { + BatchSize = 1, + QueueSize = 10, + FlushInterval = TimeSpan.FromHours(1), + }, + }); + + var summary = await GeminiRecorder.GenerateContentStreamAsync( + client, + DefaultModel, + CreateContents(), + (_, _, _, _) => EmptyStreamResponses(), + CreateConfig(), + new GeminiSigilOptions + { + ModelName = DefaultModel, + } + ); + + Assert.Empty(summary.Responses); + + await client.FlushAsync(); + await client.ShutdownAsync(); + + var generations = exporter.Requests.SelectMany(request => request.Generations).ToList(); + Assert.Single(generations); + Assert.Equal(GenerationMode.Stream, generations[0].Mode); + Assert.Equal(string.Empty, generations[0].CallError); + Assert.Single(spans, span => span.GetTagItem("error.type")?.ToString() == "mapping_error"); + } + + [Fact] + public void MapperRejectsMissingOrMalformedResponses() + { + Assert.Throws(() => GeminiGenerationMapper.FromRequestResponse( + DefaultModel, + CreateContents(), + CreateConfig(), + response: null!, + new GeminiSigilOptions() + )); + Assert.Throws(() => GeminiGenerationMapper.FromStream( + DefaultModel, + CreateContents(), + CreateConfig(), + new GeminiStreamSummary(), + new GeminiSigilOptions() + )); + } + [Fact] public void EmbeddingFromResponse_MapsInputCountUsageAndDimensions() { @@ -637,6 +700,12 @@ private static async IAsyncEnumerable StreamResponses() await Task.CompletedTask; } + private static async IAsyncEnumerable EmptyStreamResponses() + { + await Task.CompletedTask; + yield break; + } + private sealed class CapturingExporter : IGenerationExporter { public List Requests { get; } = new(); @@ -659,4 +728,20 @@ public Task ShutdownAsync(CancellationToken cancellationToken) return Task.CompletedTask; } } + + private static ActivityListener NewGenerationListener(List spans) + { + return new ActivityListener + { + ShouldListenTo = source => source.Name == "github.com/grafana/sigil/sdks/dotnet", + Sample = static (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => + { + if (activity.GetTagItem("gen_ai.operation.name")?.ToString() != "execute_tool") + { + spans.Add(activity); + } + }, + }; + } } diff --git a/dotnet/tests/Grafana.Sigil.Gemini.Tests/Grafana.Sigil.Gemini.Tests.csproj b/dotnet/tests/Grafana.Sigil.Gemini.Tests/Grafana.Sigil.Gemini.Tests.csproj index 1304ae0..3427a2c 100644 --- a/dotnet/tests/Grafana.Sigil.Gemini.Tests/Grafana.Sigil.Gemini.Tests.csproj +++ b/dotnet/tests/Grafana.Sigil.Gemini.Tests/Grafana.Sigil.Gemini.Tests.csproj @@ -8,7 +8,7 @@ - + diff --git a/dotnet/tests/Grafana.Sigil.OpenAI.Tests/Grafana.Sigil.OpenAI.Tests.csproj b/dotnet/tests/Grafana.Sigil.OpenAI.Tests/Grafana.Sigil.OpenAI.Tests.csproj index b4dc888..b7a9ce9 100644 --- a/dotnet/tests/Grafana.Sigil.OpenAI.Tests/Grafana.Sigil.OpenAI.Tests.csproj +++ b/dotnet/tests/Grafana.Sigil.OpenAI.Tests/Grafana.Sigil.OpenAI.Tests.csproj @@ -9,7 +9,7 @@ - + diff --git a/dotnet/tests/Grafana.Sigil.OpenAI.Tests/OpenAIMappingAndRecorderTests.cs b/dotnet/tests/Grafana.Sigil.OpenAI.Tests/OpenAIConformanceTests.cs similarity index 82% rename from dotnet/tests/Grafana.Sigil.OpenAI.Tests/OpenAIMappingAndRecorderTests.cs rename to dotnet/tests/Grafana.Sigil.OpenAI.Tests/OpenAIConformanceTests.cs index 1d97c02..1225fd2 100644 --- a/dotnet/tests/Grafana.Sigil.OpenAI.Tests/OpenAIMappingAndRecorderTests.cs +++ b/dotnet/tests/Grafana.Sigil.OpenAI.Tests/OpenAIConformanceTests.cs @@ -1,4 +1,5 @@ using System.ClientModel.Primitives; +using System.Diagnostics; using System.Reflection; using OpenAI.Chat; using OpenAI.Embeddings; @@ -7,7 +8,7 @@ namespace Grafana.Sigil.OpenAI.Tests; -public sealed class OpenAIMappingAndRecorderTests +public sealed class OpenAIConformanceTests { [Fact] public void ChatCompletionsFromRequestResponse_MapsSyncModeAndDefaultsRawArtifactsOff() @@ -321,6 +322,48 @@ public void ResponsesFromStream_MapsStreamModeWhenOnlyEventsPresent() Assert.Contains(generation.Artifacts, artifact => artifact.Kind == ArtifactKind.ProviderEvent && artifact.Name == "openai.responses.stream_events"); } + [Fact] + public void MapperRejectsMissingOrMalformedResponses() + { + var chatMessages = new List + { + new UserChatMessage("hello"), + }; + var responseItems = new List + { + ResponseItem.CreateUserMessageItem("hello"), + }; + + Assert.Throws(() => OpenAIGenerationMapper.ChatCompletionsFromRequestResponse( + "gpt-5", + chatMessages, + requestOptions: null, + response: null!, + new OpenAISigilOptions() + )); + Assert.Throws(() => OpenAIGenerationMapper.ResponsesFromRequestResponse( + "gpt-5", + responseItems, + requestOptions: null, + response: null!, + new OpenAISigilOptions() + )); + Assert.Throws(() => OpenAIGenerationMapper.ChatCompletionsFromStream( + "gpt-5", + chatMessages, + requestOptions: null, + new OpenAIChatCompletionsStreamSummary(), + new OpenAISigilOptions() + )); + Assert.Throws(() => OpenAIGenerationMapper.ResponsesFromStream( + "gpt-5", + responseItems, + requestOptions: null, + new OpenAIResponsesStreamSummary(), + new OpenAISigilOptions() + )); + } + [Fact] public async Task Recorder_RecordsChatAndResponsesModesAndPropagatesProviderErrors() { @@ -407,6 +450,63 @@ await Assert.ThrowsAsync(() => OpenAIRecorder.CreateR Assert.True(generations.Count(generation => generation.Mode == GenerationMode.Stream) >= 2); } + [Fact] + public async Task Recorder_StreamMappingErrors_PreserveReturnedSummaries_AndMarkSpans() + { + var exporter = new CapturingExporter(); + var spans = new List(); + using var listener = NewGenerationListener(spans); + ActivitySource.AddActivityListener(listener); + + await using var client = new SigilClient(new SigilClientConfig + { + GenerationExporter = exporter, + GenerationExport = new GenerationExportConfig + { + BatchSize = 1, + QueueSize = 10, + FlushInterval = TimeSpan.FromHours(1), + }, + }); + + var chatSummary = await OpenAIRecorder.CompleteChatStreamingAsync( + client, + new List { new UserChatMessage("hello") }, + (_, _, _) => EmptyChatUpdates(), + requestOptions: null, + options: new OpenAISigilOptions + { + ModelName = "gpt-5", + } + ); + + var responsesSummary = await OpenAIRecorder.CreateResponseStreamingAsync( + client, + new List { ResponseItem.CreateUserMessageItem("hello") }, + (_, _, _) => EmptyResponsesUpdates(), + requestOptions: new CreateResponseOptions(), + options: new OpenAISigilOptions + { + ModelName = "gpt-5", + } + ); + + Assert.Empty(chatSummary.Updates); + Assert.Empty(responsesSummary.Events); + + await client.FlushAsync(); + await client.ShutdownAsync(); + + var generations = exporter.Requests.SelectMany(request => request.Generations).ToList(); + Assert.Equal(2, generations.Count); + Assert.All(generations, generation => + { + Assert.Equal(GenerationMode.Stream, generation.Mode); + Assert.Equal(string.Empty, generation.CallError); + }); + Assert.Equal(2, spans.Count(span => span.GetTagItem("error.type")?.ToString() == "mapping_error")); + } + [Fact] public void EmbeddingsFromRequestResponse_MapsInputCountUsageAndDimensions() { @@ -567,6 +667,34 @@ private static async IAsyncEnumerable StreamResponsesUp await Task.CompletedTask; } + private static async IAsyncEnumerable EmptyChatUpdates() + { + await Task.CompletedTask; + yield break; + } + + private static async IAsyncEnumerable EmptyResponsesUpdates() + { + await Task.CompletedTask; + yield break; + } + + private static ActivityListener NewGenerationListener(List spans) + { + return new ActivityListener + { + ShouldListenTo = source => source.Name == "github.com/grafana/sigil/sdks/dotnet", + Sample = static (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => + { + if (activity.GetTagItem("gen_ai.operation.name")?.ToString() != "execute_tool") + { + spans.Add(activity); + } + }, + }; + } + private sealed class CapturingExporter : IGenerationExporter { public List Requests { get; } = new(); diff --git a/dotnet/tests/Grafana.Sigil.Tests/AuthConfigTests.cs b/dotnet/tests/Grafana.Sigil.Tests/AuthConfigTests.cs index 7701fd2..93e6bf6 100644 --- a/dotnet/tests/Grafana.Sigil.Tests/AuthConfigTests.cs +++ b/dotnet/tests/Grafana.Sigil.Tests/AuthConfigTests.cs @@ -1,3 +1,4 @@ +using System.Text; using Xunit; namespace Grafana.Sigil.Tests; @@ -27,7 +28,7 @@ public sealed class AuthConfigTests Mode = ExportAuthMode.None, TenantId = "tenant-a", }, - "generation auth mode 'none' does not allow tenant_id or bearer_token" + "generation auth mode 'none' does not allow credentials" }, { new AuthConfig @@ -54,6 +55,37 @@ public sealed class AuthConfigTests }, "unsupported generation auth mode" }, + { + new AuthConfig + { + Mode = ExportAuthMode.None, + BasicUser = "user", + }, + "generation auth mode 'none' does not allow credentials" + }, + { + new AuthConfig + { + Mode = ExportAuthMode.None, + BasicPassword = "secret", + }, + "generation auth mode 'none' does not allow credentials" + }, + { + new AuthConfig + { + Mode = ExportAuthMode.Basic, + }, + "generation auth mode 'basic' requires basic_password" + }, + { + new AuthConfig + { + Mode = ExportAuthMode.Basic, + BasicPassword = "secret", + }, + "generation auth mode 'basic' requires basic_user or tenant_id" + }, }; [Theory] @@ -99,4 +131,60 @@ public async Task Constructor_PreservesExplicitGenerationAuthorizationHeader() Assert.Equal("Bearer override-token", config.GenerationExport.Headers["authorization"]); } + [Fact] + public async Task Constructor_AppliesBasicAuthWithTenantId() + { + var config = TestHelpers.TestConfig(new CapturingGenerationExporter()); + config.GenerationExport.Auth = new AuthConfig + { + Mode = ExportAuthMode.Basic, + TenantId = "42", + BasicPassword = "secret", + }; + + await using var client = new SigilClient(config); + + var expected = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("42:secret")); + Assert.Equal(expected, config.GenerationExport.Headers["Authorization"]); + Assert.Equal("42", config.GenerationExport.Headers["X-Scope-OrgID"]); + } + + [Fact] + public async Task Constructor_AppliesBasicAuthWithExplicitUser() + { + var config = TestHelpers.TestConfig(new CapturingGenerationExporter()); + config.GenerationExport.Auth = new AuthConfig + { + Mode = ExportAuthMode.Basic, + TenantId = "42", + BasicUser = "probe-user", + BasicPassword = "secret", + }; + + await using var client = new SigilClient(config); + + var expected = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("probe-user:secret")); + Assert.Equal(expected, config.GenerationExport.Headers["Authorization"]); + Assert.Equal("42", config.GenerationExport.Headers["X-Scope-OrgID"]); + } + + [Fact] + public async Task Constructor_BasicAuthExplicitHeaderWins() + { + var config = TestHelpers.TestConfig(new CapturingGenerationExporter()); + config.GenerationExport.Headers["Authorization"] = "Basic override"; + config.GenerationExport.Headers["X-Scope-OrgID"] = "override-tenant"; + config.GenerationExport.Auth = new AuthConfig + { + Mode = ExportAuthMode.Basic, + TenantId = "42", + BasicPassword = "secret", + }; + + await using var client = new SigilClient(config); + + Assert.Equal("Basic override", config.GenerationExport.Headers["Authorization"]); + Assert.Equal("override-tenant", config.GenerationExport.Headers["X-Scope-OrgID"]); + } + } diff --git a/dotnet/tests/Grafana.Sigil.Tests/ConformanceTests.cs b/dotnet/tests/Grafana.Sigil.Tests/ConformanceTests.cs new file mode 100644 index 0000000..5571025 --- /dev/null +++ b/dotnet/tests/Grafana.Sigil.Tests/ConformanceTests.cs @@ -0,0 +1,639 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Linq; +using System.Text; +using Xunit; +using SigilProto = Sigil.V1; + +namespace Grafana.Sigil.Tests; + +public sealed class ConformanceTests +{ + [Fact] + public async Task SyncRoundtripSemantics() + { + await using var env = new ConformanceEnv(); + var requestArtifact = Artifact.JsonArtifact(ArtifactKind.Request, "request", new { ok = true }); + var responseArtifact = Artifact.JsonArtifact(ArtifactKind.Response, "response", new { status = "ok" }); + var recorder = env.Client.StartGeneration(new GenerationStart + { + Id = "gen-roundtrip", + ConversationId = "conv-roundtrip", + ConversationTitle = "Roundtrip conversation", + UserId = "user-roundtrip", + AgentName = "agent-roundtrip", + AgentVersion = "v-roundtrip", + Model = new ModelRef { Provider = "openai", Name = "gpt-5" }, + SystemPrompt = "be concise", + MaxTokens = 256, + Temperature = 0.2, + TopP = 0.9, + ToolChoice = "required", + ThinkingEnabled = false, + Tools = + { + new ToolDefinition + { + Name = "weather", + Description = "Get weather", + Type = "function", + InputSchemaJson = Encoding.UTF8.GetBytes("{\"type\":\"object\"}"), + }, + }, + Tags = new Dictionary(StringComparer.Ordinal) { ["tenant"] = "dev" }, + Metadata = new Dictionary(StringComparer.Ordinal) + { + ["trace"] = "roundtrip", + ["sigil.gen_ai.request.thinking.budget_tokens"] = 2048L, + }, + }); + recorder.SetResult(new Generation + { + ResponseId = "resp-roundtrip", + ResponseModel = "gpt-5-2026", + Input = + { + Message.UserTextMessage("hello"), + }, + Output = + { + new Message + { + Role = MessageRole.Assistant, + Parts = + { + Part.ThinkingPart("reasoning"), + Part.ToolCallPart(new ToolCall + { + Id = "call-1", + Name = "weather", + InputJson = Encoding.UTF8.GetBytes("{\"city\":\"Paris\"}"), + }), + Part.TextPart("Checking weather"), + }, + }, + new Message + { + Role = MessageRole.Tool, + Parts = + { + Part.ToolResultPart(new ToolResult + { + ToolCallId = "call-1", + Name = "weather", + Content = "sunny", + ContentJson = Encoding.UTF8.GetBytes("{\"temp_c\":18}"), + }), + }, + }, + }, + Usage = new TokenUsage + { + InputTokens = 12, + OutputTokens = 7, + TotalTokens = 19, + CacheReadInputTokens = 2, + CacheWriteInputTokens = 1, + CacheCreationInputTokens = 3, + ReasoningTokens = 4, + }, + StopReason = "stop", + Tags = new Dictionary(StringComparer.Ordinal) { ["region"] = "eu" }, + Metadata = new Dictionary(StringComparer.Ordinal) { ["result"] = "ok" }, + Artifacts = + { + requestArtifact, + responseArtifact, + }, + }); + recorder.End(); + + await env.ShutdownAsync(); + + var generation = env.SingleGeneration(); + var span = env.GenerationSpan(); + + Assert.Equal(SigilProto.GenerationMode.Sync, generation.Mode); + Assert.Equal("generateText", generation.OperationName); + Assert.Equal("conv-roundtrip", generation.ConversationId); + Assert.Equal("agent-roundtrip", generation.AgentName); + Assert.Equal("agent-roundtrip", recorder.LastGeneration!.AgentName); + Assert.Equal("v-roundtrip", generation.AgentVersion); + Assert.Equal(span.TraceId.ToHexString(), generation.TraceId); + Assert.Equal(span.SpanId.ToHexString(), generation.SpanId); + Assert.Equal("be concise", generation.SystemPrompt); + Assert.Equal("Roundtrip conversation", generation.Metadata.Fields["sigil.conversation.title"].StringValue); + Assert.Equal("user-roundtrip", generation.Metadata.Fields["sigil.user.id"].StringValue); + Assert.Equal("sdk-dotnet", generation.Metadata.Fields["sigil.sdk.name"].StringValue); + Assert.Equal("hello", generation.Input[0].Parts[0].Text); + Assert.Equal("reasoning", generation.Output[0].Parts[0].Thinking); + Assert.Equal("weather", generation.Output[0].Parts[1].ToolCall.Name); + Assert.Equal("Checking weather", generation.Output[0].Parts[2].Text); + Assert.Equal("sunny", generation.Output[1].Parts[0].ToolResult.Content); + Assert.Equal(256L, generation.MaxTokens); + Assert.Equal(0.2, generation.Temperature, 10); + Assert.Equal(0.9, generation.TopP, 10); + Assert.Equal("required", generation.ToolChoice); + Assert.False(generation.ThinkingEnabled); + Assert.Equal(12L, generation.Usage.InputTokens); + Assert.Equal(7L, generation.Usage.OutputTokens); + Assert.Equal(19L, generation.Usage.TotalTokens); + Assert.Equal(2L, generation.Usage.CacheReadInputTokens); + Assert.Equal(1L, generation.Usage.CacheWriteInputTokens); + Assert.Equal(4L, generation.Usage.ReasoningTokens); + Assert.Equal("stop", generation.StopReason); + Assert.Equal("dev", generation.Tags["tenant"]); + Assert.Equal("eu", generation.Tags["region"]); + Assert.Equal(2, generation.RawArtifacts.Count); + Assert.Equal("generateText", span.GetTagItem("gen_ai.operation.name")?.ToString()); + Assert.Equal("Roundtrip conversation", span.GetTagItem("sigil.conversation.title")?.ToString()); + Assert.Equal("user-roundtrip", span.GetTagItem("user.id")?.ToString()); + Assert.Contains("gen_ai.client.operation.duration", env.MetricNames); + Assert.Contains("gen_ai.client.token.usage", env.MetricNames); + Assert.DoesNotContain("gen_ai.client.time_to_first_token", env.MetricNames); + } + + [Theory] + [InlineData("Explicit", "Context", "Meta", "Explicit")] + [InlineData("", "Context", "", "Context")] + [InlineData("", "", "Meta", "Meta")] + [InlineData(" Padded ", "", "", "Padded")] + [InlineData(" ", "", "", "")] + public async Task ConversationTitleSemantics(string startTitle, string contextTitle, string metadataTitle, string expected) + { + await using var env = new ConformanceEnv(); + using var titleScope = contextTitle.Length > 0 ? SigilContext.WithConversationTitle(contextTitle) : NullScope.Instance; + + var start = new GenerationStart + { + Model = new ModelRef { Provider = "openai", Name = "gpt-5" }, + ConversationTitle = startTitle, + }; + if (metadataTitle.Length > 0) + { + start.Metadata["sigil.conversation.title"] = metadataTitle; + } + + var recorder = env.Client.StartGeneration(start); + recorder.SetResult(new Generation()); + recorder.End(); + + await env.ShutdownAsync(); + + var generation = env.SingleGeneration(); + var span = env.GenerationSpan(); + if (expected.Length == 0) + { + Assert.False(generation.Metadata.Fields.ContainsKey("sigil.conversation.title")); + Assert.Null(span.GetTagItem("sigil.conversation.title")); + return; + } + + Assert.Equal(expected, generation.Metadata.Fields["sigil.conversation.title"].StringValue); + Assert.Equal(expected, span.GetTagItem("sigil.conversation.title")?.ToString()); + } + + [Theory] + [InlineData("explicit", "ctx", "canonical", "legacy", "explicit")] + [InlineData("", "ctx", "", "", "ctx")] + [InlineData("", "", "canonical", "", "canonical")] + [InlineData("", "", "", "legacy", "legacy")] + [InlineData("", "", "canonical", "legacy", "canonical")] + [InlineData(" padded ", "", "", "", "padded")] + public async Task UserIdSemantics(string startUserId, string contextUserId, string canonicalUserId, string legacyUserId, string expected) + { + await using var env = new ConformanceEnv(); + using var userScope = contextUserId.Length > 0 ? SigilContext.WithUserId(contextUserId) : NullScope.Instance; + + var start = new GenerationStart + { + Model = new ModelRef { Provider = "openai", Name = "gpt-5" }, + UserId = startUserId, + }; + if (canonicalUserId.Length > 0) + { + start.Metadata["sigil.user.id"] = canonicalUserId; + } + if (legacyUserId.Length > 0) + { + start.Metadata["user.id"] = legacyUserId; + } + + var recorder = env.Client.StartGeneration(start); + recorder.SetResult(new Generation()); + recorder.End(); + + await env.ShutdownAsync(); + + var generation = env.SingleGeneration(); + var span = env.GenerationSpan(); + Assert.Equal(expected, generation.Metadata.Fields["sigil.user.id"].StringValue); + Assert.Equal(expected, span.GetTagItem("user.id")?.ToString()); + } + + [Theory] + [InlineData("agent-explicit", "v1.2.3", "", "", "", "", "agent-explicit", "v1.2.3")] + [InlineData("", "", "agent-context", "v-context", "", "", "agent-context", "v-context")] + [InlineData("agent-seed", "v-seed", "", "", "agent-result", "v-result", "agent-result", "v-result")] + [InlineData("", "", "", "", "", "", "", "")] + public async Task AgentIdentitySemantics( + string startName, + string startVersion, + string contextName, + string contextVersion, + string resultName, + string resultVersion, + string expectedName, + string expectedVersion + ) + { + await using var env = new ConformanceEnv(); + using var agentNameScope = contextName.Length > 0 ? SigilContext.WithAgentName(contextName) : NullScope.Instance; + using var agentVersionScope = contextVersion.Length > 0 ? SigilContext.WithAgentVersion(contextVersion) : NullScope.Instance; + + var recorder = env.Client.StartGeneration(new GenerationStart + { + Model = new ModelRef { Provider = "openai", Name = "gpt-5" }, + AgentName = startName, + AgentVersion = startVersion, + }); + recorder.SetResult(new Generation + { + AgentName = resultName, + AgentVersion = resultVersion, + }); + recorder.End(); + + await env.ShutdownAsync(); + + var generation = env.SingleGeneration(); + var span = env.GenerationSpan(); + Assert.Equal(expectedName, generation.AgentName); + Assert.Equal(expectedVersion, generation.AgentVersion); + Assert.Equal(expectedName.Length == 0 ? null : expectedName, span.GetTagItem("gen_ai.agent.name")?.ToString()); + Assert.Equal(expectedVersion.Length == 0 ? null : expectedVersion, span.GetTagItem("gen_ai.agent.version")?.ToString()); + } + + [Fact] + public async Task StreamingTelemetrySemantics() + { + await using var env = new ConformanceEnv(); + var start = new GenerationStart + { + Model = new ModelRef { Provider = "openai", Name = "gpt-5" }, + StartedAt = new DateTimeOffset(2026, 3, 12, 9, 0, 0, TimeSpan.Zero), + }; + var recorder = env.Client.StartStreamingGeneration(start); + recorder.SetFirstTokenAt(start.StartedAt.Value.AddMilliseconds(250)); + recorder.SetResult(new Generation + { + Usage = new TokenUsage { InputTokens = 4, OutputTokens = 3, TotalTokens = 7 }, + StartedAt = start.StartedAt, + CompletedAt = start.StartedAt.Value.AddSeconds(1), + }); + recorder.End(); + + await env.ShutdownAsync(); + + var generation = env.SingleGeneration(); + var span = env.GenerationSpan(); + Assert.Equal(SigilProto.GenerationMode.Stream, generation.Mode); + Assert.Equal("streamText", generation.OperationName); + Assert.Equal("streamText gpt-5", span.DisplayName); + Assert.Contains("gen_ai.client.operation.duration", env.MetricNames); + Assert.Contains("gen_ai.client.time_to_first_token", env.MetricNames); + } + + [Fact] + public async Task ToolExecutionSemantics() + { + await using var env = new ConformanceEnv(); + using var titleScope = SigilContext.WithConversationTitle("Context title"); + using var agentNameScope = SigilContext.WithAgentName("agent-context"); + using var agentVersionScope = SigilContext.WithAgentVersion("v-context"); + + var recorder = env.Client.StartToolExecution(new ToolExecutionStart + { + ToolName = "weather", + ToolCallId = "call-weather-1", + ToolType = "function", + RequestProvider = "openai", + RequestModel = "gpt-5", + IncludeContent = true, + }); + recorder.SetResult(new ToolExecutionEnd + { + Arguments = new Dictionary(StringComparer.Ordinal) + { + ["city"] = "Paris", + }, + Result = new Dictionary(StringComparer.Ordinal) + { + ["forecast"] = "sunny", + }, + }); + recorder.End(); + + await env.ShutdownAsync(); + + var span = env.OperationSpan("execute_tool"); + Assert.Empty(env.Ingest.Requests); + Assert.Equal("execute_tool weather", span.DisplayName); + Assert.Equal("execute_tool", span.GetTagItem("gen_ai.operation.name")); + Assert.Equal("weather", span.GetTagItem("gen_ai.tool.name")); + Assert.Equal("call-weather-1", span.GetTagItem("gen_ai.tool.call.id")); + Assert.Equal("function", span.GetTagItem("gen_ai.tool.type")); + Assert.Contains("Paris", span.GetTagItem("gen_ai.tool.call.arguments")?.ToString()); + Assert.Contains("sunny", span.GetTagItem("gen_ai.tool.call.result")?.ToString()); + Assert.Equal("openai", span.GetTagItem("gen_ai.provider.name")?.ToString()); + Assert.Equal("gpt-5", span.GetTagItem("gen_ai.request.model")?.ToString()); + Assert.Equal("Context title", span.GetTagItem("sigil.conversation.title")?.ToString()); + Assert.Equal("agent-context", span.GetTagItem("gen_ai.agent.name")?.ToString()); + Assert.Equal("v-context", span.GetTagItem("gen_ai.agent.version")?.ToString()); + Assert.Contains("gen_ai.client.operation.duration", env.MetricNames); + Assert.DoesNotContain("gen_ai.client.time_to_first_token", env.MetricNames); + } + + [Fact] + public async Task EmbeddingSemantics() + { + await using var env = new ConformanceEnv(); + using var agentNameScope = SigilContext.WithAgentName("agent-context"); + using var agentVersionScope = SigilContext.WithAgentVersion("v-context"); + + var recorder = env.Client.StartEmbedding(new EmbeddingStart + { + Model = new ModelRef { Provider = "openai", Name = "text-embedding-3-small" }, + Dimensions = 512, + }); + recorder.SetResult(new EmbeddingResult + { + InputCount = 2, + InputTokens = 8, + InputTexts = { "hello", "world" }, + ResponseModel = "text-embedding-3-small", + Dimensions = 512, + }); + recorder.End(); + + await env.ShutdownAsync(); + + var span = env.OperationSpan("embeddings"); + Assert.Empty(env.Ingest.Requests); + Assert.Equal("embeddings text-embedding-3-small", span.DisplayName); + Assert.Equal("embeddings", span.GetTagItem("gen_ai.operation.name")); + Assert.Equal("agent-context", span.GetTagItem("gen_ai.agent.name")); + Assert.Equal("v-context", span.GetTagItem("gen_ai.agent.version")); + Assert.Equal(2, span.GetTagItem("gen_ai.embeddings.input_count")); + Assert.Equal(512L, span.GetTagItem("gen_ai.embeddings.dimension.count")); + Assert.Equal("text-embedding-3-small", span.GetTagItem("gen_ai.response.model")); + Assert.Contains("gen_ai.client.operation.duration", env.MetricNames); + Assert.Contains("gen_ai.client.token.usage", env.MetricNames); + Assert.DoesNotContain("gen_ai.client.time_to_first_token", env.MetricNames); + Assert.DoesNotContain("gen_ai.client.tool_calls_per_operation", env.MetricNames); + } + + [Fact] + public async Task ValidationAndCallErrorSemantics() + { + await using var env = new ConformanceEnv(); + var invalid = env.Client.StartGeneration(new GenerationStart + { + Model = new ModelRef { Provider = "anthropic", Name = "claude-sonnet-4-5" }, + }); + invalid.SetResult(new Generation + { + Input = + { + new Message + { + Role = MessageRole.User, + Parts = + { + Part.ToolCallPart(new ToolCall { Name = "weather" }), + }, + }, + }, + }); + invalid.End(); + + Assert.NotNull(invalid.Error); + Assert.Empty(env.Ingest.Requests); + Assert.Equal("validation_error", env.GenerationSpan().GetTagItem("error.type")?.ToString()); + + var callError = env.Client.StartGeneration(new GenerationStart + { + Model = new ModelRef { Provider = "openai", Name = "gpt-5" }, + }); + callError.SetCallError(new InvalidOperationException("provider unavailable")); + callError.SetResult(new Generation()); + callError.End(); + + await env.ShutdownAsync(); + + var generation = env.SingleGeneration(); + var spans = env.Spans.ToArray(); + Assert.Null(callError.Error); + Assert.Equal("provider unavailable", generation.CallError); + Assert.Equal("provider unavailable", generation.Metadata.Fields["call_error"].StringValue); + Assert.Equal("provider_call_error", spans[^1].GetTagItem("error.type")?.ToString()); + } + + [Fact] + public async Task RatingSubmissionSemantics() + { + await using var env = new ConformanceEnv(); + var response = await env.Client.SubmitConversationRatingAsync( + "conv-rating", + new SubmitConversationRatingRequest + { + RatingId = "rat-1", + Rating = ConversationRatingValue.Bad, + Comment = "wrong answer", + Metadata = new Dictionary(StringComparer.Ordinal) + { + ["channel"] = "assistant", + }, + } + ); + + await env.ShutdownAsync(); + + Assert.True(env.Rating.Requests.TryDequeue(out var captured)); + Assert.Equal("/api/v1/conversations/conv-rating/ratings", captured.Path); + Assert.Equal("rat-1", response.Rating.RatingId); + Assert.True(response.Summary.HasBadRating); + + using var body = System.Text.Json.JsonDocument.Parse(captured.Body); + Assert.Equal("rat-1", body.RootElement.GetProperty("rating_id").GetString()); + Assert.Equal("CONVERSATION_RATING_VALUE_BAD", body.RootElement.GetProperty("rating").GetString()); + Assert.Equal("wrong answer", body.RootElement.GetProperty("comment").GetString()); + } + + [Fact] + public async Task ShutdownFlushSemantics() + { + await using var env = new ConformanceEnv(batchSize: 10); + var recorder = env.Client.StartGeneration(new GenerationStart + { + ConversationId = "conv-shutdown", + AgentName = "agent-shutdown", + AgentVersion = "v-shutdown", + Model = new ModelRef { Provider = "openai", Name = "gpt-5" }, + }); + recorder.SetResult(new Generation()); + recorder.End(); + + Assert.Empty(env.Ingest.Requests); + await env.ShutdownAsync(); + + var generation = env.SingleGeneration(); + Assert.Equal("conv-shutdown", generation.ConversationId); + Assert.Equal("agent-shutdown", generation.AgentName); + Assert.Equal("v-shutdown", generation.AgentVersion); + } + + private sealed class ConformanceEnv : IAsyncDisposable + { + private bool _shutdown; + private readonly MeterListener _meterListener; + private readonly ActivityListener _activityListener; + + public GrpcIngestServer Ingest { get; } + public RatingCaptureServer Rating { get; } + public SigilClient Client { get; } + public ConcurrentQueue Spans { get; } = new(); + public ConcurrentDictionary MetricNames { get; } = new(StringComparer.Ordinal); + + public ConformanceEnv(int batchSize = 1) + { + _activityListener = new ActivityListener + { + ShouldListenTo = source => source.Name == "github.com/grafana/sigil/sdks/dotnet", + Sample = static (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => + { + Spans.Enqueue(activity); + }, + }; + ActivitySource.AddActivityListener(_activityListener); + + _meterListener = new MeterListener(); + _meterListener.InstrumentPublished += (instrument, listener) => + { + if (instrument.Name.StartsWith("gen_ai.client.", StringComparison.Ordinal)) + { + listener.EnableMeasurementEvents(instrument); + } + }; + _meterListener.SetMeasurementEventCallback((instrument, _, _, _) => + { + MetricNames[instrument.Name] = 0; + }); + _meterListener.Start(); + + Ingest = new GrpcIngestServer(); + Rating = new RatingCaptureServer((_, _, _) => + ( + 200, + "application/json", + Encoding.UTF8.GetBytes( + """ + { + "rating":{ + "rating_id":"rat-1", + "conversation_id":"conv-rating", + "rating":"CONVERSATION_RATING_VALUE_BAD", + "created_at":"2026-03-12T09:00:00Z" + }, + "summary":{ + "total_count":1, + "good_count":0, + "bad_count":1, + "latest_rating":"CONVERSATION_RATING_VALUE_BAD", + "latest_rated_at":"2026-03-12T09:00:00Z", + "has_bad_rating":true + } + } + """ + ) + ) + ); + Client = new SigilClient(new SigilClientConfig + { + Api = new ApiConfig + { + Endpoint = $"http://127.0.0.1:{Rating.Port}", + }, + GenerationExport = new GenerationExportConfig + { + Protocol = GenerationExportProtocol.Grpc, + Endpoint = $"127.0.0.1:{Ingest.Port}", + Insecure = true, + BatchSize = batchSize, + QueueSize = 10, + FlushInterval = TimeSpan.FromHours(1), + MaxRetries = 1, + InitialBackoff = TimeSpan.FromMilliseconds(1), + MaxBackoff = TimeSpan.FromMilliseconds(2), + }, + }); + } + + public async Task ShutdownAsync() + { + if (_shutdown) + { + return; + } + + _shutdown = true; + await Client.ShutdownAsync(); + _meterListener.Dispose(); + _activityListener.Dispose(); + Ingest.Dispose(); + Rating.Dispose(); + } + + public SigilProto.Generation SingleGeneration() + { + Assert.Single(Ingest.Requests); + Assert.Single(Ingest.Requests[0].Request.Generations); + return Ingest.Requests[0].Request.Generations[0]; + } + + public Activity GenerationSpan() + { + return OperationSpan(new[] { "generateText", "streamText" }); + } + + public Activity OperationSpan(string operationName) + { + return OperationSpan(new[] { operationName }); + } + + private Activity OperationSpan(string[] operationNames) + { + var span = Spans + .Where(activity => operationNames.Contains(activity.GetTagItem("gen_ai.operation.name")?.ToString(), StringComparer.Ordinal)) + .LastOrDefault(); + Assert.NotNull(span); + return span!; + } + + public async ValueTask DisposeAsync() + { + await ShutdownAsync(); + } + } + + private sealed class NullScope : IDisposable + { + public static NullScope Instance { get; } = new(); + + public void Dispose() + { + } + } +} diff --git a/dotnet/tests/Grafana.Sigil.Tests/Grafana.Sigil.Tests.csproj b/dotnet/tests/Grafana.Sigil.Tests/Grafana.Sigil.Tests.csproj index d22f1da..2dce9f9 100644 --- a/dotnet/tests/Grafana.Sigil.Tests/Grafana.Sigil.Tests.csproj +++ b/dotnet/tests/Grafana.Sigil.Tests/Grafana.Sigil.Tests.csproj @@ -8,7 +8,7 @@ - + @@ -22,20 +22,20 @@ - - - - diff --git a/go-frameworks/google-adk/.golangci.yml b/go-frameworks/google-adk/.golangci.yml new file mode 100644 index 0000000..23482f8 --- /dev/null +++ b/go-frameworks/google-adk/.golangci.yml @@ -0,0 +1,4 @@ +version: "2" + +run: + tests: false diff --git a/go-frameworks/google-adk/README.md b/go-frameworks/google-adk/README.md index ff1595f..5beb8a0 100644 --- a/go-frameworks/google-adk/README.md +++ b/go-frameworks/google-adk/README.md @@ -9,10 +9,25 @@ This module maps Google ADK callback/interceptor lifecycles to Sigil generation - SYNC/STREAM run lifecycle support with TTFT capture - Tool lifecycle support +## Embeddings support + +This helper currently supports Google ADK run and tool lifecycles only. The +Google ADK lifecycle surface used in this repository does not expose a +dedicated embeddings callback, so embedding conformance is explicitly +unsupported until that surface exists. + +Use the capability gate before assuming framework-level embedding coverage: + +```go +if err := googleadk.CheckEmbeddingsSupport(); err != nil { + // Embedding conformance is not available through the current ADK lifecycle. +} +``` + ## Install ```bash -go get github.com/grafana/sigil/sdks/go-frameworks/google-adk +go get github.com/grafana/sigil-sdk/go-frameworks/google-adk ``` ## Quickstart @@ -89,13 +104,17 @@ Precedence: _ = callbacks.OnToolStart(ctx, googleadk.ToolStartEvent{ RunID: "tool-1", SessionID: "session-42", + ToolCallID: "call_lookup_customer", ToolName: "lookup_customer", + ToolType: "function", ToolDescription: "Lookup customer profile", Arguments: map[string]any{"customer_id": "42"}, }) _ = callbacks.OnToolEnd("tool-1", googleadk.ToolEndEvent{Result: map[string]any{"status": "ok"}}) ``` +`ToolCallID` and `ToolType` are optional, but passing them lets the framework tool span line up with the assistant `tool_call` message part and emit `gen_ai.tool.call.id` / `gen_ai.tool.type`. + ## Troubleshooting - Missing conversation grouping: pass stable session/conversation IDs from ADK context. diff --git a/go-frameworks/google-adk/adapter.go b/go-frameworks/google-adk/adapter.go index 8c8d74d..eec5109 100644 --- a/go-frameworks/google-adk/adapter.go +++ b/go-frameworks/google-adk/adapter.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "github.com/grafana/sigil/sdks/go/sigil" + "github.com/grafana/sigil-sdk/go/sigil" ) const ( @@ -86,9 +86,15 @@ type ToolStartEvent struct { SessionID string GroupID string ThreadID string + ToolCallID string ToolName string + ToolType string ToolDescription string Arguments any + // ModelName is the model that requested the tool call (e.g. "gpt-5"). + ModelName string + // Provider is the provider that served the model (e.g. "openai"). + Provider string } // ToolEndEvent is the adapter input for tool completion callbacks. @@ -390,12 +396,21 @@ func (a *Adapter) OnToolStart(ctx context.Context, event ToolStartEvent) error { ThreadID: event.ThreadID, }) + provider := resolveProvider(a.opts.Provider, event.Provider, event.ModelName, a.opts.ProviderResolver, RunStartEvent{ + ModelName: event.ModelName, + Provider: event.Provider, + }) + rec := a.startTool(ctx, sigil.ToolExecutionStart{ ToolName: strings.TrimSpace(event.ToolName), + ToolCallID: strings.TrimSpace(event.ToolCallID), + ToolType: strings.TrimSpace(event.ToolType), ToolDescription: strings.TrimSpace(event.ToolDescription), ConversationID: conversationID, AgentName: strings.TrimSpace(a.opts.AgentName), AgentVersion: strings.TrimSpace(a.opts.AgentVersion), + RequestModel: normalizeModelName(event.ModelName), + RequestProvider: provider, IncludeContent: a.captureInputs || a.captureOutputs, }) diff --git a/go-frameworks/google-adk/adapter_test.go b/go-frameworks/google-adk/adapter_test.go index d9b7071..9a887a8 100644 --- a/go-frameworks/google-adk/adapter_test.go +++ b/go-frameworks/google-adk/adapter_test.go @@ -13,7 +13,7 @@ import ( "testing" "time" - "github.com/grafana/sigil/sdks/go/sigil" + "github.com/grafana/sigil-sdk/go/sigil" ) func boolPtr(v bool) *bool { @@ -376,6 +376,142 @@ func TestOnToolStartDropsArgumentsWhenCaptureInputsDisabled(t *testing.T) { } } +func TestOnToolStartPropagatesToolCallFields(t *testing.T) { + cfg := sigil.DefaultConfig() + cfg.GenerationExport.Protocol = sigil.GenerationExportProtocolNone + client := sigil.NewClient(cfg) + t.Cleanup(func() { + _ = client.Shutdown(context.Background()) + }) + + adapter := NewSigilAdapter(client, Options{ + AgentName: "adk-agent", + AgentVersion: "1.0.0", + }) + + var captured sigil.ToolExecutionStart + adapter.startTool = func(ctx context.Context, start sigil.ToolExecutionStart) *sigil.ToolExecutionRecorder { + captured = start + _, rec := client.StartToolExecution(ctx, start) + return rec + } + + if err := adapter.OnToolStart(context.Background(), ToolStartEvent{ + RunID: "tool-propagation", + SessionID: "session-42", + ToolCallID: "call-weather", + ToolName: "weather.lookup", + ToolType: "function", + ToolDescription: "Look up weather", + Arguments: map[string]any{"city": "Paris"}, + ModelName: "gpt-5", + Provider: "openai", + }); err != nil { + t.Fatalf("tool start: %v", err) + } + + if captured.ToolCallID != "call-weather" { + t.Fatalf("expected tool call id propagation, got %q", captured.ToolCallID) + } + if captured.ToolType != "function" { + t.Fatalf("expected tool type propagation, got %q", captured.ToolType) + } + if captured.ToolName != "weather.lookup" { + t.Fatalf("expected tool name propagation, got %q", captured.ToolName) + } + if captured.ConversationID != "session-42" { + t.Fatalf("expected conversation propagation, got %q", captured.ConversationID) + } + if captured.RequestModel != "gpt-5" { + t.Fatalf("expected request model propagation, got %q", captured.RequestModel) + } + if captured.RequestProvider != "openai" { + t.Fatalf("expected request provider propagation, got %q", captured.RequestProvider) + } + + if err := adapter.OnToolEnd("tool-propagation", ToolEndEvent{CompletedAt: time.Now().UTC()}); err != nil { + t.Fatalf("tool end: %v", err) + } +} + +func TestOnToolStartResolvesModelAndProvider(t *testing.T) { + tests := []struct { + name string + adapterProvider string + eventModelName string + eventProvider string + wantModel string + wantProvider string + }{ + { + name: "explicit event provider and model", + eventModelName: "claude-4", + eventProvider: "anthropic", + wantModel: "claude-4", + wantProvider: "anthropic", + }, + { + name: "adapter-level provider fallback", + adapterProvider: "openai", + eventModelName: "gpt-5", + wantModel: "gpt-5", + wantProvider: "openai", + }, + { + name: "inferred provider from model name", + eventModelName: "gemini-2.0-flash", + wantModel: "gemini-2.0-flash", + wantProvider: "gemini", + }, + { + name: "unknown model defaults", + wantModel: "unknown", + wantProvider: "custom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := sigil.DefaultConfig() + cfg.GenerationExport.Protocol = sigil.GenerationExportProtocolNone + client := sigil.NewClient(cfg) + t.Cleanup(func() { + _ = client.Shutdown(context.Background()) + }) + + adapter := NewSigilAdapter(client, Options{Provider: tt.adapterProvider}) + + var captured sigil.ToolExecutionStart + adapter.startTool = func(ctx context.Context, start sigil.ToolExecutionStart) *sigil.ToolExecutionRecorder { + captured = start + _, rec := client.StartToolExecution(ctx, start) + return rec + } + + if err := adapter.OnToolStart(context.Background(), ToolStartEvent{ + RunID: "tool-resolve-" + tt.name, + SessionID: "session-42", + ToolName: "lookup", + ModelName: tt.eventModelName, + Provider: tt.eventProvider, + }); err != nil { + t.Fatalf("tool start: %v", err) + } + + if captured.RequestModel != tt.wantModel { + t.Fatalf("expected request model %q, got %q", tt.wantModel, captured.RequestModel) + } + if captured.RequestProvider != tt.wantProvider { + t.Fatalf("expected request provider %q, got %q", tt.wantProvider, captured.RequestProvider) + } + + if err := adapter.OnToolEnd("tool-resolve-"+tt.name, ToolEndEvent{CompletedAt: time.Now().UTC()}); err != nil { + t.Fatalf("tool end: %v", err) + } + }) + } +} + func TestBuildFrameworkMetadataNormalizesStructAndPointerValues(t *testing.T) { type metadataDetails struct { Enabled bool `json:"enabled"` diff --git a/go-frameworks/google-adk/conformance/conformance_test.go b/go-frameworks/google-adk/conformance/conformance_test.go new file mode 100644 index 0000000..02388d7 --- /dev/null +++ b/go-frameworks/google-adk/conformance/conformance_test.go @@ -0,0 +1,515 @@ +package conformance_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + googleadk "github.com/grafana/sigil-sdk/go-frameworks/google-adk" + "github.com/grafana/sigil-sdk/go/sigil" + "go.opentelemetry.io/otel/attribute" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" +) + +const ( + spanAttrOperationName = "gen_ai.operation.name" + spanAttrConversationID = "gen_ai.conversation.id" + spanAttrAgentName = "gen_ai.agent.name" + spanAttrAgentVersion = "gen_ai.agent.version" + spanAttrProviderName = "gen_ai.provider.name" + spanAttrRequestModel = "gen_ai.request.model" + spanAttrResponseModel = "gen_ai.response.model" + spanAttrGenerationID = "sigil.generation.id" + + metadataRunID = "sigil.framework.run_id" + metadataThreadID = "sigil.framework.thread_id" + metadataParentRunID = "sigil.framework.parent_run_id" + metadataRunType = "sigil.framework.run_type" + metadataComponent = "sigil.framework.component_name" + metadataEventID = "sigil.framework.event_id" + metadataTags = "sigil.framework.tags" + metadataRetry = "sigil.framework.retry_attempt" +) + +type conformanceEnv struct { + client *sigil.Client + capture *generationCaptureServer + spans *tracetest.SpanRecorder + tracerProvider *sdktrace.TracerProvider +} + +func TestConformance_RunLifecycleExportsFrameworkTelemetry(t *testing.T) { + env := newConformanceEnv(t) + + adapter := googleadk.NewSigilAdapter(env.client, googleadk.Options{ + AgentName: "triage-agent", + AgentVersion: "1.2.3", + ExtraTags: map[string]string{ + "deployment.environment": "staging", + }, + ExtraMetadata: map[string]any{ + "team": "infra", + }, + }) + + parentCtx, parentSpan := env.tracerProvider.Tracer("google-adk-conformance").Start(context.Background(), "http.request") + parentSpanContext := parentSpan.SpanContext() + retryAttempt := 2 + + if err := adapter.OnRunStart(parentCtx, googleadk.RunStartEvent{ + RunID: "run-sync", + ParentRunID: "framework-parent", + SessionID: "session-42", + ThreadID: "thread-7", + EventID: "event-9", + ComponentName: "planner", + RunType: "chat", + ModelName: "gemini-2.5-pro", + Prompts: []string{"Summarize system health"}, + Tags: []string{"prod", "workflow"}, + RetryAttempt: &retryAttempt, + Metadata: map[string]any{ + "step": "triage", + }, + }); err != nil { + t.Fatalf("run start: %v", err) + } + + if got := env.capture.requestCount(); got != 0 { + t.Fatalf("expected no normalized generation export before run end, got %d requests", got) + } + + if err := adapter.OnRunEnd("run-sync", googleadk.RunEndEvent{ + RunID: "run-sync", + OutputMessages: []sigil.Message{sigil.AssistantTextMessage("System health is green")}, + ResponseModel: "gemini-2.5-pro", + StopReason: "stop", + Usage: sigil.TokenUsage{ + InputTokens: 12, + OutputTokens: 4, + TotalTokens: 16, + }, + }); err != nil { + t.Fatalf("run end: %v", err) + } + + generation := env.capture.waitForSingleGeneration(t) + parentSpan.End() + env.Shutdown(t) + + span := findSpanByName(t, env.spans.Ended(), "generateText gemini-2.5-pro") + if span.Parent().SpanID() != parentSpanContext.SpanID() { + t.Fatalf("expected generation span parent %q, got %q", parentSpanContext.SpanID().String(), span.Parent().SpanID().String()) + } + + attrs := spanAttributeMap(span) + requireStringAttr(t, attrs, spanAttrOperationName, "generateText") + requireStringAttr(t, attrs, spanAttrConversationID, "session-42") + requireStringAttr(t, attrs, spanAttrAgentName, "triage-agent") + requireStringAttr(t, attrs, spanAttrAgentVersion, "1.2.3") + requireStringAttr(t, attrs, spanAttrProviderName, "gemini") + requireStringAttr(t, attrs, spanAttrRequestModel, "gemini-2.5-pro") + requireStringAttr(t, attrs, spanAttrResponseModel, "gemini-2.5-pro") + requireStringAttr(t, attrs, spanAttrGenerationID, mustString(t, generation, "id")) + + requireStringField(t, generation, "conversation_id", "session-42") + requireStringField(t, generation, "agent_name", "triage-agent") + requireStringField(t, generation, "agent_version", "1.2.3") + requireStringField(t, generation, "operation_name", "generateText") + requireStringField(t, generation, "mode", "GENERATION_MODE_SYNC") + requireStringField(t, generation, "response_model", "gemini-2.5-pro") + requireStringField(t, generation, "stop_reason", "stop") + requireStringField(t, generation, "trace_id", span.SpanContext().TraceID().String()) + requireStringField(t, generation, "span_id", span.SpanContext().SpanID().String()) + + model := mustObject(t, generation, "model") + requireStringFromMap(t, model, "provider", "gemini") + requireStringFromMap(t, model, "name", "gemini-2.5-pro") + + tags := mustObject(t, generation, "tags") + requireStringFromMap(t, tags, "sigil.framework.name", "google-adk") + requireStringFromMap(t, tags, "sigil.framework.source", "handler") + requireStringFromMap(t, tags, "sigil.framework.language", "go") + requireStringFromMap(t, tags, "deployment.environment", "staging") + + metadata := mustObject(t, generation, "metadata") + requireStringFromMap(t, metadata, metadataRunID, "run-sync") + requireStringFromMap(t, metadata, metadataThreadID, "thread-7") + requireStringFromMap(t, metadata, metadataParentRunID, "framework-parent") + requireStringFromMap(t, metadata, metadataRunType, "chat") + requireStringFromMap(t, metadata, metadataComponent, "planner") + requireStringFromMap(t, metadata, metadataEventID, "event-9") + requireStringFromMap(t, metadata, "team", "infra") + requireStringFromMap(t, metadata, "step", "triage") + requireNumberFromMap(t, metadata, metadataRetry, 2) + requireStringSliceFromMap(t, metadata, metadataTags, []string{"prod", "workflow"}) +} + +func TestConformance_StreamingRunExportsTokenDrivenGeneration(t *testing.T) { + env := newConformanceEnv(t) + + adapter := googleadk.NewSigilAdapter(env.client, googleadk.Options{ + AgentName: "stream-agent", + AgentVersion: "9.9.9", + }) + + parentCtx, parentSpan := env.tracerProvider.Tracer("google-adk-conformance").Start(context.Background(), "grpc.request") + parentSpanContext := parentSpan.SpanContext() + + if err := adapter.OnRunStart(parentCtx, googleadk.RunStartEvent{ + RunID: "run-stream", + ConversationID: "conversation-stream", + ThreadID: "thread-stream", + ModelName: "gpt-5", + Stream: true, + Tags: []string{"streaming"}, + }); err != nil { + t.Fatalf("run start: %v", err) + } + + adapter.OnRunToken("run-stream", "hello") + adapter.OnRunToken("run-stream", " world") + + if err := adapter.OnRunEnd("run-stream", googleadk.RunEndEvent{ + RunID: "run-stream", + ResponseModel: "gpt-5", + StopReason: "end_turn", + }); err != nil { + t.Fatalf("run end: %v", err) + } + + generation := env.capture.waitForSingleGeneration(t) + parentSpan.End() + env.Shutdown(t) + + span := findSpanByName(t, env.spans.Ended(), "streamText gpt-5") + if span.Parent().SpanID() != parentSpanContext.SpanID() { + t.Fatalf("expected streaming span parent %q, got %q", parentSpanContext.SpanID().String(), span.Parent().SpanID().String()) + } + + attrs := spanAttributeMap(span) + requireStringAttr(t, attrs, spanAttrOperationName, "streamText") + requireStringAttr(t, attrs, spanAttrConversationID, "conversation-stream") + requireStringAttr(t, attrs, spanAttrAgentName, "stream-agent") + requireStringAttr(t, attrs, spanAttrAgentVersion, "9.9.9") + requireStringAttr(t, attrs, spanAttrProviderName, "openai") + requireStringAttr(t, attrs, spanAttrRequestModel, "gpt-5") + requireStringAttr(t, attrs, spanAttrResponseModel, "gpt-5") + + requireStringField(t, generation, "conversation_id", "conversation-stream") + requireStringField(t, generation, "mode", "GENERATION_MODE_STREAM") + requireStringField(t, generation, "operation_name", "streamText") + requireStringField(t, generation, "response_model", "gpt-5") + requireStringField(t, generation, "stop_reason", "end_turn") + requireStringField(t, generation, "trace_id", span.SpanContext().TraceID().String()) + requireStringField(t, generation, "span_id", span.SpanContext().SpanID().String()) + + tags := mustObject(t, generation, "tags") + requireStringFromMap(t, tags, "sigil.framework.name", "google-adk") + requireStringFromMap(t, tags, "sigil.framework.source", "handler") + requireStringFromMap(t, tags, "sigil.framework.language", "go") + + metadata := mustObject(t, generation, "metadata") + requireStringFromMap(t, metadata, metadataRunID, "run-stream") + requireStringFromMap(t, metadata, metadataThreadID, "thread-stream") + requireStringFromMap(t, metadata, metadataRunType, "chat") + requireStringSliceFromMap(t, metadata, metadataTags, []string{"streaming"}) + + output := mustArray(t, generation, "output") + if len(output) != 1 { + t.Fatalf("expected one streamed output message, got %d", len(output)) + } + outputMessage, ok := output[0].(map[string]any) + if !ok { + t.Fatalf("expected output message object, got %T", output[0]) + } + requireStringFromMap(t, outputMessage, "role", "MESSAGE_ROLE_ASSISTANT") + parts := mustArrayFromMap(t, outputMessage, "parts") + if len(parts) != 1 { + t.Fatalf("expected one output part, got %d", len(parts)) + } + outputPart, ok := parts[0].(map[string]any) + if !ok { + t.Fatalf("expected output part object, got %T", parts[0]) + } + requireStringFromMap(t, outputPart, "text", "hello world") +} + +func newConformanceEnv(t *testing.T) *conformanceEnv { + t.Helper() + + capture := newGenerationCaptureServer(t) + spanRecorder := tracetest.NewSpanRecorder() + tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + + cfg := sigil.DefaultConfig() + cfg.Tracer = tracerProvider.Tracer("google-adk-conformance") + cfg.GenerationExport.Protocol = sigil.GenerationExportProtocolHTTP + cfg.GenerationExport.Endpoint = capture.server.URL + "/api/v1/generations:export" + cfg.GenerationExport.BatchSize = 1 + cfg.GenerationExport.QueueSize = 8 + cfg.GenerationExport.FlushInterval = time.Hour + cfg.GenerationExport.MaxRetries = 1 + cfg.GenerationExport.InitialBackoff = time.Millisecond + cfg.GenerationExport.MaxBackoff = 10 * time.Millisecond + + env := &conformanceEnv{ + client: sigil.NewClient(cfg), + capture: capture, + spans: spanRecorder, + tracerProvider: tracerProvider, + } + t.Cleanup(func() { + _ = env.close() + }) + return env +} + +func (e *conformanceEnv) Shutdown(t *testing.T) { + t.Helper() + + if err := e.close(); err != nil { + t.Fatalf("shutdown conformance env: %v", err) + } +} + +func (e *conformanceEnv) close() error { + var closeErr error + + if e.client != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := e.client.Shutdown(ctx); err != nil { + closeErr = err + } + e.client = nil + } + if e.tracerProvider != nil { + if err := e.tracerProvider.Shutdown(context.Background()); err != nil && closeErr == nil { + closeErr = err + } + e.tracerProvider = nil + } + if e.capture != nil { + e.capture.server.Close() + e.capture = nil + } + + return closeErr +} + +type generationCaptureServer struct { + server *httptest.Server + mu sync.Mutex + requests []map[string]any +} + +func newGenerationCaptureServer(t *testing.T) *generationCaptureServer { + t.Helper() + + capture := &generationCaptureServer{} + capture.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "read body", http.StatusBadRequest) + return + } + + request := map[string]any{} + if err := json.Unmarshal(body, &request); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + + capture.mu.Lock() + capture.requests = append(capture.requests, request) + capture.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"results":[]}`)) + })) + return capture +} + +func (c *generationCaptureServer) requestCount() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.requests) +} + +func (c *generationCaptureServer) waitForSingleGeneration(t *testing.T) map[string]any { + t.Helper() + + deadline := time.Now().Add(5 * time.Second) + for { + c.mu.Lock() + if len(c.requests) == 1 { + request := c.requests[0] + c.mu.Unlock() + generations := mustArray(t, request, "generations") + if len(generations) != 1 { + t.Fatalf("expected one exported generation, got %d", len(generations)) + } + generation, ok := generations[0].(map[string]any) + if !ok { + t.Fatalf("expected generation object, got %T", generations[0]) + } + return generation + } + c.mu.Unlock() + + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for a single generation export; got %d requests", c.requestCount()) + } + time.Sleep(10 * time.Millisecond) + } +} + +func findSpanByName(t *testing.T, spans []sdktrace.ReadOnlySpan, name string) sdktrace.ReadOnlySpan { + t.Helper() + + for _, span := range spans { + if span.Name() == name { + return span + } + } + + t.Fatalf("expected span %q, got %d spans", name, len(spans)) + return nil +} + +func spanAttributeMap(span sdktrace.ReadOnlySpan) map[attribute.Key]attribute.Value { + attrs := make(map[attribute.Key]attribute.Value, len(span.Attributes())) + for _, attr := range span.Attributes() { + attrs[attr.Key] = attr.Value + } + return attrs +} + +func requireStringAttr(t *testing.T, attrs map[attribute.Key]attribute.Value, key, want string) { + t.Helper() + + got, ok := attrs[attribute.Key(key)] + if !ok { + t.Fatalf("expected span attribute %q", key) + } + if got.AsString() != want { + t.Fatalf("unexpected span attribute %q: got %q want %q", key, got.AsString(), want) + } +} + +func requireStringField(t *testing.T, data map[string]any, key, want string) { + t.Helper() + requireStringFromMap(t, data, key, want) +} + +func requireStringFromMap(t *testing.T, data map[string]any, key, want string) { + t.Helper() + + got, ok := data[key] + if !ok { + t.Fatalf("expected field %q", key) + } + gotString, ok := got.(string) + if !ok { + t.Fatalf("expected %q to be a string, got %T", key, got) + } + if gotString != want { + t.Fatalf("unexpected %q: got %q want %q", key, gotString, want) + } +} + +func requireNumberFromMap(t *testing.T, data map[string]any, key string, want float64) { + t.Helper() + + got, ok := data[key] + if !ok { + t.Fatalf("expected field %q", key) + } + gotNumber, ok := got.(float64) + if !ok { + t.Fatalf("expected %q to be numeric, got %T", key, got) + } + if gotNumber != want { + t.Fatalf("unexpected %q: got %v want %v", key, gotNumber, want) + } +} + +func requireStringSliceFromMap(t *testing.T, data map[string]any, key string, want []string) { + t.Helper() + + raw, ok := data[key] + if !ok { + t.Fatalf("expected field %q", key) + } + values, ok := raw.([]any) + if !ok { + t.Fatalf("expected %q to be an array, got %T", key, raw) + } + if len(values) != len(want) { + t.Fatalf("unexpected %q length: got %d want %d", key, len(values), len(want)) + } + for i, expected := range want { + got, ok := values[i].(string) + if !ok { + t.Fatalf("expected %q[%d] to be string, got %T", key, i, values[i]) + } + if got != expected { + t.Fatalf("unexpected %q[%d]: got %q want %q", key, i, got, expected) + } + } +} + +func mustString(t *testing.T, data map[string]any, key string) string { + t.Helper() + + got, ok := data[key] + if !ok { + t.Fatalf("expected field %q", key) + } + value, ok := got.(string) + if !ok { + t.Fatalf("expected %q to be string, got %T", key, got) + } + return value +} + +func mustObject(t *testing.T, data map[string]any, key string) map[string]any { + t.Helper() + + got, ok := data[key] + if !ok { + t.Fatalf("expected field %q", key) + } + value, ok := got.(map[string]any) + if !ok { + t.Fatalf("expected %q to be an object, got %T", key, got) + } + return value +} + +func mustArray(t *testing.T, data map[string]any, key string) []any { + t.Helper() + + got, ok := data[key] + if !ok { + t.Fatalf("expected field %q", key) + } + value, ok := got.([]any) + if !ok { + t.Fatalf("expected %q to be an array, got %T", key, got) + } + return value +} + +func mustArrayFromMap(t *testing.T, data map[string]any, key string) []any { + t.Helper() + return mustArray(t, data, key) +} diff --git a/go-frameworks/google-adk/conformance/doc.go b/go-frameworks/google-adk/conformance/doc.go new file mode 100644 index 0000000..df6f154 --- /dev/null +++ b/go-frameworks/google-adk/conformance/doc.go @@ -0,0 +1 @@ +package conformance diff --git a/go-frameworks/google-adk/conformance_test.go b/go-frameworks/google-adk/conformance_test.go new file mode 100644 index 0000000..70addf8 --- /dev/null +++ b/go-frameworks/google-adk/conformance_test.go @@ -0,0 +1,679 @@ +package googleadk_test + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + googleadk "github.com/grafana/sigil-sdk/go-frameworks/google-adk" + sigil "github.com/grafana/sigil-sdk/go/sigil" + "go.opentelemetry.io/otel/attribute" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "go.opentelemetry.io/otel/trace" +) + +const ( + metricOperationDuration = "gen_ai.client.operation.duration" + metricTimeToFirstToken = "gen_ai.client.time_to_first_token" + metricToolCallsPerOp = "gen_ai.client.tool_calls_per_operation" + spanAttrOperationName = "gen_ai.operation.name" + spanAttrConversationID = "gen_ai.conversation.id" + spanAttrAgentName = "gen_ai.agent.name" + spanAttrAgentVersion = "gen_ai.agent.version" + spanAttrProviderName = "gen_ai.provider.name" + spanAttrRequestModel = "gen_ai.request.model" + spanAttrResponseModel = "gen_ai.response.model" + spanAttrToolName = "gen_ai.tool.name" + spanAttrToolCallID = "gen_ai.tool.call.id" + spanAttrToolType = "gen_ai.tool.type" +) + +func TestConformance_RunLifecyclePropagatesFrameworkMetadataAndLinksSpans(t *testing.T) { + env := newConformanceEnv(t, googleadk.Options{ + AgentName: "planner", + AgentVersion: "2026.03.12", + ExtraTags: map[string]string{ + "deployment.environment": "test", + }, + ExtraMetadata: map[string]any{ + "team": "infra", + }, + }) + + retryAttempt := 2 + parentCtx, parent := env.Tracer.Start(context.Background(), "google-adk.run", + trace.WithAttributes( + attribute.String("sigil.framework.name", "google-adk"), + attribute.String("sigil.framework.source", "handler"), + attribute.String("sigil.framework.language", "go"), + ), + ) + + if err := env.Callbacks.OnRunStart(parentCtx, googleadk.RunStartEvent{ + RunID: "run-sync", + ParentRunID: "parent-run", + SessionID: "session-42", + ThreadID: "thread-7", + EventID: "event-42", + ComponentName: "planner", + RunType: "chat", + RetryAttempt: &retryAttempt, + ModelName: "gpt-5", + Prompts: []string{"Summarize release status"}, + Tags: []string{"prod", "framework", "prod"}, + Metadata: map[string]any{"event_payload": map[string]any{"step": "validate"}}, + InputMessages: []sigil.Message{sigil.UserTextMessage("Summarize release status")}, + ConversationID: "", + }); err != nil { + t.Fatalf("run start: %v", err) + } + + if err := env.Callbacks.OnRunEnd("run-sync", googleadk.RunEndEvent{ + RunID: "run-sync", + OutputMessages: []sigil.Message{sigil.AssistantTextMessage("Release is healthy")}, + ResponseModel: "gpt-5", + StopReason: "stop", + Usage: sigil.TokenUsage{ + InputTokens: 6, + OutputTokens: 4, + TotalTokens: 10, + }, + }); err != nil { + t.Fatalf("run end: %v", err) + } + + parentSpanContext := parent.SpanContext() + parent.End() + + metrics := env.CollectMetrics(t) + if len(findHistogram[float64](t, metrics, metricOperationDuration).DataPoints) == 0 { + t.Fatalf("expected %s datapoints for sync google-adk conformance", metricOperationDuration) + } + requireNoHistogram(t, metrics, metricTimeToFirstToken) + + env.Shutdown(t) + + parentSpan := findSpanByName(t, env.Spans.Ended(), "google-adk.run") + parentAttrs := spanAttrs(parentSpan) + requireSpanAttr(t, parentAttrs, "sigil.framework.name", "google-adk") + requireSpanAttr(t, parentAttrs, "sigil.framework.source", "handler") + requireSpanAttr(t, parentAttrs, "sigil.framework.language", "go") + + generationSpan := findSpanByOperationName(t, env.Spans.Ended(), "generateText") + if generationSpan.Parent().SpanID() != parentSpanContext.SpanID() { + t.Fatalf("expected generation span parent %q, got %q", parentSpanContext.SpanID().String(), generationSpan.Parent().SpanID().String()) + } + + generationAttrs := spanAttrs(generationSpan) + requireSpanAttr(t, generationAttrs, spanAttrOperationName, "generateText") + requireSpanAttr(t, generationAttrs, spanAttrConversationID, "session-42") + requireSpanAttr(t, generationAttrs, spanAttrAgentName, "planner") + requireSpanAttr(t, generationAttrs, spanAttrAgentVersion, "2026.03.12") + requireSpanAttr(t, generationAttrs, spanAttrProviderName, "openai") + requireSpanAttr(t, generationAttrs, spanAttrRequestModel, "gpt-5") + requireSpanAttr(t, generationAttrs, spanAttrResponseModel, "gpt-5") + + generation := env.Export.SingleGeneration(t) + if got := stringValue(t, generation, "conversation_id"); got != "session-42" { + t.Fatalf("unexpected conversation_id: got %q want %q", got, "session-42") + } + if got := stringValue(t, generation, "trace_id"); got != generationSpan.SpanContext().TraceID().String() { + t.Fatalf("unexpected trace_id: got %q want %q", got, generationSpan.SpanContext().TraceID().String()) + } + if got := stringValue(t, generation, "span_id"); got != generationSpan.SpanContext().SpanID().String() { + t.Fatalf("unexpected span_id: got %q want %q", got, generationSpan.SpanContext().SpanID().String()) + } + + tags := objectValue(t, generation, "tags") + requireStringField(t, tags, "deployment.environment", "test") + requireStringField(t, tags, "sigil.framework.name", "google-adk") + requireStringField(t, tags, "sigil.framework.source", "handler") + requireStringField(t, tags, "sigil.framework.language", "go") + + metadata := objectValue(t, generation, "metadata") + requireStringField(t, metadata, "team", "infra") + requireStringField(t, metadata, "sigil.framework.run_id", "run-sync") + requireStringField(t, metadata, "sigil.framework.thread_id", "thread-7") + requireStringField(t, metadata, "sigil.framework.parent_run_id", "parent-run") + requireStringField(t, metadata, "sigil.framework.component_name", "planner") + requireStringField(t, metadata, "sigil.framework.run_type", "chat") + requireNumberField(t, metadata, "sigil.framework.retry_attempt", 2) + requireStringField(t, metadata, "sigil.framework.event_id", "event-42") + requireStringSliceField(t, metadata, "sigil.framework.tags", []string{"prod", "framework"}) + + eventPayload := objectValue(t, metadata, "event_payload") + requireStringField(t, eventPayload, "step", "validate") +} + +func TestConformance_StreamingRunTriggersGenerationExport(t *testing.T) { + env := newConformanceEnv(t, googleadk.Options{ + AgentName: "planner", + AgentVersion: "2026.03.12", + }) + + if err := env.Callbacks.OnRunStart(context.Background(), googleadk.RunStartEvent{ + RunID: "run-stream", + SessionID: "session-stream", + ModelName: "gemini-2.5-pro", + RunType: "chat", + Stream: true, + Prompts: []string{"Stream migration status"}, + }); err != nil { + t.Fatalf("run start: %v", err) + } + env.Callbacks.OnRunToken("run-stream", "step ") + env.Callbacks.OnRunToken("run-stream", "complete") + + if err := env.Callbacks.OnRunEnd("run-stream", googleadk.RunEndEvent{ + RunID: "run-stream", + ResponseModel: "gemini-2.5-pro", + Usage: sigil.TokenUsage{ + InputTokens: 3, + OutputTokens: 2, + TotalTokens: 5, + }, + }); err != nil { + t.Fatalf("run end: %v", err) + } + + metrics := env.CollectMetrics(t) + if len(findHistogram[float64](t, metrics, metricOperationDuration).DataPoints) == 0 { + t.Fatalf("expected %s datapoints for streaming google-adk conformance", metricOperationDuration) + } + if len(findHistogram[float64](t, metrics, metricTimeToFirstToken).DataPoints) == 0 { + t.Fatalf("expected %s datapoints for streaming google-adk conformance", metricTimeToFirstToken) + } + + env.Shutdown(t) + + generationSpan := findSpanByOperationName(t, env.Spans.Ended(), "streamText") + requireSpanAttr(t, spanAttrs(generationSpan), spanAttrRequestModel, "gemini-2.5-pro") + + generation := env.Export.SingleGeneration(t) + if got := stringValue(t, generation, "operation_name"); got != "streamText" { + t.Fatalf("unexpected operation_name: got %q want %q", got, "streamText") + } + + output := arrayValue(t, generation, "output") + if len(output) != 1 { + t.Fatalf("expected one streamed output message, got %d", len(output)) + } + message := asObject(t, output[0], "output[0]") + parts := arrayValue(t, message, "parts") + if len(parts) != 1 { + t.Fatalf("expected one streamed output part, got %d", len(parts)) + } + part := asObject(t, parts[0], "output[0].parts[0]") + requireStringField(t, part, "text", "step complete") + + metadata := objectValue(t, generation, "metadata") + requireStringField(t, metadata, "sigil.framework.run_id", "run-stream") + requireStringField(t, metadata, "sigil.framework.run_type", "chat") +} + +func TestConformance_EmbeddingSupportStatus(t *testing.T) { + err := googleadk.CheckEmbeddingsSupport() + if err == nil { + t.Fatalf("expected Google ADK embeddings to remain unsupported") + } + if !errors.Is(err, googleadk.ErrEmbeddingsUnsupported) { + t.Fatalf("expected ErrEmbeddingsUnsupported, got %v", err) + } +} + +func TestConformance_ToolCallOutputsAndToolLifecycleStayObservable(t *testing.T) { + env := newConformanceEnv(t, googleadk.Options{ + AgentName: "planner", + AgentVersion: "2026.03.12", + }) + + if err := env.Callbacks.OnRunStart(context.Background(), googleadk.RunStartEvent{ + RunID: "run-tool-call", + SessionID: "session-tool-call", + ModelName: "gpt-5", + RunType: "chat", + Prompts: []string{"Look up the weather in Paris"}, + }); err != nil { + t.Fatalf("run start: %v", err) + } + + if err := env.Callbacks.OnRunEnd("run-tool-call", googleadk.RunEndEvent{ + RunID: "run-tool-call", + OutputMessages: []sigil.Message{ + { + Role: sigil.RoleAssistant, + Name: "assistant", + Parts: []sigil.Part{ + sigil.TextPart("Calling weather lookup."), + sigil.ToolCallPart(sigil.ToolCall{ + ID: "call-weather", + Name: "weather.lookup", + InputJSON: []byte(`{"city":"Paris"}`), + }), + }, + }, + { + Role: sigil.RoleTool, + Name: "weather.lookup", + Parts: []sigil.Part{ + sigil.ToolResultPart(sigil.ToolResult{ + ToolCallID: "call-weather", + Name: "weather.lookup", + Content: "18C", + ContentJSON: []byte(`{"temp_c":18}`), + }), + }, + }, + }, + ResponseModel: "gpt-5", + StopReason: "tool_calls", + Usage: sigil.TokenUsage{ + InputTokens: 8, + OutputTokens: 3, + TotalTokens: 11, + }, + }); err != nil { + t.Fatalf("run end: %v", err) + } + + if err := env.Callbacks.OnToolStart(context.Background(), googleadk.ToolStartEvent{ + RunID: "tool-call-span", + SessionID: "session-tool-call", + ToolCallID: "call-weather", + ToolName: "weather.lookup", + ToolType: "function", + ToolDescription: "Look up weather", + Arguments: map[string]any{"city": "Paris"}, + }); err != nil { + t.Fatalf("tool start: %v", err) + } + if err := env.Callbacks.OnToolEnd("tool-call-span", googleadk.ToolEndEvent{ + Result: map[string]any{"temp_c": 18}, + CompletedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("tool end: %v", err) + } + + metrics := env.CollectMetrics(t) + toolCalls := findHistogram[int64](t, metrics, metricToolCallsPerOp) + if len(toolCalls.DataPoints) != 1 { + t.Fatalf("expected one %s datapoint, got %d", metricToolCallsPerOp, len(toolCalls.DataPoints)) + } + if toolCalls.DataPoints[0].Sum != 1 { + t.Fatalf("expected %s sum=1, got %d", metricToolCallsPerOp, toolCalls.DataPoints[0].Sum) + } + + env.Shutdown(t) + + generation := env.Export.SingleGeneration(t) + output := arrayValue(t, generation, "output") + if len(output) != 2 { + t.Fatalf("expected assistant tool call plus tool result output messages, got %d", len(output)) + } + + assistant := asObject(t, output[0], "output[0]") + assistantParts := arrayValue(t, assistant, "parts") + if len(assistantParts) != 2 { + t.Fatalf("expected assistant text + tool call parts, got %d", len(assistantParts)) + } + toolCallPart := asObject(t, assistantParts[1], "output[0].parts[1]") + toolCall := objectValue(t, toolCallPart, "tool_call") + requireStringField(t, toolCall, "id", "call-weather") + requireStringField(t, toolCall, "name", "weather.lookup") + requireStringField(t, toolCall, "input_json", "eyJjaXR5IjoiUGFyaXMifQ==") + + toolMessage := asObject(t, output[1], "output[1]") + toolParts := arrayValue(t, toolMessage, "parts") + if len(toolParts) != 1 { + t.Fatalf("expected one tool result part, got %d", len(toolParts)) + } + toolResultPart := asObject(t, toolParts[0], "output[1].parts[0]") + toolResult := objectValue(t, toolResultPart, "tool_result") + requireStringField(t, toolResult, "tool_call_id", "call-weather") + requireStringField(t, toolResult, "name", "weather.lookup") + + toolSpan := findSpanByOperationName(t, env.Spans.Ended(), "execute_tool") + toolAttrs := spanAttrs(toolSpan) + requireSpanAttr(t, toolAttrs, spanAttrToolName, "weather.lookup") + requireSpanAttr(t, toolAttrs, spanAttrToolCallID, "call-weather") + requireSpanAttr(t, toolAttrs, spanAttrToolType, "function") + requireSpanAttr(t, toolAttrs, spanAttrConversationID, "session-tool-call") +} + +type conformanceEnv struct { + Client *sigil.Client + Callbacks googleadk.Callbacks + Export *generationCaptureServer + Spans *tracetest.SpanRecorder + Metrics *sdkmetric.ManualReader + Tracer trace.Tracer + + tracerProvider *sdktrace.TracerProvider + meterProvider *sdkmetric.MeterProvider +} + +func newConformanceEnv(t *testing.T, opts googleadk.Options) *conformanceEnv { + t.Helper() + + export := newGenerationCaptureServer(t) + spanRecorder := tracetest.NewSpanRecorder() + tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + metricReader := sdkmetric.NewManualReader() + meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(metricReader)) + + cfg := sigil.DefaultConfig() + cfg.Tracer = tracerProvider.Tracer("google-adk-conformance-test") + cfg.Meter = meterProvider.Meter("google-adk-conformance-test") + cfg.GenerationExport.Protocol = sigil.GenerationExportProtocolHTTP + cfg.GenerationExport.Endpoint = export.server.URL + "/api/v1/generations:export" + cfg.GenerationExport.BatchSize = 1 + cfg.GenerationExport.QueueSize = 8 + cfg.GenerationExport.FlushInterval = time.Hour + cfg.GenerationExport.MaxRetries = 1 + cfg.GenerationExport.InitialBackoff = time.Millisecond + cfg.GenerationExport.MaxBackoff = 5 * time.Millisecond + + client := sigil.NewClient(cfg) + env := &conformanceEnv{ + Client: client, + Callbacks: googleadk.NewCallbacks(client, opts), + Export: export, + Spans: spanRecorder, + Metrics: metricReader, + Tracer: tracerProvider.Tracer("google-adk-framework-test"), + tracerProvider: tracerProvider, + meterProvider: meterProvider, + } + t.Cleanup(func() { + _ = env.close() + }) + return env +} + +func (e *conformanceEnv) Shutdown(t *testing.T) { + t.Helper() + if e == nil || e.Client == nil { + return + } + client := e.Client + e.Client = nil + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := client.Shutdown(ctx); err != nil { + t.Fatalf("shutdown client: %v", err) + } +} + +func (e *conformanceEnv) close() error { + if e == nil { + return nil + } + + var closeErr error + if e.Client != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := e.Client.Shutdown(ctx); err != nil { + closeErr = err + } + e.Client = nil + } + if e.meterProvider != nil { + if err := e.meterProvider.Shutdown(context.Background()); err != nil && closeErr == nil { + closeErr = err + } + e.meterProvider = nil + } + if e.tracerProvider != nil { + if err := e.tracerProvider.Shutdown(context.Background()); err != nil && closeErr == nil { + closeErr = err + } + e.tracerProvider = nil + } + if e.Export != nil && e.Export.server != nil { + e.Export.server.Close() + e.Export.server = nil + } + return closeErr +} + +func (e *conformanceEnv) CollectMetrics(t *testing.T) metricdata.ResourceMetrics { + t.Helper() + var collected metricdata.ResourceMetrics + if err := e.Metrics.Collect(context.Background(), &collected); err != nil { + t.Fatalf("collect metrics: %v", err) + } + return collected +} + +type generationCaptureServer struct { + server *httptest.Server + mu sync.Mutex + requests []map[string]any +} + +func newGenerationCaptureServer(t *testing.T) *generationCaptureServer { + t.Helper() + capture := &generationCaptureServer{} + capture.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "read body", http.StatusBadRequest) + return + } + + request := map[string]any{} + if err := json.Unmarshal(body, &request); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + + capture.mu.Lock() + capture.requests = append(capture.requests, request) + capture.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"results":[]}`)) + })) + return capture +} + +func (c *generationCaptureServer) SingleGeneration(t *testing.T) map[string]any { + t.Helper() + c.mu.Lock() + defer c.mu.Unlock() + + if len(c.requests) != 1 { + t.Fatalf("expected exactly one export request, got %d", len(c.requests)) + } + + generations := arrayValue(t, c.requests[0], "generations") + if len(generations) != 1 { + t.Fatalf("expected exactly one exported generation, got %d", len(generations)) + } + + return asObject(t, generations[0], "generations[0]") +} + +func findSpanByName(t *testing.T, spans []sdktrace.ReadOnlySpan, name string) sdktrace.ReadOnlySpan { + t.Helper() + for _, span := range spans { + if span.Name() == name { + return span + } + } + t.Fatalf("span %q not found", name) + return nil +} + +func findSpanByOperationName(t *testing.T, spans []sdktrace.ReadOnlySpan, operation string) sdktrace.ReadOnlySpan { + t.Helper() + for _, span := range spans { + if spanAttr := attrValue(span.Attributes(), spanAttrOperationName); spanAttr.Type() == attribute.STRING && spanAttr.AsString() == operation { + return span + } + } + t.Fatalf("span with %s=%q not found", spanAttrOperationName, operation) + return nil +} + +func spanAttrs(span sdktrace.ReadOnlySpan) map[string]attribute.Value { + attrs := make(map[string]attribute.Value, len(span.Attributes())) + for _, attr := range span.Attributes() { + attrs[string(attr.Key)] = attr.Value + } + return attrs +} + +func requireSpanAttr(t *testing.T, attrs map[string]attribute.Value, key string, want string) { + t.Helper() + value, ok := attrs[key] + if !ok { + t.Fatalf("missing span attr %q", key) + } + if value.Type() != attribute.STRING { + t.Fatalf("span attr %q has type %v, want string", key, value.Type()) + } + if got := value.AsString(); got != want { + t.Fatalf("unexpected span attr %q: got %q want %q", key, got, want) + } +} + +func attrValue(attrs []attribute.KeyValue, key string) attribute.Value { + for _, attr := range attrs { + if string(attr.Key) == key { + return attr.Value + } + } + return attribute.Value{} +} + +func stringValue(t *testing.T, object map[string]any, key string) string { + t.Helper() + value, ok := object[key] + if !ok { + t.Fatalf("missing %q", key) + } + text, ok := value.(string) + if !ok { + t.Fatalf("expected %q to be string, got %T", key, value) + } + return text +} + +func objectValue(t *testing.T, object map[string]any, key string) map[string]any { + t.Helper() + value, ok := object[key] + if !ok { + t.Fatalf("missing %q", key) + } + return asObject(t, value, key) +} + +func arrayValue(t *testing.T, object map[string]any, key string) []any { + t.Helper() + value, ok := object[key] + if !ok { + t.Fatalf("missing %q", key) + } + items, ok := value.([]any) + if !ok { + t.Fatalf("expected %q to be array, got %T", key, value) + } + return items +} + +func asObject(t *testing.T, value any, label string) map[string]any { + t.Helper() + object, ok := value.(map[string]any) + if !ok { + t.Fatalf("expected %s to be object, got %T", label, value) + } + return object +} + +func requireStringField(t *testing.T, object map[string]any, key string, want string) { + t.Helper() + if got := stringValue(t, object, key); got != want { + t.Fatalf("unexpected %q: got %q want %q", key, got, want) + } +} + +func requireNumberField(t *testing.T, object map[string]any, key string, want float64) { + t.Helper() + value, ok := object[key] + if !ok { + t.Fatalf("missing %q", key) + } + number, ok := value.(float64) + if !ok { + t.Fatalf("expected %q to be number, got %T", key, value) + } + if number != want { + t.Fatalf("unexpected %q: got %v want %v", key, number, want) + } +} + +func requireStringSliceField(t *testing.T, object map[string]any, key string, want []string) { + t.Helper() + value, ok := object[key] + if !ok { + t.Fatalf("missing %q", key) + } + items, ok := value.([]any) + if !ok { + t.Fatalf("expected %q to be string array, got %T", key, value) + } + if len(items) != len(want) { + t.Fatalf("unexpected %q length: got %d want %d", key, len(items), len(want)) + } + for i := range want { + text, ok := items[i].(string) + if !ok { + t.Fatalf("expected %q[%d] to be string, got %T", key, i, items[i]) + } + if text != want[i] { + t.Fatalf("unexpected %q[%d]: got %q want %q", key, i, text, want[i]) + } + } +} + +func findHistogram[N int64 | float64](t *testing.T, metrics metricdata.ResourceMetrics, name string) metricdata.Histogram[N] { + t.Helper() + for _, scopeMetrics := range metrics.ScopeMetrics { + for _, metric := range scopeMetrics.Metrics { + if metric.Name != name { + continue + } + histogram, ok := metric.Data.(metricdata.Histogram[N]) + if !ok { + t.Fatalf("metric %q did not contain expected histogram data", name) + } + return histogram + } + } + t.Fatalf("metric %q not found", name) + return metricdata.Histogram[N]{} +} + +func requireNoHistogram(t *testing.T, metrics metricdata.ResourceMetrics, name string) { + t.Helper() + for _, scopeMetrics := range metrics.ScopeMetrics { + for _, metric := range scopeMetrics.Metrics { + if metric.Name == name { + t.Fatalf("did not expect metric %q", name) + } + } + } +} diff --git a/go-frameworks/google-adk/doc.go b/go-frameworks/google-adk/doc.go index 03d6eea..30be368 100644 --- a/go-frameworks/google-adk/doc.go +++ b/go-frameworks/google-adk/doc.go @@ -4,4 +4,7 @@ // metadata for tracing and generation analysis. // // NewCallbacks provides one-time function-based lifecycle wiring for runner setup. +// Embedding support is currently exposed as an explicit unsupported capability +// gate because the Google ADK lifecycle surface used here does not provide a +// dedicated embeddings callback. package googleadk diff --git a/go-frameworks/google-adk/embedding_support.go b/go-frameworks/google-adk/embedding_support.go new file mode 100644 index 0000000..e6f81b4 --- /dev/null +++ b/go-frameworks/google-adk/embedding_support.go @@ -0,0 +1,15 @@ +package googleadk + +import "errors" + +// ErrEmbeddingsUnsupported reports that the Google ADK lifecycle surface used +// by this helper does not currently expose a dedicated embeddings callback. +var ErrEmbeddingsUnsupported = errors.New("googleadk: embeddings are not supported because the Google ADK lifecycle surface does not expose a dedicated embeddings callback") + +// CheckEmbeddingsSupport reports whether this helper can wrap a native Google +// ADK embeddings lifecycle. The current lifecycle surface only exposes run and +// tool callbacks, so callers should treat a non-nil error as an explicit +// capability gate instead of assuming embedding conformance coverage exists. +func CheckEmbeddingsSupport() error { + return ErrEmbeddingsUnsupported +} diff --git a/go-frameworks/google-adk/embedding_support_test.go b/go-frameworks/google-adk/embedding_support_test.go new file mode 100644 index 0000000..c52727c --- /dev/null +++ b/go-frameworks/google-adk/embedding_support_test.go @@ -0,0 +1,19 @@ +package googleadk + +import ( + "errors" + "testing" +) + +func TestCheckEmbeddingsSupportReturnsUnsupportedError(t *testing.T) { + err := CheckEmbeddingsSupport() + if err == nil { + t.Fatalf("expected embeddings support error") + } + if !errors.Is(err, ErrEmbeddingsUnsupported) { + t.Fatalf("expected ErrEmbeddingsUnsupported, got %v", err) + } + if got, want := err.Error(), ErrEmbeddingsUnsupported.Error(); got != want { + t.Fatalf("unexpected embeddings support error: got %q want %q", got, want) + } +} diff --git a/go-frameworks/google-adk/go.mod b/go-frameworks/google-adk/go.mod index c057641..7d21ead 100644 --- a/go-frameworks/google-adk/go.mod +++ b/go-frameworks/google-adk/go.mod @@ -1,23 +1,28 @@ -module github.com/grafana/sigil/sdks/go-frameworks/google-adk +module github.com/grafana/sigil-sdk/go-frameworks/google-adk go 1.25.6 -require github.com/grafana/sigil/sdks/go v0.0.0 +require ( + github.com/grafana/sigil-sdk/go v0.1.2 + go.opentelemetry.io/otel v1.42.0 + go.opentelemetry.io/otel/sdk v1.42.0 + go.opentelemetry.io/otel/sdk/metric v1.42.0 + go.opentelemetry.io/otel/trace v1.42.0 +) require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.40.0 // indirect - go.opentelemetry.io/otel/metric v1.40.0 // indirect - go.opentelemetry.io/otel/trace v1.40.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + go.opentelemetry.io/otel/metric v1.42.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/grpc v1.79.1 // indirect + google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect ) -replace github.com/grafana/sigil/sdks/go => ../../go +replace github.com/grafana/sigil-sdk/go => ../../go diff --git a/go-frameworks/google-adk/go.sum b/go-frameworks/google-adk/go.sum index 067da5f..377d78e 100644 --- a/go-frameworks/google-adk/go.sum +++ b/go-frameworks/google-adk/go.sum @@ -1,7 +1,7 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -13,34 +13,36 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= -google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/go-providers/LICENSE b/go-providers/LICENSE index ae8c60c..626a3ab 100644 --- a/go-providers/LICENSE +++ b/go-providers/LICENSE @@ -1,3 +1,3 @@ SPDX-License-Identifier: Apache-2.0 -See /sdks/LICENSE at repository root for full license text. +See /LICENSE at repository root for full license text. diff --git a/go-providers/anthropic/LICENSE b/go-providers/anthropic/LICENSE index ae8c60c..626a3ab 100644 --- a/go-providers/anthropic/LICENSE +++ b/go-providers/anthropic/LICENSE @@ -1,3 +1,3 @@ SPDX-License-Identifier: Apache-2.0 -See /sdks/LICENSE at repository root for full license text. +See /LICENSE at repository root for full license text. diff --git a/go-providers/anthropic/README.md b/go-providers/anthropic/README.md index 7378d31..64369d7 100644 --- a/go-providers/anthropic/README.md +++ b/go-providers/anthropic/README.md @@ -7,10 +7,20 @@ typed Sigil `Generation` model. This helper currently supports Anthropic Messages APIs only. Native Anthropic embeddings endpoints are not available in the official SDK/API surface used in this repository. +Use the exported support gate when you need a deterministic capability check: + +```go +if err := anthropic.CheckEmbeddingsSupport(); err != nil { + return err +} +``` + ## Scope - One-liner wrappers: - `Message(ctx, sigilClient, provider, req, opts...)` - `MessageStream(ctx, sigilClient, provider, req, opts...)` +- Embedding capability gate: + - `CheckEmbeddingsSupport()` - Request/response mapper: - `FromRequestResponse(req, resp, opts...)` - Stream mapper: @@ -28,6 +38,7 @@ This helper currently supports Anthropic Messages APIs only. Native Anthropic em ```go resp, err := anthropic.Message(ctx, sigilClient, providerClient, req, anthropic.WithConversationID("conv-1"), + anthropic.WithConversationTitle("Weather follow-up"), anthropic.WithAgentName("assistant-anthropic"), anthropic.WithAgentVersion("1.0.0"), ) @@ -96,3 +107,9 @@ In addition to normalized usage fields, Anthropic server-tool counters are mappe - `sigil.gen_ai.usage.server_tool_use.web_search_requests` - `sigil.gen_ai.usage.server_tool_use.web_fetch_requests` - `sigil.gen_ai.usage.server_tool_use.total_requests` + +Anthropic tool `defer_loading` is mapped to Sigil `Generation.Tools[].Deferred`. + +## Tool result correlation + +- Anthropic `tool_result` and server-tool result blocks preserve `tool_use_id` in normalized `tool_result.tool_call_id`. diff --git a/go-providers/anthropic/conformance_test.go b/go-providers/anthropic/conformance_test.go new file mode 100644 index 0000000..b369023 --- /dev/null +++ b/go-providers/anthropic/conformance_test.go @@ -0,0 +1,471 @@ +package anthropic + +import ( + "errors" + "net/http" + "net/url" + "strings" + "testing" + "time" + + asdk "github.com/anthropics/anthropic-sdk-go" + + sigil "github.com/grafana/sigil-sdk/go/sigil" + "github.com/grafana/sigil-sdk/go/sigil/sigiltest" +) + +const anthropicSpanErrorCategory = "error.category" + +func TestConformance_AnthropicSyncMapping(t *testing.T) { + env := sigiltest.NewEnv(t) + + req := testRequest() + resp := &asdk.BetaMessage{ + ID: "msg_conformance_sync", + Model: asdk.Model("claude-sonnet-4-5"), + StopReason: asdk.BetaStopReasonToolUse, + Content: []asdk.BetaContentBlockUnion{ + mustUnmarshalBetaContentBlockUnion(t, `{"type":"thinking","thinking":"need weather tool","signature":"sig"}`), + mustUnmarshalBetaContentBlockUnion(t, `{"type":"text","text":"Checking weather."}`), + mustUnmarshalBetaContentBlockUnion(t, `{"type":"tool_use","id":"toolu_sync","name":"weather","input":{"city":"Paris"}}`), + }, + Usage: asdk.BetaUsage{ + InputTokens: 120, + OutputTokens: 42, + CacheReadInputTokens: 30, + CacheCreationInputTokens: 10, + ServerToolUse: asdk.BetaServerToolUsage{ + WebSearchRequests: 2, + WebFetchRequests: 1, + }, + }, + } + start := sigil.GenerationStart{ + ConversationID: "conv-anthropic-sync", + ConversationTitle: "Anthropic sync", + AgentName: "agent-anthropic", + AgentVersion: "v-anthropic", + Model: sigil.ModelRef{Provider: "anthropic", Name: string(req.Model)}, + } + + generation, err := FromRequestResponse( + req, + resp, + WithConversationID(start.ConversationID), + WithConversationTitle(start.ConversationTitle), + WithAgentName(start.AgentName), + WithAgentVersion(start.AgentVersion), + WithTag("tenant", "t-anthropic"), + ) + sigiltest.RecordGeneration(t, env, start, generation, err) + env.Shutdown(t) + + exported := env.SingleGenerationJSON(t) + + if got := sigiltest.StringValue(t, exported, "mode"); got != "GENERATION_MODE_SYNC" { + t.Fatalf("unexpected mode: got %q want %q\n%s", got, "GENERATION_MODE_SYNC", sigiltest.DebugJSON(exported)) + } + if got := sigiltest.StringValue(t, exported, "stop_reason"); got != "tool_use" { + t.Fatalf("unexpected stop_reason: got %q want %q", got, "tool_use") + } + if got := sigiltest.StringValue(t, exported, "output", 0, "parts", 0, "thinking"); got != "need weather tool" { + t.Fatalf("unexpected thinking part: got %q want %q", got, "need weather tool") + } + if got := sigiltest.StringValue(t, exported, "output", 0, "parts", 1, "text"); got != "Checking weather." { + t.Fatalf("unexpected text part: got %q want %q", got, "Checking weather.") + } + if got := sigiltest.StringValue(t, exported, "output", 0, "parts", 2, "tool_call", "name"); got != "weather" { + t.Fatalf("unexpected tool_call.name: got %q want %q", got, "weather") + } + if got := sigiltest.StringValue(t, exported, "input", 2, "role"); got != "MESSAGE_ROLE_TOOL" { + t.Fatalf("unexpected tool input role: got %q want %q", got, "MESSAGE_ROLE_TOOL") + } + if got := sigiltest.StringValue(t, exported, "usage", "cache_read_input_tokens"); got != "30" { + t.Fatalf("unexpected usage.cache_read_input_tokens: got %q want %q", got, "30") + } + if got := sigiltest.StringValue(t, exported, "usage", "cache_write_input_tokens"); got != "10" { + t.Fatalf("unexpected usage.cache_write_input_tokens: got %q want %q", got, "10") + } + if got := sigiltest.FloatValue(t, exported, "metadata", "sigil.gen_ai.usage.server_tool_use.total_requests"); got != 3 { + t.Fatalf("unexpected server tool total requests: got %v want %v", got, float64(3)) + } +} + +func TestConformance_AnthropicStreamMapping(t *testing.T) { + env := sigiltest.NewEnv(t) + + req := testRequest() + summary := StreamSummary{ + FirstChunkAt: time.Unix(1_741_780_100, 0).UTC(), + Events: []asdk.BetaRawMessageStreamEventUnion{ + { + Type: "message_start", + Message: asdk.BetaMessage{ + ID: "msg_conformance_stream", + Model: asdk.Model("claude-sonnet-4-5"), + }, + }, + { + Type: "content_block_start", + Index: 0, + ContentBlock: mustUnmarshalBetaRawContentBlockStartEventContentBlockUnion(t, `{"type":"thinking","thinking":""}`), + }, + { + Type: "content_block_delta", + Index: 0, + Delta: asdk.BetaRawMessageStreamEventUnionDelta{Thinking: "need weather"}, + }, + { + Type: "content_block_start", + Index: 1, + ContentBlock: mustUnmarshalBetaRawContentBlockStartEventContentBlockUnion(t, `{"type":"text","text":""}`), + }, + { + Type: "content_block_delta", + Index: 1, + Delta: asdk.BetaRawMessageStreamEventUnionDelta{Text: "Checking "}, + }, + { + Type: "content_block_delta", + Index: 1, + Delta: asdk.BetaRawMessageStreamEventUnionDelta{Text: "weather"}, + }, + { + Type: "content_block_start", + Index: 2, + ContentBlock: mustUnmarshalBetaRawContentBlockStartEventContentBlockUnion(t, `{"type":"tool_use","id":"toolu_stream","name":"weather","input":{}}`), + }, + { + Type: "content_block_delta", + Index: 2, + Delta: asdk.BetaRawMessageStreamEventUnionDelta{PartialJSON: `{"city":"Paris"}`}, + }, + { + Type: "message_delta", + Delta: asdk.BetaRawMessageStreamEventUnionDelta{ + StopReason: asdk.BetaStopReasonToolUse, + }, + Usage: asdk.BetaMessageDeltaUsage{ + InputTokens: 80, + OutputTokens: 25, + }, + }, + }, + } + start := sigil.GenerationStart{ + ConversationID: "conv-anthropic-stream", + AgentName: "agent-anthropic-stream", + AgentVersion: "v-anthropic-stream", + Model: sigil.ModelRef{Provider: "anthropic", Name: string(req.Model)}, + } + + generation, err := FromStream( + req, + summary, + WithConversationID(start.ConversationID), + WithAgentName(start.AgentName), + WithAgentVersion(start.AgentVersion), + ) + sigiltest.RecordStreamingGeneration(t, env, start, summary.FirstChunkAt, generation, err) + env.Shutdown(t) + + exported := env.SingleGenerationJSON(t) + + if got := sigiltest.StringValue(t, exported, "mode"); got != "GENERATION_MODE_STREAM" { + t.Fatalf("unexpected mode: got %q want %q\n%s", got, "GENERATION_MODE_STREAM", sigiltest.DebugJSON(exported)) + } + if got := sigiltest.StringValue(t, exported, "response_id"); got != "msg_conformance_stream" { + t.Fatalf("unexpected response_id: got %q want %q", got, "msg_conformance_stream") + } + if got := sigiltest.StringValue(t, exported, "stop_reason"); got != "tool_use" { + t.Fatalf("unexpected stop_reason: got %q want %q", got, "tool_use") + } + if got := sigiltest.StringValue(t, exported, "output", 0, "parts", 0, "thinking"); got != "need weather" { + t.Fatalf("unexpected streamed thinking part: got %q want %q", got, "need weather") + } + if got := sigiltest.StringValue(t, exported, "output", 0, "parts", 1, "text"); got != "Checking weather" { + t.Fatalf("unexpected streamed text part: got %q want %q", got, "Checking weather") + } + if got := sigiltest.StringValue(t, exported, "output", 0, "parts", 2, "tool_call", "name"); got != "weather" { + t.Fatalf("unexpected streamed tool_call.name: got %q want %q", got, "weather") + } + if got := sigiltest.StringValue(t, exported, "usage", "total_tokens"); got != "105" { + t.Fatalf("unexpected streamed usage.total_tokens: got %q want %q", got, "105") + } +} + +func TestConformance_AnthropicErrorMapping(t *testing.T) { + env := sigiltest.NewEnv(t) + + callErr := &asdk.Error{ + StatusCode: http.StatusTooManyRequests, + Request: &http.Request{Method: http.MethodPost, URL: mustAnthropicURL(t, "https://api.anthropic.com/v1/messages")}, + Response: &http.Response{StatusCode: http.StatusTooManyRequests, Status: "429 Too Many Requests"}, + } + sigiltest.RecordCallError(t, env, sigil.GenerationStart{ + Model: sigil.ModelRef{Provider: "anthropic", Name: "claude-sonnet-4-5"}, + }, callErr) + + span := sigiltest.FindSpan(t, env.Spans.Ended(), "generateText claude-sonnet-4-5") + attrs := sigiltest.SpanAttributes(span) + if got := attrs[anthropicSpanErrorCategory].AsString(); got != "rate_limit" { + t.Fatalf("unexpected error.category: got %q want %q", got, "rate_limit") + } + + env.Shutdown(t) + exported := env.SingleGenerationJSON(t) + callError := sigiltest.StringValue(t, exported, "call_error") + if !strings.Contains(callError, "429") { + t.Fatalf("expected call_error to include status code, got %q", callError) + } +} + +func mustAnthropicURL(t testing.TB, raw string) *url.URL { + t.Helper() + + parsed, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse url %q: %v", raw, err) + } + return parsed +} + +func TestConformance_MessageSyncNormalization(t *testing.T) { + req := testRequest() + resp := &asdk.BetaMessage{ + ID: "msg_1", + Model: asdk.Model("claude-sonnet-4-5"), + StopReason: asdk.BetaStopReasonEndTurn, + Content: []asdk.BetaContentBlockUnion{ + {Type: "text", Text: "It's 18C and sunny."}, + {Type: "thinking", Thinking: "answer done"}, + mustUnmarshalBetaContentBlockUnion(t, `{"type":"tool_use","id":"toolu_2","name":"weather","input":{"city":"Paris"}}`), + }, + Usage: asdk.BetaUsage{ + InputTokens: 120, + OutputTokens: 42, + CacheReadInputTokens: 30, + CacheCreationInputTokens: 10, + }, + } + + generation, err := FromRequestResponse(req, resp, + WithConversationID("conv-anthropic-sync"), + WithConversationTitle("Paris weather"), + WithAgentName("agent-anthropic"), + WithAgentVersion("v-anthropic"), + WithTag("tenant", "t-123"), + WithRawArtifacts(), + ) + if err != nil { + t.Fatalf("anthropic sync mapping: %v", err) + } + + if generation.Model.Provider != "anthropic" || generation.Model.Name != "claude-sonnet-4-5" { + t.Fatalf("unexpected model mapping: %#v", generation.Model) + } + if generation.ConversationID != "conv-anthropic-sync" || generation.ConversationTitle != "Paris weather" { + t.Fatalf("unexpected conversation mapping: %#v", generation) + } + if generation.AgentName != "agent-anthropic" || generation.AgentVersion != "v-anthropic" { + t.Fatalf("unexpected agent mapping: name=%q version=%q", generation.AgentName, generation.AgentVersion) + } + if generation.ResponseID != "msg_1" || generation.ResponseModel != "claude-sonnet-4-5" { + t.Fatalf("unexpected response mapping: id=%q model=%q", generation.ResponseID, generation.ResponseModel) + } + if generation.StopReason != "end_turn" { + t.Fatalf("unexpected stop reason: %q", generation.StopReason) + } + if generation.Usage.TotalTokens != 162 || generation.Usage.CacheReadInputTokens != 30 || generation.Usage.CacheCreationInputTokens != 10 { + t.Fatalf("unexpected usage mapping: %#v", generation.Usage) + } + if generation.ThinkingEnabled == nil || !*generation.ThinkingEnabled { + t.Fatalf("expected thinking enabled true, got %v", generation.ThinkingEnabled) + } + if len(generation.Output) != 1 || len(generation.Output[0].Parts) != 3 { + t.Fatalf("expected text + thinking + tool call output, got %#v", generation.Output) + } + if generation.Output[0].Parts[0].Kind != sigil.PartKindText || generation.Output[0].Parts[0].Text != "It's 18C and sunny." { + t.Fatalf("unexpected text output: %#v", generation.Output[0].Parts[0]) + } + if generation.Output[0].Parts[1].Kind != sigil.PartKindThinking || generation.Output[0].Parts[1].Thinking != "answer done" { + t.Fatalf("unexpected thinking output: %#v", generation.Output[0].Parts[1]) + } + if generation.Output[0].Parts[2].Kind != sigil.PartKindToolCall { + t.Fatalf("expected tool call output, got %#v", generation.Output[0].Parts[2]) + } + if generation.Output[0].Parts[2].ToolCall.ID != "toolu_2" || generation.Output[0].Parts[2].ToolCall.Name != "weather" { + t.Fatalf("unexpected tool call mapping: %#v", generation.Output[0].Parts[2].ToolCall) + } + if generation.Tags["tenant"] != "t-123" { + t.Fatalf("expected tenant tag") + } + requireAnthropicArtifactKinds(t, generation.Artifacts, + sigil.ArtifactKindRequest, + sigil.ArtifactKindResponse, + sigil.ArtifactKindTools, + ) +} + +func TestConformance_MessageStreamNormalization(t *testing.T) { + req := testRequest() + summary := StreamSummary{ + Events: []asdk.BetaRawMessageStreamEventUnion{ + { + Type: "message_start", + Message: asdk.BetaMessage{ + ID: "msg_delta_1", + Model: asdk.Model("claude-sonnet-4-5"), + }, + }, + { + Type: "content_block_start", + Index: 0, + ContentBlock: asdk.BetaRawContentBlockStartEventContentBlockUnion{ + Type: "thinking", + }, + }, + { + Type: "content_block_delta", + Index: 0, + Delta: asdk.BetaRawMessageStreamEventUnionDelta{Thinking: "let me "}, + }, + { + Type: "content_block_delta", + Index: 0, + Delta: asdk.BetaRawMessageStreamEventUnionDelta{Thinking: "think about this"}, + }, + { + Type: "content_block_start", + Index: 1, + ContentBlock: asdk.BetaRawContentBlockStartEventContentBlockUnion{ + Type: "text", + }, + }, + { + Type: "content_block_delta", + Index: 1, + Delta: asdk.BetaRawMessageStreamEventUnionDelta{Text: "Hello, "}, + }, + { + Type: "content_block_delta", + Index: 1, + Delta: asdk.BetaRawMessageStreamEventUnionDelta{Text: "world!"}, + }, + { + Type: "content_block_start", + Index: 2, + ContentBlock: asdk.BetaRawContentBlockStartEventContentBlockUnion{ + Type: "tool_use", + ID: "toolu_1", + Name: "weather", + Input: map[string]any{}, + }, + }, + { + Type: "content_block_delta", + Index: 2, + Delta: asdk.BetaRawMessageStreamEventUnionDelta{PartialJSON: `{"city"`}, + }, + { + Type: "content_block_delta", + Index: 2, + Delta: asdk.BetaRawMessageStreamEventUnionDelta{PartialJSON: `:"Berlin"}`}, + }, + { + Type: "message_delta", + Delta: asdk.BetaRawMessageStreamEventUnionDelta{ + StopReason: asdk.BetaStopReasonToolUse, + }, + Usage: asdk.BetaMessageDeltaUsage{ + InputTokens: 100, + OutputTokens: 50, + }, + }, + }, + } + + generation, err := FromStream(req, summary, + WithConversationID("conv-anthropic-stream"), + WithAgentName("agent-anthropic-stream"), + WithAgentVersion("v-anthropic-stream"), + WithRawArtifacts(), + ) + if err != nil { + t.Fatalf("anthropic stream mapping: %v", err) + } + + if generation.ConversationID != "conv-anthropic-stream" || generation.AgentName != "agent-anthropic-stream" || generation.AgentVersion != "v-anthropic-stream" { + t.Fatalf("unexpected identity mapping: %#v", generation) + } + if generation.ResponseID != "msg_delta_1" || generation.ResponseModel != "claude-sonnet-4-5" { + t.Fatalf("unexpected response mapping: id=%q model=%q", generation.ResponseID, generation.ResponseModel) + } + if generation.StopReason != "tool_use" { + t.Fatalf("unexpected stop reason: %q", generation.StopReason) + } + if generation.Usage.TotalTokens != 150 { + t.Fatalf("unexpected usage mapping: %#v", generation.Usage) + } + if len(generation.Output) != 1 || len(generation.Output[0].Parts) != 3 { + t.Fatalf("expected thinking + text + tool call output, got %#v", generation.Output) + } + if generation.Output[0].Parts[0].Kind != sigil.PartKindThinking || generation.Output[0].Parts[0].Thinking != "let me think about this" { + t.Fatalf("unexpected thinking output: %#v", generation.Output[0].Parts[0]) + } + if generation.Output[0].Parts[1].Kind != sigil.PartKindText || generation.Output[0].Parts[1].Text != "Hello, world!" { + t.Fatalf("unexpected text output: %#v", generation.Output[0].Parts[1]) + } + if generation.Output[0].Parts[2].Kind != sigil.PartKindToolCall { + t.Fatalf("expected tool call output, got %#v", generation.Output[0].Parts[2]) + } + if string(generation.Output[0].Parts[2].ToolCall.InputJSON) != `{"city":"Berlin"}` { + t.Fatalf("unexpected streamed tool input: %q", string(generation.Output[0].Parts[2].ToolCall.InputJSON)) + } + requireAnthropicArtifactKinds(t, generation.Artifacts, + sigil.ArtifactKindRequest, + sigil.ArtifactKindTools, + sigil.ArtifactKindProviderEvent, + ) +} + +func TestConformance_AnthropicMapperValidationErrors(t *testing.T) { + if _, err := FromRequestResponse(testRequest(), nil); err == nil || err.Error() != "response is required" { + t.Fatalf("expected explicit response error, got %v", err) + } + if _, err := FromStream(testRequest(), StreamSummary{}); err == nil || err.Error() != "stream summary has no events and no final message" { + t.Fatalf("expected explicit stream error, got %v", err) + } + + _, err := FromRequestResponse( + testRequest(), + &asdk.BetaMessage{Model: asdk.Model("claude-sonnet-4-5")}, + WithProviderName(""), + ) + if err == nil || err.Error() != "generation.model.provider is required" { + t.Fatalf("expected explicit validation error for invalid provider mapping, got %v", err) + } +} + +func TestConformance_EmbeddingSupportStatus(t *testing.T) { + err := CheckEmbeddingsSupport() + if err == nil { + t.Fatalf("expected Anthropic embeddings to remain unsupported") + } + if !errors.Is(err, ErrEmbeddingsUnsupported) { + t.Fatalf("expected ErrEmbeddingsUnsupported, got %v", err) + } +} + +func requireAnthropicArtifactKinds(t *testing.T, artifacts []sigil.Artifact, want ...sigil.ArtifactKind) { + t.Helper() + + if len(artifacts) != len(want) { + t.Fatalf("expected %d artifacts, got %d", len(want), len(artifacts)) + } + for i, kind := range want { + if artifacts[i].Kind != kind { + t.Fatalf("artifact %d kind mismatch: got %q want %q", i, artifacts[i].Kind, kind) + } + } +} diff --git a/go-providers/anthropic/doc.go b/go-providers/anthropic/doc.go index 843809c..61b2298 100644 --- a/go-providers/anthropic/doc.go +++ b/go-providers/anthropic/doc.go @@ -1,5 +1,11 @@ // Package anthropic maps Anthropic message payloads to sigil.Generation. // -// Use FromRequestResponse for non-streaming calls and FromStream for streaming calls. -// The resulting generation keeps request content in Input and model output in Output. +// Use FromRequestResponse for non-streaming calls and FromStream for streaming +// calls. The resulting generation keeps request content in Input and model +// output in Output. +// +// This package currently supports Anthropic Messages APIs only. Call +// CheckEmbeddingsSupport before wiring embedding-specific flows; the official +// Anthropic SDK/API surface used by this module does not expose a native +// embeddings endpoint. package anthropic diff --git a/go-providers/anthropic/embedding_support.go b/go-providers/anthropic/embedding_support.go new file mode 100644 index 0000000..22f81ae --- /dev/null +++ b/go-providers/anthropic/embedding_support.go @@ -0,0 +1,15 @@ +package anthropic + +import "errors" + +// ErrEmbeddingsUnsupported reports that the official Anthropic SDK/API surface +// used by this helper does not expose a native embeddings endpoint. +var ErrEmbeddingsUnsupported = errors.New("anthropic: embeddings are not supported by the official Anthropic SDK/API surface") + +// CheckEmbeddingsSupport reports whether this helper can wrap a native Anthropic +// embeddings API. The current official Anthropic SDK/API surface used by this +// module does not expose embeddings, so callers should treat a non-nil error as +// a hard capability boundary rather than inventing custom request DTOs. +func CheckEmbeddingsSupport() error { + return ErrEmbeddingsUnsupported +} diff --git a/go-providers/anthropic/embedding_support_test.go b/go-providers/anthropic/embedding_support_test.go new file mode 100644 index 0000000..d829694 --- /dev/null +++ b/go-providers/anthropic/embedding_support_test.go @@ -0,0 +1,19 @@ +package anthropic + +import ( + "errors" + "testing" +) + +func TestCheckEmbeddingsSupportReturnsUnsupportedError(t *testing.T) { + err := CheckEmbeddingsSupport() + if err == nil { + t.Fatalf("expected embeddings support error") + } + if !errors.Is(err, ErrEmbeddingsUnsupported) { + t.Fatalf("expected ErrEmbeddingsUnsupported, got %v", err) + } + if got, want := err.Error(), ErrEmbeddingsUnsupported.Error(); got != want { + t.Fatalf("unexpected embeddings support error: got %q want %q", got, want) + } +} diff --git a/go-providers/anthropic/go.mod b/go-providers/anthropic/go.mod index cea9c9e..69419ac 100644 --- a/go-providers/anthropic/go.mod +++ b/go-providers/anthropic/go.mod @@ -1,33 +1,35 @@ -module github.com/grafana/sigil/sdks/go-providers/anthropic +module github.com/grafana/sigil-sdk/go-providers/anthropic go 1.25.6 require ( - github.com/anthropics/anthropic-sdk-go v1.26.0 - github.com/grafana/sigil/sdks/go v0.0.0 + github.com/anthropics/anthropic-sdk-go v1.27.1 + github.com/grafana/sigil-sdk/go v0.1.2 ) require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.40.0 // indirect - go.opentelemetry.io/otel/metric v1.40.0 // indirect - go.opentelemetry.io/otel/trace v1.40.0 // indirect - golang.org/x/net v0.49.0 // indirect + go.opentelemetry.io/otel v1.42.0 // indirect + go.opentelemetry.io/otel/metric v1.42.0 // indirect + go.opentelemetry.io/otel/sdk v1.42.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect + go.opentelemetry.io/otel/trace v1.42.0 // indirect + golang.org/x/net v0.51.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/grpc v1.79.1 // indirect + google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) -replace github.com/grafana/sigil/sdks/go => ../../go +replace github.com/grafana/sigil-sdk/go => ../../go diff --git a/go-providers/anthropic/go.sum b/go-providers/anthropic/go.sum index 59f839a..01dcbd0 100644 --- a/go-providers/anthropic/go.sum +++ b/go-providers/anthropic/go.sum @@ -1,5 +1,5 @@ -github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= -github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= +github.com/anthropics/anthropic-sdk-go v1.27.1 h1:7DgMZ2Ng3C2mPzJGHA30NXQTZolcF07mHd0tGaLwfzk= +github.com/anthropics/anthropic-sdk-go v1.27.1/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -33,33 +33,36 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= -google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go-providers/anthropic/mapper.go b/go-providers/anthropic/mapper.go index b7edaa0..48fe0c2 100644 --- a/go-providers/anthropic/mapper.go +++ b/go-providers/anthropic/mapper.go @@ -7,13 +7,17 @@ import ( "strings" asdk "github.com/anthropics/anthropic-sdk-go" - "github.com/grafana/sigil/sdks/go/sigil" + "github.com/grafana/sigil-sdk/go/sigil" ) const thinkingBudgetMetadataKey = "sigil.gen_ai.request.thinking.budget_tokens" const usageServerToolUseWebSearchMetadataKey = "sigil.gen_ai.usage.server_tool_use.web_search_requests" const usageServerToolUseWebFetchMetadataKey = "sigil.gen_ai.usage.server_tool_use.web_fetch_requests" const usageServerToolUseTotalMetadataKey = "sigil.gen_ai.usage.server_tool_use.total_requests" +const toolSearchRegexToolUseType = "tool_search_tool_regex" +const toolSearchBM25ToolUseType = "tool_search_tool_bm25" +const toolSearchRegexToolResultType = "tool_search_tool_regex_tool_result" +const toolSearchBM25ToolResultType = "tool_search_tool_bm25_tool_result" // FromRequestResponse maps an Anthropic request/response pair to sigil.Generation. func FromRequestResponse(req asdk.BetaMessageNewParams, resp *asdk.BetaMessage, opts ...Option) (sigil.Generation, error) { @@ -59,26 +63,27 @@ func FromRequestResponse(req asdk.BetaMessageNewParams, resp *asdk.BetaMessage, metadata = mergeServerToolUsageMetadata(metadata, resp.Usage.ServerToolUse) generation := sigil.Generation{ - ConversationID: options.conversationID, - AgentName: options.agentName, - AgentVersion: options.agentVersion, - Model: sigil.ModelRef{Provider: options.providerName, Name: requestModel}, - ResponseID: resp.ID, - ResponseModel: responseModel, - SystemPrompt: mapSystemPrompt(req.System), - Input: input, - Output: output, - Tools: mapTools(req.Tools), - MaxTokens: maxTokens, - Temperature: temperature, - TopP: topP, - ToolChoice: toolChoice, - ThinkingEnabled: thinkingEnabled, - Usage: mapUsage(resp.Usage), - StopReason: string(resp.StopReason), - Tags: cloneStringMap(options.tags), - Metadata: metadata, - Artifacts: artifacts, + ConversationID: options.conversationID, + ConversationTitle: options.conversationTitle, + AgentName: options.agentName, + AgentVersion: options.agentVersion, + Model: sigil.ModelRef{Provider: options.providerName, Name: requestModel}, + ResponseID: resp.ID, + ResponseModel: responseModel, + SystemPrompt: mapSystemPrompt(req.System), + Input: input, + Output: output, + Tools: mapTools(req.Tools), + MaxTokens: maxTokens, + Temperature: temperature, + TopP: topP, + ToolChoice: toolChoice, + ThinkingEnabled: thinkingEnabled, + Usage: mapUsage(resp.Usage), + StopReason: string(resp.StopReason), + Tags: cloneStringMap(options.tags), + Metadata: metadata, + Artifacts: artifacts, } if err := generation.Validate(); err != nil { @@ -167,7 +172,7 @@ func mapResponseMessages(content []asdk.BetaContentBlockUnion) []sigil.Message { func mapRequestBlock(block asdk.BetaContentBlockParamUnion) (sigil.Part, bool) { if block.OfText != nil { - text := strings.TrimSpace(block.OfText.Text) + text := block.OfText.Text if text == "" { return sigil.Part{}, false } @@ -195,12 +200,13 @@ func mapRequestBlock(block asdk.BetaContentBlockParamUnion) (sigil.Part, bool) { } if block.OfServerToolUse != nil { inputJSON, _ := marshalAny(block.OfServerToolUse.Input) + providerType := providerTypeForToolUse("server_tool_use", string(block.OfServerToolUse.Name)) part := sigil.ToolCallPart(sigil.ToolCall{ ID: block.OfServerToolUse.ID, Name: string(block.OfServerToolUse.Name), InputJSON: inputJSON, }) - part.Metadata.ProviderType = "server_tool_use" + part.Metadata.ProviderType = providerType return part, true } if block.OfMCPToolUse != nil { @@ -291,7 +297,7 @@ func mapRequestBlock(block asdk.BetaContentBlockParamUnion) (sigil.Part, bool) { typ := derefString(block.GetType()) switch typ { case "text": - text := strings.TrimSpace(derefString(block.GetText())) + text := derefString(block.GetText()) if text == "" { return sigil.Part{}, false } @@ -306,12 +312,13 @@ func mapRequestBlock(block asdk.BetaContentBlockParamUnion) (sigil.Part, bool) { return part, true case "tool_use", "server_tool_use", "mcp_tool_use": inputJSON, _ := marshalAny(derefAny(block.GetInput())) + providerType := providerTypeForToolUse(typ, derefString(block.GetName())) part := sigil.ToolCallPart(sigil.ToolCall{ ID: derefString(block.GetID()), Name: derefString(block.GetName()), InputJSON: inputJSON, }) - part.Metadata.ProviderType = typ + part.Metadata.ProviderType = providerType return part, true case "tool_result", "web_search_tool_result", @@ -320,6 +327,8 @@ func mapRequestBlock(block asdk.BetaContentBlockParamUnion) (sigil.Part, bool) { "bash_code_execution_tool_result", "text_editor_code_execution_tool_result", "tool_search_tool_result", + toolSearchRegexToolResultType, + toolSearchBM25ToolResultType, "mcp_tool_result": contentJSON, _ := marshalAny(block) part := sigil.ToolResultPart(sigil.ToolResult{ @@ -337,7 +346,7 @@ func mapRequestBlock(block asdk.BetaContentBlockParamUnion) (sigil.Part, bool) { func mapResponseBlock(block asdk.BetaContentBlockUnion) (sigil.Part, bool) { switch block.Type { case "text": - text := strings.TrimSpace(block.Text) + text := block.Text if text == "" { return sigil.Part{}, false } @@ -352,12 +361,13 @@ func mapResponseBlock(block asdk.BetaContentBlockUnion) (sigil.Part, bool) { return part, true case "tool_use", "server_tool_use", "mcp_tool_use": inputJSON, _ := marshalAny(block.Input) + providerType := providerTypeForToolUse(block.Type, block.Name) part := sigil.ToolCallPart(sigil.ToolCall{ ID: block.ID, Name: block.Name, InputJSON: inputJSON, }) - part.Metadata.ProviderType = block.Type + part.Metadata.ProviderType = providerType return part, true case "tool_result", "web_search_tool_result", @@ -366,6 +376,8 @@ func mapResponseBlock(block asdk.BetaContentBlockUnion) (sigil.Part, bool) { "bash_code_execution_tool_result", "text_editor_code_execution_tool_result", "tool_search_tool_result", + toolSearchRegexToolResultType, + toolSearchBM25ToolResultType, "mcp_tool_result": contentJSON, _ := marshalAny(block.Content) part := sigil.ToolResultPart(sigil.ToolResult{ @@ -397,6 +409,9 @@ func mapTools(tools []asdk.BetaToolUnionParam) []sigil.ToolDefinition { Description: derefString(tools[i].GetDescription()), Type: derefString(tools[i].GetType()), } + if deferred := tools[i].GetDeferLoading(); deferred != nil { + definition.Deferred = *deferred + } if schema := tools[i].GetInputSchema(); schema != nil { raw, err := marshalAny(*schema) @@ -411,6 +426,18 @@ func mapTools(tools []asdk.BetaToolUnionParam) []sigil.ToolDefinition { return out } +func providerTypeForToolUse(blockType, toolName string) string { + if blockType != "server_tool_use" { + return blockType + } + switch toolName { + case toolSearchRegexToolUseType, toolSearchBM25ToolUseType: + return toolName + default: + return blockType + } +} + func mapSystemPrompt(system []asdk.BetaTextBlockParam) string { if len(system) == 0 { return "" @@ -418,11 +445,7 @@ func mapSystemPrompt(system []asdk.BetaTextBlockParam) string { parts := make([]string, 0, len(system)) for i := range system { - text := strings.TrimSpace(system[i].Text) - if text == "" { - continue - } - parts = append(parts, text) + parts = append(parts, system[i].Text) } return strings.Join(parts, "\n\n") diff --git a/go-providers/anthropic/mapper_test.go b/go-providers/anthropic/mapper_test.go index 74cd9fa..5399c04 100644 --- a/go-providers/anthropic/mapper_test.go +++ b/go-providers/anthropic/mapper_test.go @@ -1,11 +1,12 @@ package anthropic import ( + "encoding/json" "testing" asdk "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/packages/param" - "github.com/grafana/sigil/sdks/go/sigil" + "github.com/grafana/sigil-sdk/go/sigil" ) func TestFromRequestResponse(t *testing.T) { @@ -32,6 +33,7 @@ func TestFromRequestResponse(t *testing.T) { generation, err := FromRequestResponse(req, resp, WithConversationID("conv-9b2f"), + WithConversationTitle("Paris weather"), WithAgentName("agent-anthropic"), WithAgentVersion("v-anthropic"), WithTag("tenant", "t-123"), @@ -49,6 +51,9 @@ func TestFromRequestResponse(t *testing.T) { if generation.ConversationID != "conv-9b2f" { t.Fatalf("expected conversation id conv-9b2f, got %q", generation.ConversationID) } + if generation.ConversationTitle != "Paris weather" { + t.Fatalf("expected conversation title Paris weather, got %q", generation.ConversationTitle) + } if generation.AgentName != "agent-anthropic" { t.Fatalf("expected agent-anthropic, got %q", generation.AgentName) } @@ -109,11 +114,23 @@ func TestFromRequestResponse(t *testing.T) { if len(generation.Artifacts) != 0 { t.Fatalf("expected 0 artifacts by default, got %d", len(generation.Artifacts)) } + if len(generation.Tools) != 1 { + t.Fatalf("expected 1 tool, got %d", len(generation.Tools)) + } + if !generation.Tools[0].Deferred { + t.Fatalf("expected mapped tool deferred=true") + } hasToolRole := false for _, message := range generation.Input { if message.Role == sigil.RoleTool { hasToolRole = true + if len(message.Parts) != 1 || message.Parts[0].ToolResult == nil { + t.Fatalf("expected single tool_result part, got %#v", message.Parts) + } + if message.Parts[0].ToolResult.ToolCallID != "toolu_1" { + t.Fatalf("expected Anthropic tool_result tool_call_id toolu_1, got %q", message.Parts[0].ToolResult.ToolCallID) + } } } if !hasToolRole { @@ -240,6 +257,12 @@ func TestFromStream(t *testing.T) { if len(generation.Artifacts) != 0 { t.Fatalf("expected 0 artifacts by default, got %d", len(generation.Artifacts)) } + if len(generation.Tools) != 1 { + t.Fatalf("expected 1 tool, got %d", len(generation.Tools)) + } + if !generation.Tools[0].Deferred { + t.Fatalf("expected mapped tool deferred=true") + } } func TestFromStream_DeltaAccumulation(t *testing.T) { @@ -541,6 +564,409 @@ func TestFromRequestResponseMapsThinkingDisabled(t *testing.T) { } } +func TestFromRequestResponseMapsToolDeferredDefaultFalse(t *testing.T) { + req := testRequest() + req.Tools = []asdk.BetaToolUnionParam{ + asdk.BetaToolUnionParamOfTool(asdk.BetaToolInputSchemaParam{ + Type: "object", + Properties: map[string]any{ + "city": map[string]any{ + "type": "string", + }, + }, + Required: []string{"city"}, + }, "weather"), + } + + resp := &asdk.BetaMessage{ + ID: "msg_1", + Model: asdk.Model("claude-sonnet-4-5"), + StopReason: asdk.BetaStopReasonEndTurn, + Content: []asdk.BetaContentBlockUnion{ + {Type: "text", Text: "done"}, + }, + } + + generation, err := FromRequestResponse(req, resp) + if err != nil { + t.Fatalf("from request/response: %v", err) + } + if len(generation.Tools) != 1 { + t.Fatalf("expected 1 tool, got %d", len(generation.Tools)) + } + if generation.Tools[0].Deferred { + t.Fatalf("expected mapped tool deferred=false when defer_loading is unset") + } +} + +func TestFromRequestResponsePreservesWhitespaceInTextAndSystemPrompt(t *testing.T) { + req := asdk.BetaMessageNewParams{ + MaxTokens: 1, + Model: asdk.Model("claude-sonnet-4-5"), + System: []asdk.BetaTextBlockParam{ + {Text: " first system ", Type: "text"}, + {Text: " second system ", Type: "text"}, + }, + Messages: []asdk.BetaMessageParam{ + { + Role: asdk.BetaMessageParamRoleUser, + Content: []asdk.BetaContentBlockParamUnion{ + asdk.NewBetaTextBlock(" user content with literal \\\\n\\\\n "), + }, + }, + }, + } + + resp := &asdk.BetaMessage{ + ID: "msg_whitespace", + Model: asdk.Model("claude-sonnet-4-5"), + StopReason: asdk.BetaStopReasonEndTurn, + Content: []asdk.BetaContentBlockUnion{ + {Type: "text", Text: "\n assistant content \n"}, + }, + } + + generation, err := FromRequestResponse(req, resp) + if err != nil { + t.Fatalf("from request/response: %v", err) + } + + if generation.SystemPrompt != " first system \n\n second system " { + t.Fatalf("unexpected system prompt %q", generation.SystemPrompt) + } + if len(generation.Input) != 1 || len(generation.Input[0].Parts) != 1 { + t.Fatalf("expected one input text part, got %#v", generation.Input) + } + if generation.Input[0].Parts[0].Text != " user content with literal \\\\n\\\\n " { + t.Fatalf("unexpected input text %q", generation.Input[0].Parts[0].Text) + } + if len(generation.Output) != 1 || len(generation.Output[0].Parts) != 1 { + t.Fatalf("expected one output text part, got %#v", generation.Output) + } + if generation.Output[0].Parts[0].Text != "\n assistant content \n" { + t.Fatalf("unexpected output text %q", generation.Output[0].Parts[0].Text) + } +} + +func TestFromStreamPreservesWhitespaceOnlyParts(t *testing.T) { + req := asdk.BetaMessageNewParams{ + MaxTokens: 1, + Model: asdk.Model("claude-sonnet-4-5"), + } + + summary := StreamSummary{ + Events: []asdk.BetaRawMessageStreamEventUnion{ + { + Type: "message_start", + Message: asdk.BetaMessage{ + ID: "msg_stream_whitespace", + Model: asdk.Model("claude-sonnet-4-5"), + }, + }, + { + Type: "content_block_start", + Index: 0, + ContentBlock: asdk.BetaRawContentBlockStartEventContentBlockUnion{ + Type: "thinking", + }, + }, + { + Type: "content_block_delta", + Index: 0, + Delta: asdk.BetaRawMessageStreamEventUnionDelta{Thinking: " "}, + }, + { + Type: "content_block_start", + Index: 1, + ContentBlock: asdk.BetaRawContentBlockStartEventContentBlockUnion{ + Type: "text", + Text: " ", + }, + }, + { + Type: "message_delta", + Delta: asdk.BetaRawMessageStreamEventUnionDelta{ + StopReason: asdk.BetaStopReasonEndTurn, + }, + Usage: asdk.BetaMessageDeltaUsage{ + InputTokens: 1, + OutputTokens: 1, + }, + }, + }, + } + + generation, err := FromStream(req, summary) + if err != nil { + t.Fatalf("from stream: %v", err) + } + if len(generation.Output) != 1 || len(generation.Output[0].Parts) != 2 { + t.Fatalf("expected two output parts, got %#v", generation.Output) + } + if generation.Output[0].Parts[0].Thinking != " " { + t.Fatalf("unexpected thinking %q", generation.Output[0].Parts[0].Thinking) + } + if generation.Output[0].Parts[1].Text != " " { + t.Fatalf("unexpected text %q", generation.Output[0].Parts[1].Text) + } +} + +func TestMapSystemPromptPreservesEmptySegments(t *testing.T) { + got := mapSystemPrompt([]asdk.BetaTextBlockParam{ + {Text: "", Type: "text"}, + {Text: "second", Type: "text"}, + }) + if got != "\n\nsecond" { + t.Fatalf("expected preserved empty segment separator, got %q", got) + } +} + +func TestFromRequestResponsePreservesToolSearchVariantToolResultTypes(t *testing.T) { + req := testRequest() + resp := &asdk.BetaMessage{ + ID: "msg_variant_types", + Model: asdk.Model("claude-sonnet-4-5"), + StopReason: asdk.BetaStopReasonEndTurn, + Content: []asdk.BetaContentBlockUnion{ + mustUnmarshalBetaContentBlockUnion(t, `{"type":"tool_search_tool_regex_tool_result","tool_use_id":"toolu_regex","content":{"type":"tool_search_tool_search_result","tool_references":[]}}`), + mustUnmarshalBetaContentBlockUnion(t, `{"type":"tool_search_tool_bm25_tool_result","tool_use_id":"toolu_bm25","content":{"type":"tool_search_tool_search_result","tool_references":[]}}`), + }, + } + + generation, err := FromRequestResponse(req, resp) + if err != nil { + t.Fatalf("from request/response: %v", err) + } + + if len(generation.Output) != 1 { + t.Fatalf("expected 1 output message (tool), got %d", len(generation.Output)) + } + if generation.Output[0].Role != sigil.RoleTool { + t.Fatalf("expected tool role, got %q", generation.Output[0].Role) + } + if len(generation.Output[0].Parts) != 2 { + t.Fatalf("expected 2 tool result parts, got %d", len(generation.Output[0].Parts)) + } + + regexPart := generation.Output[0].Parts[0] + if regexPart.Metadata.ProviderType != toolSearchRegexToolResultType { + t.Fatalf("expected regex provider type %q, got %q", toolSearchRegexToolResultType, regexPart.Metadata.ProviderType) + } + if regexPart.ToolResult.ToolCallID != "toolu_regex" { + t.Fatalf("expected regex tool call id toolu_regex, got %q", regexPart.ToolResult.ToolCallID) + } + + bm25Part := generation.Output[0].Parts[1] + if bm25Part.Metadata.ProviderType != toolSearchBM25ToolResultType { + t.Fatalf("expected bm25 provider type %q, got %q", toolSearchBM25ToolResultType, bm25Part.Metadata.ProviderType) + } + if bm25Part.ToolResult.ToolCallID != "toolu_bm25" { + t.Fatalf("expected bm25 tool call id toolu_bm25, got %q", bm25Part.ToolResult.ToolCallID) + } +} + +func TestFromStreamPreservesToolSearchVariantToolResultTypes(t *testing.T) { + req := asdk.BetaMessageNewParams{ + MaxTokens: 1, + Model: asdk.Model("claude-sonnet-4-5"), + } + + summary := StreamSummary{ + Events: []asdk.BetaRawMessageStreamEventUnion{ + { + Type: "message_start", + Message: asdk.BetaMessage{ + ID: "msg_stream_variants", + Model: asdk.Model("claude-sonnet-4-5"), + }, + }, + { + Type: "content_block_start", + Index: 0, + ContentBlock: mustUnmarshalBetaRawContentBlockStartEventContentBlockUnion(t, `{"type":"tool_search_tool_regex_tool_result","tool_use_id":"toolu_regex","content":{"type":"tool_search_tool_search_result","tool_references":[]}}`), + }, + { + Type: "content_block_start", + Index: 1, + ContentBlock: mustUnmarshalBetaRawContentBlockStartEventContentBlockUnion(t, `{"type":"tool_search_tool_bm25_tool_result","tool_use_id":"toolu_bm25","content":{"type":"tool_search_tool_search_result","tool_references":[]}}`), + }, + { + Type: "message_delta", + Delta: asdk.BetaRawMessageStreamEventUnionDelta{ + StopReason: asdk.BetaStopReasonEndTurn, + }, + Usage: asdk.BetaMessageDeltaUsage{ + InputTokens: 10, + OutputTokens: 5, + }, + }, + }, + } + + generation, err := FromStream(req, summary) + if err != nil { + t.Fatalf("from stream: %v", err) + } + + if len(generation.Output) != 1 { + t.Fatalf("expected 1 output message (tool), got %d", len(generation.Output)) + } + if generation.Output[0].Role != sigil.RoleTool { + t.Fatalf("expected tool role, got %q", generation.Output[0].Role) + } + if len(generation.Output[0].Parts) != 2 { + t.Fatalf("expected 2 tool result parts, got %d", len(generation.Output[0].Parts)) + } + + regexPart := generation.Output[0].Parts[0] + if regexPart.Metadata.ProviderType != toolSearchRegexToolResultType { + t.Fatalf("expected regex provider type %q, got %q", toolSearchRegexToolResultType, regexPart.Metadata.ProviderType) + } + if regexPart.ToolResult.ToolCallID != "toolu_regex" { + t.Fatalf("expected regex tool call id toolu_regex, got %q", regexPart.ToolResult.ToolCallID) + } + + bm25Part := generation.Output[0].Parts[1] + if bm25Part.Metadata.ProviderType != toolSearchBM25ToolResultType { + t.Fatalf("expected bm25 provider type %q, got %q", toolSearchBM25ToolResultType, bm25Part.Metadata.ProviderType) + } + if bm25Part.ToolResult.ToolCallID != "toolu_bm25" { + t.Fatalf("expected bm25 tool call id toolu_bm25, got %q", bm25Part.ToolResult.ToolCallID) + } +} + +func TestFromRequestResponsePreservesToolSearchVariantToolUseTypes(t *testing.T) { + req := testRequest() + resp := &asdk.BetaMessage{ + ID: "msg_variant_tool_use_types", + Model: asdk.Model("claude-sonnet-4-5"), + StopReason: asdk.BetaStopReasonToolUse, + Content: []asdk.BetaContentBlockUnion{ + mustUnmarshalBetaContentBlockUnion(t, `{"type":"server_tool_use","id":"toolu_regex","name":"tool_search_tool_regex","input":{"query":"error"}}`), + mustUnmarshalBetaContentBlockUnion(t, `{"type":"server_tool_use","id":"toolu_bm25","name":"tool_search_tool_bm25","input":{"query":"latency"}}`), + }, + } + + generation, err := FromRequestResponse(req, resp) + if err != nil { + t.Fatalf("from request/response: %v", err) + } + + if len(generation.Output) != 1 { + t.Fatalf("expected 1 output assistant message, got %d", len(generation.Output)) + } + if generation.Output[0].Role != sigil.RoleAssistant { + t.Fatalf("expected assistant role, got %q", generation.Output[0].Role) + } + if len(generation.Output[0].Parts) != 2 { + t.Fatalf("expected 2 tool call parts, got %d", len(generation.Output[0].Parts)) + } + + regexPart := generation.Output[0].Parts[0] + if regexPart.Metadata.ProviderType != toolSearchRegexToolUseType { + t.Fatalf("expected regex provider type %q, got %q", toolSearchRegexToolUseType, regexPart.Metadata.ProviderType) + } + if regexPart.ToolCall.Name != toolSearchRegexToolUseType { + t.Fatalf("expected regex tool call name %q, got %q", toolSearchRegexToolUseType, regexPart.ToolCall.Name) + } + + bm25Part := generation.Output[0].Parts[1] + if bm25Part.Metadata.ProviderType != toolSearchBM25ToolUseType { + t.Fatalf("expected bm25 provider type %q, got %q", toolSearchBM25ToolUseType, bm25Part.Metadata.ProviderType) + } + if bm25Part.ToolCall.Name != toolSearchBM25ToolUseType { + t.Fatalf("expected bm25 tool call name %q, got %q", toolSearchBM25ToolUseType, bm25Part.ToolCall.Name) + } +} + +func TestFromStreamPreservesToolSearchVariantToolUseTypes(t *testing.T) { + req := asdk.BetaMessageNewParams{ + MaxTokens: 1, + Model: asdk.Model("claude-sonnet-4-5"), + } + + summary := StreamSummary{ + Events: []asdk.BetaRawMessageStreamEventUnion{ + { + Type: "message_start", + Message: asdk.BetaMessage{ + ID: "msg_stream_tool_use_variants", + Model: asdk.Model("claude-sonnet-4-5"), + }, + }, + { + Type: "content_block_start", + Index: 0, + ContentBlock: mustUnmarshalBetaRawContentBlockStartEventContentBlockUnion(t, `{"type":"server_tool_use","id":"toolu_regex","name":"tool_search_tool_regex","input":{"query":"error"}}`), + }, + { + Type: "content_block_start", + Index: 1, + ContentBlock: mustUnmarshalBetaRawContentBlockStartEventContentBlockUnion(t, `{"type":"server_tool_use","id":"toolu_bm25","name":"tool_search_tool_bm25","input":{"query":"latency"}}`), + }, + { + Type: "message_delta", + Delta: asdk.BetaRawMessageStreamEventUnionDelta{ + StopReason: asdk.BetaStopReasonToolUse, + }, + Usage: asdk.BetaMessageDeltaUsage{ + InputTokens: 10, + OutputTokens: 5, + }, + }, + }, + } + + generation, err := FromStream(req, summary) + if err != nil { + t.Fatalf("from stream: %v", err) + } + + if len(generation.Output) != 1 { + t.Fatalf("expected 1 output assistant message, got %d", len(generation.Output)) + } + if generation.Output[0].Role != sigil.RoleAssistant { + t.Fatalf("expected assistant role, got %q", generation.Output[0].Role) + } + if len(generation.Output[0].Parts) != 2 { + t.Fatalf("expected 2 tool call parts, got %d", len(generation.Output[0].Parts)) + } + + regexPart := generation.Output[0].Parts[0] + if regexPart.Metadata.ProviderType != toolSearchRegexToolUseType { + t.Fatalf("expected regex provider type %q, got %q", toolSearchRegexToolUseType, regexPart.Metadata.ProviderType) + } + if regexPart.ToolCall.Name != toolSearchRegexToolUseType { + t.Fatalf("expected regex tool call name %q, got %q", toolSearchRegexToolUseType, regexPart.ToolCall.Name) + } + + bm25Part := generation.Output[0].Parts[1] + if bm25Part.Metadata.ProviderType != toolSearchBM25ToolUseType { + t.Fatalf("expected bm25 provider type %q, got %q", toolSearchBM25ToolUseType, bm25Part.Metadata.ProviderType) + } + if bm25Part.ToolCall.Name != toolSearchBM25ToolUseType { + t.Fatalf("expected bm25 tool call name %q, got %q", toolSearchBM25ToolUseType, bm25Part.ToolCall.Name) + } +} + +func mustUnmarshalBetaContentBlockUnion(t *testing.T, payload string) asdk.BetaContentBlockUnion { + t.Helper() + var block asdk.BetaContentBlockUnion + if err := json.Unmarshal([]byte(payload), &block); err != nil { + t.Fatalf("unmarshal beta content block union: %v", err) + } + return block +} + +func mustUnmarshalBetaRawContentBlockStartEventContentBlockUnion(t *testing.T, payload string) asdk.BetaRawContentBlockStartEventContentBlockUnion { + t.Helper() + var block asdk.BetaRawContentBlockStartEventContentBlockUnion + if err := json.Unmarshal([]byte(payload), &block); err != nil { + t.Fatalf("unmarshal beta raw content block start event union: %v", err) + } + return block +} + func testRequest() asdk.BetaMessageNewParams { toolResult := asdk.NewBetaToolResultBlock("toolu_1", "", false) toolResult.OfToolResult.Content = []asdk.BetaToolResultBlockParamContentUnion{ @@ -552,6 +978,17 @@ func testRequest() asdk.BetaMessageNewParams { }, } + weatherTool := asdk.BetaToolUnionParamOfTool(asdk.BetaToolInputSchemaParam{ + Type: "object", + Properties: map[string]any{ + "city": map[string]any{ + "type": "string", + }, + }, + Required: []string{"city"}, + }, "weather") + weatherTool.OfTool.DeferLoading = param.NewOpt(true) + return asdk.BetaMessageNewParams{ MaxTokens: 512, Model: asdk.Model("claude-sonnet-4-5"), @@ -586,16 +1023,6 @@ func testRequest() asdk.BetaMessageNewParams { }, }, }, - Tools: []asdk.BetaToolUnionParam{ - asdk.BetaToolUnionParamOfTool(asdk.BetaToolInputSchemaParam{ - Type: "object", - Properties: map[string]any{ - "city": map[string]any{ - "type": "string", - }, - }, - Required: []string{"city"}, - }, "weather"), - }, + Tools: []asdk.BetaToolUnionParam{weatherTool}, } } diff --git a/go-providers/anthropic/options.go b/go-providers/anthropic/options.go index 57c6ab8..5b69892 100644 --- a/go-providers/anthropic/options.go +++ b/go-providers/anthropic/options.go @@ -4,12 +4,13 @@ package anthropic type Option func(*mapperOptions) type mapperOptions struct { - providerName string - conversationID string - agentName string - agentVersion string - tags map[string]string - metadata map[string]any + providerName string + conversationID string + conversationTitle string + agentName string + agentVersion string + tags map[string]string + metadata map[string]any includeRequestArtifact bool includeResponseArtifact bool @@ -45,6 +46,13 @@ func WithConversationID(conversationID string) Option { } } +// WithConversationTitle sets Generation.ConversationTitle. +func WithConversationTitle(conversationTitle string) Option { + return func(options *mapperOptions) { + options.conversationTitle = conversationTitle + } +} + // WithAgentName sets Generation.AgentName. func WithAgentName(agentName string) Option { return func(options *mapperOptions) { diff --git a/go-providers/anthropic/record.go b/go-providers/anthropic/record.go index 8d1b639..78e21b5 100644 --- a/go-providers/anthropic/record.go +++ b/go-providers/anthropic/record.go @@ -6,7 +6,7 @@ import ( asdk "github.com/anthropics/anthropic-sdk-go" - "github.com/grafana/sigil/sdks/go/sigil" + "github.com/grafana/sigil-sdk/go/sigil" ) // Message calls the Anthropic messages API and records the generation. @@ -18,18 +18,31 @@ func Message( provider asdk.Client, req asdk.BetaMessageNewParams, opts ...Option, +) (*asdk.BetaMessage, error) { + return message(ctx, client, req, func(ctx context.Context, request asdk.BetaMessageNewParams) (*asdk.BetaMessage, error) { + return provider.Beta.Messages.New(ctx, request) + }, opts...) +} + +func message( + ctx context.Context, + client *sigil.Client, + req asdk.BetaMessageNewParams, + invoke func(context.Context, asdk.BetaMessageNewParams) (*asdk.BetaMessage, error), + opts ...Option, ) (*asdk.BetaMessage, error) { options := applyOptions(opts) ctx, rec := client.StartGeneration(ctx, sigil.GenerationStart{ - ConversationID: options.conversationID, - AgentName: options.agentName, - AgentVersion: options.agentVersion, - Model: sigil.ModelRef{Provider: options.providerName, Name: string(req.Model)}, + ConversationID: options.conversationID, + ConversationTitle: options.conversationTitle, + AgentName: options.agentName, + AgentVersion: options.agentVersion, + Model: sigil.ModelRef{Provider: options.providerName, Name: string(req.Model)}, }) defer rec.End() - resp, err := provider.Beta.Messages.New(ctx, req) + resp, err := invoke(ctx, req) if err != nil { rec.SetCallError(err) return nil, err @@ -53,10 +66,11 @@ func MessageStream( options := applyOptions(opts) ctx, rec := client.StartStreamingGeneration(ctx, sigil.GenerationStart{ - ConversationID: options.conversationID, - AgentName: options.agentName, - AgentVersion: options.agentVersion, - Model: sigil.ModelRef{Provider: options.providerName, Name: string(req.Model)}, + ConversationID: options.conversationID, + ConversationTitle: options.conversationTitle, + AgentName: options.agentName, + AgentVersion: options.agentVersion, + Model: sigil.ModelRef{Provider: options.providerName, Name: string(req.Model)}, }) defer rec.End() diff --git a/go-providers/anthropic/record_test.go b/go-providers/anthropic/record_test.go new file mode 100644 index 0000000..5b28e65 --- /dev/null +++ b/go-providers/anthropic/record_test.go @@ -0,0 +1,76 @@ +package anthropic + +import ( + "context" + "errors" + "testing" + + asdk "github.com/anthropics/anthropic-sdk-go" + + "github.com/grafana/sigil-sdk/go/sigil" +) + +func TestConformance_MessageErrorMapping(t *testing.T) { + client := newProviderTestClient(t) + req := testRequest() + + t.Run("provider errors are preserved", func(t *testing.T) { + providerErr := errors.New("provider failed") + + response, err := message( + context.Background(), + client, + req, + func(context.Context, asdk.BetaMessageNewParams) (*asdk.BetaMessage, error) { + return nil, providerErr + }, + ) + if !errors.Is(err, providerErr) { + t.Fatalf("expected provider error, got %v", err) + } + if response != nil { + t.Fatalf("expected nil response on provider error") + } + }) + + t.Run("mapping failures do not hide provider responses", func(t *testing.T) { + expectedResponse := &asdk.BetaMessage{ + Model: asdk.Model("claude-sonnet-4-5"), + StopReason: asdk.BetaStopReasonEndTurn, + Content: []asdk.BetaContentBlockUnion{ + {Type: "text", Text: "hi"}, + }, + } + + response, err := message( + context.Background(), + client, + req, + func(context.Context, asdk.BetaMessageNewParams) (*asdk.BetaMessage, error) { + return expectedResponse, nil + }, + WithProviderName(""), + ) + if err != nil { + t.Fatalf("expected nil local error for mapping failure, got %v", err) + } + if response != expectedResponse { + t.Fatalf("expected wrapper to return provider response pointer") + } + }) +} + +func newProviderTestClient(t *testing.T) *sigil.Client { + t.Helper() + + cfg := sigil.DefaultConfig() + cfg.GenerationExport.Protocol = sigil.GenerationExportProtocolNone + + client := sigil.NewClient(cfg) + t.Cleanup(func() { + if err := client.Shutdown(context.Background()); err != nil { + t.Errorf("shutdown sigil client: %v", err) + } + }) + return client +} diff --git a/go-providers/anthropic/sdk_example_test.go b/go-providers/anthropic/sdk_example_test.go index 6672247..111765c 100644 --- a/go-providers/anthropic/sdk_example_test.go +++ b/go-providers/anthropic/sdk_example_test.go @@ -6,7 +6,7 @@ import ( asdk "github.com/anthropics/anthropic-sdk-go" asdkoption "github.com/anthropics/anthropic-sdk-go/option" - "github.com/grafana/sigil/sdks/go/sigil" + "github.com/grafana/sigil-sdk/go/sigil" ) // Example_withSigilWrapper shows the one-liner wrapper approach. diff --git a/go-providers/anthropic/stream_mapper.go b/go-providers/anthropic/stream_mapper.go index cb5a2c7..3090b1f 100644 --- a/go-providers/anthropic/stream_mapper.go +++ b/go-providers/anthropic/stream_mapper.go @@ -7,7 +7,7 @@ import ( "time" asdk "github.com/anthropics/anthropic-sdk-go" - "github.com/grafana/sigil/sdks/go/sigil" + "github.com/grafana/sigil-sdk/go/sigil" ) // StreamSummary captures Anthropic stream events and an optional final message. @@ -107,26 +107,27 @@ func FromStream(req asdk.BetaMessageNewParams, summary StreamSummary, opts ...Op } generation := sigil.Generation{ - ConversationID: options.conversationID, - AgentName: options.agentName, - AgentVersion: options.agentVersion, - Model: sigil.ModelRef{Provider: options.providerName, Name: string(req.Model)}, - ResponseID: responseID, - ResponseModel: modelName, - SystemPrompt: mapSystemPrompt(req.System), - Input: input, - Output: output, - Tools: mapTools(req.Tools), - MaxTokens: maxTokens, - Temperature: temperature, - TopP: topP, - ToolChoice: toolChoice, - ThinkingEnabled: thinkingEnabled, - Usage: usage, - StopReason: stopReason, - Tags: cloneStringMap(options.tags), - Metadata: metadata, - Artifacts: artifacts, + ConversationID: options.conversationID, + ConversationTitle: options.conversationTitle, + AgentName: options.agentName, + AgentVersion: options.agentVersion, + Model: sigil.ModelRef{Provider: options.providerName, Name: string(req.Model)}, + ResponseID: responseID, + ResponseModel: modelName, + SystemPrompt: mapSystemPrompt(req.System), + Input: input, + Output: output, + Tools: mapTools(req.Tools), + MaxTokens: maxTokens, + Temperature: temperature, + TopP: topP, + ToolChoice: toolChoice, + ThinkingEnabled: thinkingEnabled, + Usage: usage, + StopReason: stopReason, + Tags: cloneStringMap(options.tags), + Metadata: metadata, + Artifacts: artifacts, } if err := generation.Validate(); err != nil { @@ -209,6 +210,7 @@ func (a *streamBlockAccumulator) startBlock(index int, cb asdk.BetaRawContentBlo case "tool_use", "server_tool_use", "mcp_tool_use": b.toolID = cb.ID b.toolName = cb.Name + b.providerType = providerTypeForToolUse(cb.Type, cb.Name) if cb.Input != nil { if raw, err := json.Marshal(cb.Input); err == nil && string(raw) != "{}" { b.toolJSON.Write(raw) @@ -283,14 +285,14 @@ func (a *streamBlockAccumulator) build() (assistantParts, toolParts []sigil.Part func (b *streamBlock) toPart() (sigil.Part, bool, bool) { switch b.blockType { case "text": - text := strings.TrimSpace(b.text.String()) + text := b.text.String() if text == "" { return sigil.Part{}, false, false } return sigil.TextPart(text), false, true case "thinking", "redacted_thinking": content := b.thinking.String() - if strings.TrimSpace(content) == "" { + if content == "" { return sigil.Part{}, false, false } part := sigil.ThinkingPart(content) @@ -332,6 +334,8 @@ func isToolResultType(t string) bool { "bash_code_execution_tool_result", "text_editor_code_execution_tool_result", "tool_search_tool_result", + toolSearchRegexToolResultType, + toolSearchBM25ToolResultType, "mcp_tool_result": return true } diff --git a/go-providers/gemini/LICENSE b/go-providers/gemini/LICENSE index ae8c60c..626a3ab 100644 --- a/go-providers/gemini/LICENSE +++ b/go-providers/gemini/LICENSE @@ -1,3 +1,3 @@ SPDX-License-Identifier: Apache-2.0 -See /sdks/LICENSE at repository root for full license text. +See /LICENSE at repository root for full license text. diff --git a/go-providers/gemini/README.md b/go-providers/gemini/README.md index 6dd944c..ceb6b86 100644 --- a/go-providers/gemini/README.md +++ b/go-providers/gemini/README.md @@ -7,8 +7,10 @@ typed Sigil `Generation` model. - One-liner wrappers: - `GenerateContent(ctx, sigilClient, provider, model, contents, config, opts...)` - `GenerateContentStream(ctx, sigilClient, provider, model, contents, config, opts...)` + - `EmbedContent(ctx, sigilClient, provider, model, contents, config, opts...)` - Request/response mapper: - `FromRequestResponse(model, contents, config, resp, opts...)` + - `EmbeddingFromResponse(model, contents, config, resp)` - Stream mapper: - `FromStream(model, contents, config, summary, opts...)` - Typed artifacts: @@ -24,6 +26,7 @@ typed Sigil `Generation` model. ```go resp, err := gemini.GenerateContent(ctx, sigilClient, providerClient, model, contents, config, gemini.WithConversationID("conv-1"), + gemini.WithConversationTitle("Weather follow-up"), gemini.WithAgentName("assistant-gemini"), gemini.WithAgentVersion("1.0.0"), ) @@ -33,6 +36,16 @@ if err != nil { _ = resp.Candidates[0].Content.Parts[0].Text ``` +## Embedding Wrapper + +```go +embedResp, err := gemini.EmbedContent(ctx, sigilClient, providerClient, "gemini-embedding-001", contents, &genai.EmbedContentConfig{}) +if err != nil { + return err +} +_ = embedResp.Embeddings +``` + ## Defer Pattern (full control) ```go ctx, rec := sigilClient.StartGeneration(ctx, sigil.GenerationStart{ @@ -89,3 +102,5 @@ Gemini-specific fields are mapped as follows: - `usage.toolUsePromptTokenCount` -> metadata `sigil.gen_ai.usage.tool_use_prompt_tokens` - `config.thinkingConfig.thinkingBudget` -> metadata `sigil.gen_ai.request.thinking.budget_tokens` - `config.thinkingConfig.thinkingLevel` -> metadata `sigil.gen_ai.request.thinking.level` +- `function_response.id` -> normalized `tool_result.tool_call_id` when present +- Gemini helper constructors can surface `function_response` parts without an ID; in that case the mapper preserves `tool_result.name` as the fallback correlation key diff --git a/go-providers/gemini/conformance_test.go b/go-providers/gemini/conformance_test.go new file mode 100644 index 0000000..43d0c69 --- /dev/null +++ b/go-providers/gemini/conformance_test.go @@ -0,0 +1,604 @@ +package gemini + +import ( + "math" + "strings" + "testing" + "time" + + "google.golang.org/genai" + + sigil "github.com/grafana/sigil-sdk/go/sigil" + "github.com/grafana/sigil-sdk/go/sigil/sigiltest" +) + +const ( + geminiSpanErrorCategory = "error.category" + geminiSpanInputCount = "gen_ai.embeddings.input_count" + geminiSpanDimCount = "gen_ai.embeddings.dimension.count" +) + +func TestConformance_GeminiSyncMapping(t *testing.T) { + env := sigiltest.NewEnv(t) + + model, contents, config := geminiConformanceRequest() + resp := &genai.GenerateContentResponse{ + ResponseID: "resp_gemini_sync", + ModelVersion: "gemini-2.5-pro-001", + Candidates: []*genai.Candidate{ + { + FinishReason: genai.FinishReasonStop, + Content: genai.NewContentFromParts([]*genai.Part{ + {Text: "need weather tool", Thought: true}, + { + FunctionCall: &genai.FunctionCall{ + ID: "call_weather", + Name: "weather", + Args: map[string]any{"city": "Paris"}, + }, + }, + genai.NewPartFromText("It is 18C and sunny."), + }, genai.RoleModel), + }, + }, + UsageMetadata: &genai.GenerateContentResponseUsageMetadata{ + PromptTokenCount: 120, + CandidatesTokenCount: 40, + TotalTokenCount: 160, + CachedContentTokenCount: 12, + ThoughtsTokenCount: 10, + ToolUsePromptTokenCount: 9, + }, + } + start := sigil.GenerationStart{ + ConversationID: "conv-gemini-sync", + ConversationTitle: "Gemini sync", + AgentName: "agent-gemini", + AgentVersion: "v-gemini", + Model: sigil.ModelRef{Provider: "gemini", Name: model}, + } + + generation, err := FromRequestResponse( + model, + contents, + config, + resp, + WithConversationID(start.ConversationID), + WithConversationTitle(start.ConversationTitle), + WithAgentName(start.AgentName), + WithAgentVersion(start.AgentVersion), + WithTag("tenant", "t-gemini"), + ) + sigiltest.RecordGeneration(t, env, start, generation, err) + env.Shutdown(t) + + exported := env.SingleGenerationJSON(t) + + if got := sigiltest.StringValue(t, exported, "mode"); got != "GENERATION_MODE_SYNC" { + t.Fatalf("unexpected mode: got %q want %q\n%s", got, "GENERATION_MODE_SYNC", sigiltest.DebugJSON(exported)) + } + if got := sigiltest.StringValue(t, exported, "stop_reason"); got != "STOP" { + t.Fatalf("unexpected stop_reason: got %q want %q", got, "STOP") + } + if got := sigiltest.StringValue(t, exported, "output", 0, "parts", 0, "thinking"); got != "need weather tool" { + t.Fatalf("unexpected thinking part: got %q want %q", got, "need weather tool") + } + if got := sigiltest.StringValue(t, exported, "output", 0, "parts", 1, "tool_call", "name"); got != "weather" { + t.Fatalf("unexpected tool_call.name: got %q want %q", got, "weather") + } + if got := sigiltest.StringValue(t, exported, "output", 0, "parts", 2, "text"); got != "It is 18C and sunny." { + t.Fatalf("unexpected output text: got %q want %q", got, "It is 18C and sunny.") + } + if got := sigiltest.StringValue(t, exported, "input", 1, "role"); got != "MESSAGE_ROLE_TOOL" { + t.Fatalf("unexpected tool input role: got %q want %q", got, "MESSAGE_ROLE_TOOL") + } + if got := sigiltest.StringValue(t, exported, "usage", "reasoning_tokens"); got != "10" { + t.Fatalf("unexpected usage.reasoning_tokens: got %q want %q", got, "10") + } + if got := sigiltest.FloatValue(t, exported, "metadata", "sigil.gen_ai.usage.tool_use_prompt_tokens"); got != 9 { + t.Fatalf("unexpected tool_use_prompt_tokens: got %v want %v", got, float64(9)) + } +} + +func TestConformance_GeminiStreamMapping(t *testing.T) { + env := sigiltest.NewEnv(t) + + model, contents, config := geminiConformanceRequest() + summary := StreamSummary{ + FirstChunkAt: time.Unix(1_741_780_200, 0).UTC(), + Responses: []*genai.GenerateContentResponse{ + { + ResponseID: "resp_gemini_stream_1", + ModelVersion: "gemini-2.5-pro-001", + Candidates: []*genai.Candidate{ + { + Content: genai.NewContentFromParts([]*genai.Part{ + {Text: "need weather tool", Thought: true}, + { + FunctionCall: &genai.FunctionCall{ + ID: "call_weather", + Name: "weather", + Args: map[string]any{"city": "Paris"}, + }, + }, + }, genai.RoleModel), + }, + }, + }, + { + ResponseID: "resp_gemini_stream_2", + ModelVersion: "gemini-2.5-pro-001", + Candidates: []*genai.Candidate{ + { + FinishReason: genai.FinishReasonStop, + Content: genai.NewContentFromText("It is 18C and sunny.", genai.RoleModel), + }, + }, + UsageMetadata: &genai.GenerateContentResponseUsageMetadata{ + PromptTokenCount: 20, + CandidatesTokenCount: 6, + TotalTokenCount: 26, + ThoughtsTokenCount: 4, + ToolUsePromptTokenCount: 5, + }, + }, + }, + } + start := sigil.GenerationStart{ + ConversationID: "conv-gemini-stream", + AgentName: "agent-gemini-stream", + AgentVersion: "v-gemini-stream", + Model: sigil.ModelRef{Provider: "gemini", Name: model}, + } + + generation, err := FromStream( + model, + contents, + config, + summary, + WithConversationID(start.ConversationID), + WithAgentName(start.AgentName), + WithAgentVersion(start.AgentVersion), + ) + sigiltest.RecordStreamingGeneration(t, env, start, summary.FirstChunkAt, generation, err) + env.Shutdown(t) + + exported := env.SingleGenerationJSON(t) + + if got := sigiltest.StringValue(t, exported, "mode"); got != "GENERATION_MODE_STREAM" { + t.Fatalf("unexpected mode: got %q want %q\n%s", got, "GENERATION_MODE_STREAM", sigiltest.DebugJSON(exported)) + } + if got := sigiltest.StringValue(t, exported, "response_id"); got != "resp_gemini_stream_2" { + t.Fatalf("unexpected response_id: got %q want %q", got, "resp_gemini_stream_2") + } + if got := sigiltest.StringValue(t, exported, "stop_reason"); got != "STOP" { + t.Fatalf("unexpected stop_reason: got %q want %q", got, "STOP") + } + if got := sigiltest.StringValue(t, exported, "output", 0, "parts", 0, "thinking"); got != "need weather tool" { + t.Fatalf("unexpected streamed thinking part: got %q want %q", got, "need weather tool") + } + if got := sigiltest.StringValue(t, exported, "output", 0, "parts", 1, "tool_call", "name"); got != "weather" { + t.Fatalf("unexpected streamed tool_call.name: got %q want %q", got, "weather") + } + if got := sigiltest.StringValue(t, exported, "output", 1, "parts", 0, "text"); got != "It is 18C and sunny." { + t.Fatalf("unexpected streamed output text: got %q want %q", got, "It is 18C and sunny.") + } + if got := sigiltest.StringValue(t, exported, "usage", "total_tokens"); got != "26" { + t.Fatalf("unexpected usage.total_tokens: got %q want %q", got, "26") + } +} + +func TestConformance_GeminiErrorMapping(t *testing.T) { + env := sigiltest.NewEnv(t) + + sigiltest.RecordCallError(t, env, sigil.GenerationStart{ + Model: sigil.ModelRef{Provider: "gemini", Name: "gemini-2.5-pro"}, + }, genai.APIError{Code: 429, Message: "rate limited", Status: "RESOURCE_EXHAUSTED"}) + + span := sigiltest.FindSpan(t, env.Spans.Ended(), "generateText gemini-2.5-pro") + attrs := sigiltest.SpanAttributes(span) + if got := attrs[geminiSpanErrorCategory].AsString(); got != "rate_limit" { + t.Fatalf("unexpected error.category: got %q want %q", got, "rate_limit") + } + + env.Shutdown(t) + exported := env.SingleGenerationJSON(t) + callError := sigiltest.StringValue(t, exported, "call_error") + if !strings.Contains(callError, "429") { + t.Fatalf("expected call_error to include status code, got %q", callError) + } +} + +func TestConformance_GeminiEmbeddingMapping(t *testing.T) { + env := sigiltest.NewEnv(t) + + model := "gemini-embedding-001" + contents := []*genai.Content{ + genai.NewContentFromText("hello", genai.RoleUser), + genai.NewContentFromText("world", genai.RoleUser), + } + dimensions := int32(3) + config := &genai.EmbedContentConfig{ + OutputDimensionality: &dimensions, + } + resp := &genai.EmbedContentResponse{ + Embeddings: []*genai.ContentEmbedding{ + { + Values: []float32{0.1, 0.2, 0.3}, + Statistics: &genai.ContentEmbeddingStatistics{ + TokenCount: 2, + }, + }, + { + Values: []float32{0.4, 0.5, 0.6}, + Statistics: &genai.ContentEmbeddingStatistics{ + TokenCount: 2, + }, + }, + }, + } + startDimensions := int64(dimensions) + sigiltest.RecordEmbedding(t, env, sigil.EmbeddingStart{ + Model: sigil.ModelRef{Provider: "gemini", Name: model}, + AgentName: "agent-gemini-embed", + AgentVersion: "v-gemini-embed", + Dimensions: &startDimensions, + }, EmbeddingFromResponse(model, contents, config, resp)) + + span := sigiltest.FindSpan(t, env.Spans.Ended(), "embeddings gemini-embedding-001") + attrs := sigiltest.SpanAttributes(span) + if got := attrs[geminiSpanInputCount].AsInt64(); got != 2 { + t.Fatalf("unexpected gen_ai.embeddings.input_count: got %d want %d", got, 2) + } + if got := attrs[geminiSpanDimCount].AsInt64(); got != 3 { + t.Fatalf("unexpected gen_ai.embeddings.dimension.count: got %d want %d", got, 3) + } + + env.Shutdown(t) + sigiltest.RequireRequestCount(t, env, 0) +} + +func geminiConformanceRequest() (string, []*genai.Content, *genai.GenerateContentConfig) { + temperature := float32(0.4) + topP := float32(0.75) + thinkingBudget := int32(2048) + model := "gemini-2.5-pro" + contents := []*genai.Content{ + genai.NewContentFromText("What is the weather in Paris?", genai.RoleUser), + genai.NewContentFromParts([]*genai.Part{ + genai.NewPartFromFunctionResponse("weather", map[string]any{ + "temp_c": 18, + }), + }, genai.RoleUser), + } + config := &genai.GenerateContentConfig{ + SystemInstruction: genai.NewContentFromText("Be concise.", genai.RoleUser), + MaxOutputTokens: 300, + Temperature: &temperature, + TopP: &topP, + ToolConfig: &genai.ToolConfig{ + FunctionCallingConfig: &genai.FunctionCallingConfig{ + Mode: genai.FunctionCallingConfigModeAny, + }, + }, + ThinkingConfig: &genai.ThinkingConfig{ + IncludeThoughts: true, + ThinkingBudget: &thinkingBudget, + ThinkingLevel: genai.ThinkingLevelHigh, + }, + Tools: []*genai.Tool{ + { + FunctionDeclarations: []*genai.FunctionDeclaration{ + { + Name: "weather", + Description: "Get weather", + ParametersJsonSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "city": map[string]any{"type": "string"}, + }, + "required": []string{"city"}, + }, + }, + }, + }, + }, + } + return model, contents, config +} + +func TestConformance_GenerateContentSyncNormalization(t *testing.T) { + temperature := float32(0.4) + topP := float32(0.75) + thinkingBudget := int32(2048) + model := "gemini-2.5-pro" + contents := []*genai.Content{ + genai.NewContentFromText("What is the weather in Paris?", genai.RoleUser), + genai.NewContentFromParts([]*genai.Part{ + genai.NewPartFromFunctionResponse("weather", map[string]any{ + "temp_c": 18, + }), + }, genai.RoleUser), + } + config := &genai.GenerateContentConfig{ + SystemInstruction: genai.NewContentFromText("Be concise.", genai.RoleUser), + MaxOutputTokens: 300, + Temperature: &temperature, + TopP: &topP, + ToolConfig: &genai.ToolConfig{ + FunctionCallingConfig: &genai.FunctionCallingConfig{ + Mode: genai.FunctionCallingConfigModeAny, + }, + }, + ThinkingConfig: &genai.ThinkingConfig{ + IncludeThoughts: true, + ThinkingBudget: &thinkingBudget, + ThinkingLevel: genai.ThinkingLevelHigh, + }, + Tools: []*genai.Tool{ + { + FunctionDeclarations: []*genai.FunctionDeclaration{ + { + Name: "weather", + Description: "Get weather", + ParametersJsonSchema: map[string]any{ + "type": "object", + }, + }, + }, + }, + }, + } + + resp := &genai.GenerateContentResponse{ + ResponseID: "resp_1", + ModelVersion: "gemini-2.5-pro-001", + Candidates: []*genai.Candidate{ + { + FinishReason: genai.FinishReasonStop, + Content: genai.NewContentFromParts([]*genai.Part{ + { + Text: "reasoning trace", + Thought: true, + }, + { + FunctionCall: &genai.FunctionCall{ + ID: "call_weather", + Name: "weather", + Args: map[string]any{"city": "Paris"}, + }, + }, + genai.NewPartFromText("It is 18C and sunny."), + }, genai.RoleModel), + }, + }, + UsageMetadata: &genai.GenerateContentResponseUsageMetadata{ + PromptTokenCount: 120, + CandidatesTokenCount: 40, + TotalTokenCount: 160, + CachedContentTokenCount: 12, + ThoughtsTokenCount: 10, + ToolUsePromptTokenCount: 9, + }, + } + + generation, err := FromRequestResponse(model, contents, config, resp, + WithConversationID("conv-gemini-sync"), + WithConversationTitle("Paris weather"), + WithAgentName("agent-gemini"), + WithAgentVersion("v-gemini"), + WithTag("tenant", "t-123"), + WithRawArtifacts(), + ) + if err != nil { + t.Fatalf("gemini sync mapping: %v", err) + } + + if generation.Model.Provider != "gemini" || generation.Model.Name != "gemini-2.5-pro" { + t.Fatalf("unexpected model mapping: %#v", generation.Model) + } + if generation.ConversationID != "conv-gemini-sync" || generation.ConversationTitle != "Paris weather" { + t.Fatalf("unexpected conversation mapping: %#v", generation) + } + if generation.AgentName != "agent-gemini" || generation.AgentVersion != "v-gemini" { + t.Fatalf("unexpected agent mapping: name=%q version=%q", generation.AgentName, generation.AgentVersion) + } + if generation.ResponseID != "resp_1" || generation.ResponseModel != "gemini-2.5-pro-001" { + t.Fatalf("unexpected response mapping: id=%q model=%q", generation.ResponseID, generation.ResponseModel) + } + if generation.StopReason != "STOP" { + t.Fatalf("unexpected stop reason: %q", generation.StopReason) + } + if generation.Usage.TotalTokens != 160 || generation.Usage.CacheReadInputTokens != 12 || generation.Usage.ReasoningTokens != 10 { + t.Fatalf("unexpected usage mapping: %#v", generation.Usage) + } + if generation.ThinkingEnabled == nil || !*generation.ThinkingEnabled { + t.Fatalf("expected thinking enabled true, got %v", generation.ThinkingEnabled) + } + if generation.Temperature == nil || math.Abs(*generation.Temperature-0.4) > 1e-6 { + t.Fatalf("unexpected temperature: %v", generation.Temperature) + } + if generation.TopP == nil || math.Abs(*generation.TopP-0.75) > 1e-6 { + t.Fatalf("unexpected top_p: %v", generation.TopP) + } + if len(generation.Output) != 1 || len(generation.Output[0].Parts) != 3 { + t.Fatalf("expected thinking + tool call + text output, got %#v", generation.Output) + } + if generation.Output[0].Parts[0].Kind != sigil.PartKindThinking || generation.Output[0].Parts[0].Thinking != "reasoning trace" { + t.Fatalf("unexpected thinking output: %#v", generation.Output[0].Parts[0]) + } + if generation.Output[0].Parts[1].Kind != sigil.PartKindToolCall { + t.Fatalf("expected tool call output, got %#v", generation.Output[0].Parts[1]) + } + if generation.Output[0].Parts[2].Kind != sigil.PartKindText || generation.Output[0].Parts[2].Text != "It is 18C and sunny." { + t.Fatalf("unexpected text output: %#v", generation.Output[0].Parts[2]) + } + if generation.Metadata["sigil.gen_ai.request.thinking.level"] != "high" { + t.Fatalf("unexpected thinking level metadata: %#v", generation.Metadata) + } + if generation.Tags["tenant"] != "t-123" { + t.Fatalf("expected tenant tag") + } + requireGeminiArtifactKinds(t, generation.Artifacts, + sigil.ArtifactKindRequest, + sigil.ArtifactKindResponse, + sigil.ArtifactKindTools, + ) +} + +func TestConformance_GenerateContentStreamNormalization(t *testing.T) { + temperature := float32(0.2) + topP := float32(0.6) + thinkingBudget := int32(1536) + model := "gemini-2.5-pro" + contents := []*genai.Content{ + genai.NewContentFromText("What is the weather in Paris?", genai.RoleUser), + } + config := &genai.GenerateContentConfig{ + MaxOutputTokens: 90, + Temperature: &temperature, + TopP: &topP, + ToolConfig: &genai.ToolConfig{ + FunctionCallingConfig: &genai.FunctionCallingConfig{ + Mode: genai.FunctionCallingConfigModeAuto, + }, + }, + ThinkingConfig: &genai.ThinkingConfig{ + IncludeThoughts: true, + ThinkingBudget: &thinkingBudget, + ThinkingLevel: genai.ThinkingLevelMedium, + }, + Tools: []*genai.Tool{ + { + FunctionDeclarations: []*genai.FunctionDeclaration{ + {Name: "weather"}, + }, + }, + }, + } + + summary := StreamSummary{ + Responses: []*genai.GenerateContentResponse{ + { + ResponseID: "resp_stream_1", + ModelVersion: "gemini-2.5-pro-001", + Candidates: []*genai.Candidate{ + { + Content: genai.NewContentFromParts([]*genai.Part{ + { + Text: "reasoning trace", + Thought: true, + }, + { + FunctionCall: &genai.FunctionCall{ + ID: "call_weather", + Name: "weather", + Args: map[string]any{"city": "Paris"}, + }, + }, + }, genai.RoleModel), + }, + }, + }, + { + ResponseID: "resp_stream_2", + ModelVersion: "gemini-2.5-pro-001", + Candidates: []*genai.Candidate{ + { + FinishReason: genai.FinishReasonStop, + Content: genai.NewContentFromText("It is 18C and sunny.", genai.RoleModel), + }, + }, + UsageMetadata: &genai.GenerateContentResponseUsageMetadata{ + PromptTokenCount: 20, + CandidatesTokenCount: 6, + TotalTokenCount: 26, + ThoughtsTokenCount: 4, + ToolUsePromptTokenCount: 5, + }, + }, + }, + } + + generation, err := FromStream(model, contents, config, summary, + WithConversationID("conv-gemini-stream"), + WithAgentName("agent-gemini-stream"), + WithAgentVersion("v-gemini-stream"), + WithRawArtifacts(), + ) + if err != nil { + t.Fatalf("gemini stream mapping: %v", err) + } + + if generation.ConversationID != "conv-gemini-stream" || generation.AgentName != "agent-gemini-stream" || generation.AgentVersion != "v-gemini-stream" { + t.Fatalf("unexpected identity mapping: %#v", generation) + } + if generation.ResponseID != "resp_stream_2" || generation.ResponseModel != "gemini-2.5-pro-001" { + t.Fatalf("unexpected response mapping: id=%q model=%q", generation.ResponseID, generation.ResponseModel) + } + if generation.StopReason != "STOP" { + t.Fatalf("unexpected stop reason: %q", generation.StopReason) + } + if generation.Usage.TotalTokens != 26 || generation.Usage.ReasoningTokens != 4 { + t.Fatalf("unexpected usage mapping: %#v", generation.Usage) + } + if len(generation.Output) != 2 { + t.Fatalf("expected streamed thinking/tool output plus final text, got %#v", generation.Output) + } + if generation.Output[0].Parts[0].Kind != sigil.PartKindThinking || generation.Output[0].Parts[0].Thinking != "reasoning trace" { + t.Fatalf("unexpected streamed thinking output: %#v", generation.Output[0].Parts[0]) + } + if generation.Output[0].Parts[1].Kind != sigil.PartKindToolCall { + t.Fatalf("expected streamed tool call output, got %#v", generation.Output[0].Parts[1]) + } + if generation.Output[1].Parts[0].Kind != sigil.PartKindText || generation.Output[1].Parts[0].Text != "It is 18C and sunny." { + t.Fatalf("unexpected streamed text output: %#v", generation.Output[1].Parts[0]) + } + requireGeminiArtifactKinds(t, generation.Artifacts, + sigil.ArtifactKindRequest, + sigil.ArtifactKindTools, + sigil.ArtifactKindProviderEvent, + ) +} + +func TestConformance_GeminiMapperValidationErrors(t *testing.T) { + if _, err := FromRequestResponse("", nil, nil, &genai.GenerateContentResponse{}); err == nil || err.Error() != "request model is required" { + t.Fatalf("expected explicit request model error, got %v", err) + } + if _, err := FromRequestResponse("gemini-2.5-pro", nil, nil, nil); err == nil || err.Error() != "response is required" { + t.Fatalf("expected explicit response error, got %v", err) + } + if _, err := FromStream("gemini-2.5-pro", nil, nil, StreamSummary{}); err == nil || err.Error() != "stream summary has no responses" { + t.Fatalf("expected explicit stream error, got %v", err) + } + + _, err := FromRequestResponse( + "gemini-2.5-pro", + nil, + nil, + &genai.GenerateContentResponse{ + Candidates: []*genai.Candidate{ + { + Content: genai.NewContentFromText("ok", genai.RoleModel), + }, + }, + }, + WithProviderName(""), + ) + if err == nil || err.Error() != "generation.model.provider is required" { + t.Fatalf("expected explicit validation error for invalid provider mapping, got %v", err) + } +} + +func requireGeminiArtifactKinds(t *testing.T, artifacts []sigil.Artifact, want ...sigil.ArtifactKind) { + t.Helper() + + if len(artifacts) != len(want) { + t.Fatalf("expected %d artifacts, got %d", len(want), len(artifacts)) + } + for i, kind := range want { + if artifacts[i].Kind != kind { + t.Fatalf("artifact %d kind mismatch: got %q want %q", i, artifacts[i].Kind, kind) + } + } +} diff --git a/go-providers/gemini/go.mod b/go-providers/gemini/go.mod index 4f1b170..afc8fb9 100644 --- a/go-providers/gemini/go.mod +++ b/go-providers/gemini/go.mod @@ -1,36 +1,40 @@ -module github.com/grafana/sigil/sdks/go-providers/gemini +module github.com/grafana/sigil-sdk/go-providers/gemini go 1.25.6 require ( - github.com/grafana/sigil/sdks/go v0.0.0 - google.golang.org/genai v1.47.0 + github.com/grafana/sigil-sdk/go v0.1.2 + google.golang.org/genai v1.51.0 ) require ( - cloud.google.com/go v0.116.0 // indirect - cloud.google.com/go/auth v0.9.3 // indirect + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/s2a-go v0.1.8 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect + github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect - go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.40.0 // indirect - go.opentelemetry.io/otel/metric v1.40.0 // indirect - go.opentelemetry.io/otel/trace v1.40.0 // indirect - golang.org/x/crypto v0.47.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect + go.opentelemetry.io/otel v1.42.0 // indirect + go.opentelemetry.io/otel/metric v1.42.0 // indirect + go.opentelemetry.io/otel/sdk v1.42.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect + go.opentelemetry.io/otel/trace v1.42.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/grpc v1.79.1 // indirect + google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect ) -replace github.com/grafana/sigil/sdks/go => ../../go +replace github.com/grafana/sigil-sdk/go => ../../go diff --git a/go-providers/gemini/go.sum b/go-providers/gemini/go.sum index b70c605..2ca3732 100644 --- a/go-providers/gemini/go.sum +++ b/go-providers/gemini/go.sum @@ -1,156 +1,73 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= -cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= -cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U= -cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= -github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= -github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= +github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= +github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= +github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= -go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/genai v1.47.0 h1:iWCS7gEdO6rctOqfCYLOrZGKu2D+N42aTnCEcBvB1jo= -google.golang.org/genai v1.47.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genai v1.51.0 h1:IZGuUqgfx40INv3hLFGCbOSGp0qFqm7LVmDghzNIYqg= +google.golang.org/genai v1.51.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= -google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/go-providers/gemini/mapper.go b/go-providers/gemini/mapper.go index 888c737..9480def 100644 --- a/go-providers/gemini/mapper.go +++ b/go-providers/gemini/mapper.go @@ -7,7 +7,7 @@ import ( "google.golang.org/genai" - "github.com/grafana/sigil/sdks/go/sigil" + "github.com/grafana/sigil-sdk/go/sigil" ) const thinkingBudgetMetadataKey = "sigil.gen_ai.request.thinking.budget_tokens" @@ -75,26 +75,27 @@ func FromRequestResponse( metadata = mergeGeminiUsageMetadata(metadata, resp.UsageMetadata) generation := sigil.Generation{ - ConversationID: options.conversationID, - AgentName: options.agentName, - AgentVersion: options.agentVersion, - Model: sigil.ModelRef{Provider: options.providerName, Name: model}, - ResponseID: resp.ResponseID, - ResponseModel: resp.ModelVersion, - SystemPrompt: extractSystemPrompt(config), - Input: input, - Output: output, - Tools: mapTools(config), - MaxTokens: maxTokens, - Temperature: temperature, - TopP: topP, - ToolChoice: toolChoice, - ThinkingEnabled: thinkingEnabled, - Usage: mapUsage(resp.UsageMetadata), - StopReason: stopReason, - Tags: cloneStringMap(options.tags), - Metadata: metadata, - Artifacts: artifacts, + ConversationID: options.conversationID, + ConversationTitle: options.conversationTitle, + AgentName: options.agentName, + AgentVersion: options.agentVersion, + Model: sigil.ModelRef{Provider: options.providerName, Name: model}, + ResponseID: resp.ResponseID, + ResponseModel: resp.ModelVersion, + SystemPrompt: extractSystemPrompt(config), + Input: input, + Output: output, + Tools: mapTools(config), + MaxTokens: maxTokens, + Temperature: temperature, + TopP: topP, + ToolChoice: toolChoice, + ThinkingEnabled: thinkingEnabled, + Usage: mapUsage(resp.UsageMetadata), + StopReason: stopReason, + Tags: cloneStringMap(options.tags), + Metadata: metadata, + Artifacts: artifacts, } if err := generation.Validate(); err != nil { @@ -170,7 +171,7 @@ func mapContents(contents []*genai.Content) []sigil.Message { continue } - if text := strings.TrimSpace(part.Text); text != "" { + if text := part.Text; text != "" { if part.Thought && role == sigil.RoleAssistant { roleParts = append(roleParts, sigil.ThinkingPart(text)) } else { @@ -233,7 +234,7 @@ func embeddingInputTexts(contents []*genai.Content) []string { } out := make([]string, 0, len(contents)) for _, content := range contents { - text := strings.TrimSpace(embeddingContentText(content)) + text := embeddingContentText(content) if text != "" { out = append(out, text) } @@ -253,7 +254,7 @@ func embeddingContentText(content *genai.Content) string { if part == nil { continue } - if text := strings.TrimSpace(part.Text); text != "" { + if text := part.Text; text != "" { chunks = append(chunks, text) } } @@ -354,9 +355,7 @@ func extractSystemPrompt(config *genai.GenerateContentConfig) string { if part == nil { continue } - if text := strings.TrimSpace(part.Text); text != "" { - parts = append(parts, text) - } + parts = append(parts, part.Text) } return strings.Join(parts, "\n\n") } diff --git a/go-providers/gemini/mapper_test.go b/go-providers/gemini/mapper_test.go index ca9ba0e..9f27675 100644 --- a/go-providers/gemini/mapper_test.go +++ b/go-providers/gemini/mapper_test.go @@ -6,7 +6,7 @@ import ( "google.golang.org/genai" - "github.com/grafana/sigil/sdks/go/sigil" + "github.com/grafana/sigil-sdk/go/sigil" ) func TestFromRequestResponse(t *testing.T) { @@ -77,7 +77,7 @@ func TestFromRequestResponse(t *testing.T) { UsageMetadata: &genai.GenerateContentResponseUsageMetadata{ PromptTokenCount: 120, CandidatesTokenCount: 40, - TotalTokenCount: 170, + TotalTokenCount: 160, CachedContentTokenCount: 12, ThoughtsTokenCount: 10, ToolUsePromptTokenCount: 9, @@ -86,6 +86,7 @@ func TestFromRequestResponse(t *testing.T) { generation, err := FromRequestResponse(model, contents, config, resp, WithConversationID("conv-9b2f"), + WithConversationTitle("Paris weather"), WithAgentName("agent-gemini"), WithAgentVersion("v-gemini"), WithTag("tenant", "t-123"), @@ -103,6 +104,9 @@ func TestFromRequestResponse(t *testing.T) { if generation.ConversationID != "conv-9b2f" { t.Fatalf("expected conv-9b2f, got %q", generation.ConversationID) } + if generation.ConversationTitle != "Paris weather" { + t.Fatalf("expected conversation title Paris weather, got %q", generation.ConversationTitle) + } if generation.AgentName != "agent-gemini" { t.Fatalf("expected agent-gemini, got %q", generation.AgentName) } @@ -121,8 +125,8 @@ func TestFromRequestResponse(t *testing.T) { if generation.StopReason != "STOP" { t.Fatalf("expected stop reason STOP, got %q", generation.StopReason) } - if generation.Usage.TotalTokens != 170 { - t.Fatalf("expected total tokens 170, got %d", generation.Usage.TotalTokens) + if generation.Usage.TotalTokens != 160 { + t.Fatalf("expected total tokens 160, got %d", generation.Usage.TotalTokens) } if generation.Usage.CacheReadInputTokens != 12 { t.Fatalf("expected cache read tokens 12, got %d", generation.Usage.CacheReadInputTokens) @@ -168,6 +172,15 @@ func TestFromRequestResponse(t *testing.T) { for _, message := range generation.Input { if message.Role == sigil.RoleTool { hasToolRole = true + if len(message.Parts) != 1 || message.Parts[0].ToolResult == nil { + t.Fatalf("expected single tool_result part, got %#v", message.Parts) + } + if message.Parts[0].ToolResult.ToolCallID != "" { + t.Fatalf("expected empty Gemini tool_call_id fallback, got %q", message.Parts[0].ToolResult.ToolCallID) + } + if message.Parts[0].ToolResult.Name != "weather" { + t.Fatalf("expected Gemini tool_result name weather, got %q", message.Parts[0].ToolResult.Name) + } } } if !hasToolRole { @@ -237,7 +250,7 @@ func TestFromStream(t *testing.T) { UsageMetadata: &genai.GenerateContentResponseUsageMetadata{ PromptTokenCount: 20, CandidatesTokenCount: 6, - TotalTokenCount: 31, + TotalTokenCount: 26, ToolUsePromptTokenCount: 5, }, }, @@ -271,8 +284,8 @@ func TestFromStream(t *testing.T) { if generation.ResponseModel != "gemini-2.5-pro-001" { t.Fatalf("expected response model gemini-2.5-pro-001, got %q", generation.ResponseModel) } - if generation.Usage.TotalTokens != 31 { - t.Fatalf("expected total tokens 31, got %d", generation.Usage.TotalTokens) + if generation.Usage.TotalTokens != 26 { + t.Fatalf("expected total tokens 26, got %d", generation.Usage.TotalTokens) } if generation.MaxTokens == nil || *generation.MaxTokens != 90 { t.Fatalf("expected max tokens 90, got %v", generation.MaxTokens) @@ -409,3 +422,85 @@ func TestEmbeddingFromResponseFallsBackToRequestedDimensions(t *testing.T) { t.Fatalf("expected dimensions 12, got %v", result.Dimensions) } } + +func TestFromRequestResponsePreservesWhitespace(t *testing.T) { + model := "gemini-2.5-pro" + contents := []*genai.Content{ + genai.NewContentFromText(" user literal \\\\n\\\\n ", genai.RoleUser), + } + config := &genai.GenerateContentConfig{ + SystemInstruction: genai.NewContentFromText(" system prompt ", genai.RoleUser), + } + resp := &genai.GenerateContentResponse{ + ResponseID: "resp_whitespace", + ModelVersion: "gemini-2.5-pro-001", + Candidates: []*genai.Candidate{ + { + FinishReason: genai.FinishReasonStop, + Content: genai.NewContentFromText("\n assistant output \n", genai.RoleModel), + }, + }, + } + + generation, err := FromRequestResponse(model, contents, config, resp) + if err != nil { + t.Fatalf("from request/response: %v", err) + } + + if generation.SystemPrompt != " system prompt " { + t.Fatalf("unexpected system prompt %q", generation.SystemPrompt) + } + if len(generation.Input) != 1 || len(generation.Input[0].Parts) != 1 { + t.Fatalf("expected single input text part, got %#v", generation.Input) + } + if generation.Input[0].Parts[0].Text != " user literal \\\\n\\\\n " { + t.Fatalf("unexpected input text %q", generation.Input[0].Parts[0].Text) + } + if len(generation.Output) != 1 || len(generation.Output[0].Parts) != 1 { + t.Fatalf("expected single output text part, got %#v", generation.Output) + } + if generation.Output[0].Parts[0].Text != "\n assistant output \n" { + t.Fatalf("unexpected output text %q", generation.Output[0].Parts[0].Text) + } +} + +func TestFromStreamPreservesWhitespaceOnlyOutput(t *testing.T) { + model := "gemini-2.5-pro" + summary := StreamSummary{ + Responses: []*genai.GenerateContentResponse{ + { + ResponseID: "resp_stream_whitespace", + ModelVersion: "gemini-2.5-pro-001", + Candidates: []*genai.Candidate{ + { + FinishReason: genai.FinishReasonStop, + Content: genai.NewContentFromText(" ", genai.RoleModel), + }, + }, + }, + }, + } + + generation, err := FromStream(model, nil, nil, summary) + if err != nil { + t.Fatalf("from stream: %v", err) + } + if len(generation.Output) != 1 || len(generation.Output[0].Parts) != 1 { + t.Fatalf("expected single output text part, got %#v", generation.Output) + } + if generation.Output[0].Parts[0].Text != " " { + t.Fatalf("unexpected output text %q", generation.Output[0].Parts[0].Text) + } +} + +func TestExtractSystemPromptPreservesEmptySegments(t *testing.T) { + config := &genai.GenerateContentConfig{ + SystemInstruction: genai.NewContentFromParts([]*genai.Part{ + genai.NewPartFromText(""), + genai.NewPartFromText("second"), + }, genai.RoleUser), + } + if got := extractSystemPrompt(config); got != "\n\nsecond" { + t.Fatalf("expected preserved empty segment separator, got %q", got) + } +} diff --git a/go-providers/gemini/options.go b/go-providers/gemini/options.go index bcb6674..de85a4e 100644 --- a/go-providers/gemini/options.go +++ b/go-providers/gemini/options.go @@ -4,12 +4,13 @@ package gemini type Option func(*mapperOptions) type mapperOptions struct { - providerName string - conversationID string - agentName string - agentVersion string - tags map[string]string - metadata map[string]any + providerName string + conversationID string + conversationTitle string + agentName string + agentVersion string + tags map[string]string + metadata map[string]any includeRequestArtifact bool includeResponseArtifact bool @@ -45,6 +46,13 @@ func WithConversationID(conversationID string) Option { } } +// WithConversationTitle sets Generation.ConversationTitle. +func WithConversationTitle(conversationTitle string) Option { + return func(options *mapperOptions) { + options.conversationTitle = conversationTitle + } +} + // WithAgentName sets Generation.AgentName. func WithAgentName(agentName string) Option { return func(options *mapperOptions) { diff --git a/go-providers/gemini/record.go b/go-providers/gemini/record.go index 5fc295e..317948f 100644 --- a/go-providers/gemini/record.go +++ b/go-providers/gemini/record.go @@ -6,7 +6,7 @@ import ( "google.golang.org/genai" - "github.com/grafana/sigil/sdks/go/sigil" + "github.com/grafana/sigil-sdk/go/sigil" ) // GenerateContent calls the Gemini generate-content API and records the generation. @@ -20,18 +20,38 @@ func GenerateContent( contents []*genai.Content, config *genai.GenerateContentConfig, opts ...Option, +) (*genai.GenerateContentResponse, error) { + return generateContent(ctx, client, model, contents, config, func( + ctx context.Context, + model string, + contents []*genai.Content, + config *genai.GenerateContentConfig, + ) (*genai.GenerateContentResponse, error) { + return provider.Models.GenerateContent(ctx, model, contents, config) + }, opts...) +} + +func generateContent( + ctx context.Context, + client *sigil.Client, + model string, + contents []*genai.Content, + config *genai.GenerateContentConfig, + invoke func(context.Context, string, []*genai.Content, *genai.GenerateContentConfig) (*genai.GenerateContentResponse, error), + opts ...Option, ) (*genai.GenerateContentResponse, error) { options := applyOptions(opts) ctx, rec := client.StartGeneration(ctx, sigil.GenerationStart{ - ConversationID: options.conversationID, - AgentName: options.agentName, - AgentVersion: options.agentVersion, - Model: sigil.ModelRef{Provider: options.providerName, Name: model}, + ConversationID: options.conversationID, + ConversationTitle: options.conversationTitle, + AgentName: options.agentName, + AgentVersion: options.agentVersion, + Model: sigil.ModelRef{Provider: options.providerName, Name: model}, }) defer rec.End() - resp, err := provider.Models.GenerateContent(ctx, model, contents, config) + resp, err := invoke(ctx, model, contents, config) if err != nil { rec.SetCallError(err) return nil, err @@ -115,10 +135,11 @@ func GenerateContentStream( options := applyOptions(opts) ctx, rec := client.StartStreamingGeneration(ctx, sigil.GenerationStart{ - ConversationID: options.conversationID, - AgentName: options.agentName, - AgentVersion: options.agentVersion, - Model: sigil.ModelRef{Provider: options.providerName, Name: model}, + ConversationID: options.conversationID, + ConversationTitle: options.conversationTitle, + AgentName: options.agentName, + AgentVersion: options.agentVersion, + Model: sigil.ModelRef{Provider: options.providerName, Name: model}, }) defer rec.End() diff --git a/go-providers/gemini/record_test.go b/go-providers/gemini/record_test.go index 90ffc68..4d3619e 100644 --- a/go-providers/gemini/record_test.go +++ b/go-providers/gemini/record_test.go @@ -8,11 +8,11 @@ import ( "google.golang.org/genai" - "github.com/grafana/sigil/sdks/go/sigil" + "github.com/grafana/sigil-sdk/go/sigil" ) func TestEmbedContentReturnsRecorderValidationErrorAfterEnd(t *testing.T) { - client := newEmbeddingTestClient(t) + client := newProviderTestClient(t) contents := []*genai.Content{ genai.NewContentFromText("hello", genai.RoleUser), @@ -51,7 +51,7 @@ func TestEmbedContentReturnsRecorderValidationErrorAfterEnd(t *testing.T) { } func TestEmbedContentPreservesProviderErrors(t *testing.T) { - client := newEmbeddingTestClient(t) + client := newProviderTestClient(t) providerErr := errors.New("provider failed") @@ -74,7 +74,65 @@ func TestEmbedContentPreservesProviderErrors(t *testing.T) { } } -func newEmbeddingTestClient(t *testing.T) *sigil.Client { +func TestConformance_GenerateContentErrorMapping(t *testing.T) { + client := newProviderTestClient(t) + model := "gemini-2.5-pro" + contents := []*genai.Content{ + genai.NewContentFromText("hello", genai.RoleUser), + } + + t.Run("provider errors are preserved", func(t *testing.T) { + providerErr := errors.New("provider failed") + + response, err := generateContent( + context.Background(), + client, + model, + contents, + nil, + func(context.Context, string, []*genai.Content, *genai.GenerateContentConfig) (*genai.GenerateContentResponse, error) { + return nil, providerErr + }, + ) + if !errors.Is(err, providerErr) { + t.Fatalf("expected provider error, got %v", err) + } + if response != nil { + t.Fatalf("expected nil response on provider error") + } + }) + + t.Run("mapping failures do not hide provider responses", func(t *testing.T) { + expectedResponse := &genai.GenerateContentResponse{ + Candidates: []*genai.Candidate{ + { + FinishReason: genai.FinishReasonStop, + Content: genai.NewContentFromText("hi", genai.RoleModel), + }, + }, + } + + response, err := generateContent( + context.Background(), + client, + model, + contents, + nil, + func(context.Context, string, []*genai.Content, *genai.GenerateContentConfig) (*genai.GenerateContentResponse, error) { + return expectedResponse, nil + }, + WithProviderName(""), + ) + if err != nil { + t.Fatalf("expected nil local error for mapping failure, got %v", err) + } + if response != expectedResponse { + t.Fatalf("expected wrapper to return provider response pointer") + } + }) +} + +func newProviderTestClient(t *testing.T) *sigil.Client { t.Helper() cfg := sigil.DefaultConfig() diff --git a/go-providers/gemini/sdk_example_test.go b/go-providers/gemini/sdk_example_test.go index df52284..0ad8a17 100644 --- a/go-providers/gemini/sdk_example_test.go +++ b/go-providers/gemini/sdk_example_test.go @@ -4,7 +4,7 @@ import ( "context" "os" - "github.com/grafana/sigil/sdks/go/sigil" + "github.com/grafana/sigil-sdk/go/sigil" "google.golang.org/genai" ) diff --git a/go-providers/gemini/stream_mapper.go b/go-providers/gemini/stream_mapper.go index 73a9165..56b4431 100644 --- a/go-providers/gemini/stream_mapper.go +++ b/go-providers/gemini/stream_mapper.go @@ -7,7 +7,7 @@ import ( "google.golang.org/genai" - "github.com/grafana/sigil/sdks/go/sigil" + "github.com/grafana/sigil-sdk/go/sigil" ) // StreamSummary captures Gemini streamed responses. @@ -103,26 +103,27 @@ func FromStream( metadata = mergeGeminiUsageMetadata(metadata, usageMetadata) generation := sigil.Generation{ - ConversationID: options.conversationID, - AgentName: options.agentName, - AgentVersion: options.agentVersion, - Model: sigil.ModelRef{Provider: options.providerName, Name: model}, - ResponseID: responseID, - ResponseModel: responseModel, - SystemPrompt: extractSystemPrompt(config), - Input: input, - Output: output, - Tools: mapTools(config), - MaxTokens: maxTokens, - Temperature: temperature, - TopP: topP, - ToolChoice: toolChoice, - ThinkingEnabled: thinkingEnabled, - Usage: usage, - StopReason: stopReason, - Tags: cloneStringMap(options.tags), - Metadata: metadata, - Artifacts: artifacts, + ConversationID: options.conversationID, + ConversationTitle: options.conversationTitle, + AgentName: options.agentName, + AgentVersion: options.agentVersion, + Model: sigil.ModelRef{Provider: options.providerName, Name: model}, + ResponseID: responseID, + ResponseModel: responseModel, + SystemPrompt: extractSystemPrompt(config), + Input: input, + Output: output, + Tools: mapTools(config), + MaxTokens: maxTokens, + Temperature: temperature, + TopP: topP, + ToolChoice: toolChoice, + ThinkingEnabled: thinkingEnabled, + Usage: usage, + StopReason: stopReason, + Tags: cloneStringMap(options.tags), + Metadata: metadata, + Artifacts: artifacts, } if err := generation.Validate(); err != nil { diff --git a/go-providers/openai/LICENSE b/go-providers/openai/LICENSE index ae8c60c..626a3ab 100644 --- a/go-providers/openai/LICENSE +++ b/go-providers/openai/LICENSE @@ -1,3 +1,3 @@ SPDX-License-Identifier: Apache-2.0 -See /sdks/LICENSE at repository root for full license text. +See /LICENSE at repository root for full license text. diff --git a/go-providers/openai/README.md b/go-providers/openai/README.md index 826337a..cffe494 100644 --- a/go-providers/openai/README.md +++ b/go-providers/openai/README.md @@ -9,11 +9,13 @@ This module maps official OpenAI Go SDK request/response payloads into typed Sig - `ChatCompletionsNewStreaming(ctx, sigilClient, provider, req, opts...)` - `ResponsesNew(ctx, sigilClient, provider, req, opts...)` - `ResponsesNewStreaming(ctx, sigilClient, provider, req, opts...)` + - `EmbeddingsNew(ctx, sigilClient, provider, req, opts...)` - Mapper functions: - `ChatCompletionsFromRequestResponse(req, resp, opts...)` - `ChatCompletionsFromStream(req, summary, opts...)` - `ResponsesFromRequestResponse(req, resp, opts...)` - `ResponsesFromStream(req, summary, opts...)` + - `EmbeddingsFromResponse(req, resp)` ## Integration styles @@ -29,6 +31,7 @@ This module maps official OpenAI Go SDK request/response payloads into typed Sig ```go resp, err := openai.ChatCompletionsNew(ctx, sigilClient, providerClient, req, openai.WithConversationID("conv-1"), + openai.WithConversationTitle("Weather follow-up"), openai.WithAgentName("assistant-openai"), openai.WithAgentVersion("1.0.0"), ) @@ -43,6 +46,7 @@ _ = resp.Choices[0].Message.Content ```go resp, err := openai.ResponsesNew(ctx, sigilClient, providerClient, req, openai.WithConversationID("conv-1"), + openai.WithConversationTitle("Weather follow-up"), openai.WithAgentName("assistant-openai"), openai.WithAgentVersion("1.0.0"), ) @@ -52,6 +56,21 @@ if err != nil { _ = resp.ID ``` +## Embeddings Wrapper + +```go +embedResp, err := openai.EmbeddingsNew(ctx, sigilClient, providerClient, osdk.EmbeddingNewParams{ + Model: osdk.EmbeddingModel("text-embedding-3-small"), + Input: osdk.EmbeddingNewParamsInputUnion{ + OfArrayOfStrings: []string{"hello", "world"}, + }, +}) +if err != nil { + return err +} +_ = embedResp.Model +``` + ## Defer Pattern (explicit control) ```go @@ -101,6 +120,11 @@ rec.SetResult(openai.ResponsesFromStream(req, summary)) - Default: raw request/response/provider-event artifacts are OFF. - Opt-in with `WithRawArtifacts()`. +## Tool result correlation + +- Chat Completions tool messages and Responses function-call outputs preserve upstream call IDs in normalized `tool_result.tool_call_id`. +- Legacy Chat Completions `function` role messages do not expose a call ID; the mapper falls back to normalized `tool_result.name`. + ## Live SDK examples Real end-to-end examples using the actual OpenAI SDK (no fake provider calls) are in `sdk_example_test.go`. diff --git a/go-providers/openai/conformance_test.go b/go-providers/openai/conformance_test.go new file mode 100644 index 0000000..ca4eb40 --- /dev/null +++ b/go-providers/openai/conformance_test.go @@ -0,0 +1,751 @@ +package openai + +import ( + "net/http" + "net/url" + "strings" + "testing" + "time" + + osdk "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/packages/param" + oresponses "github.com/openai/openai-go/v3/responses" + "github.com/openai/openai-go/v3/shared" + + sigil "github.com/grafana/sigil-sdk/go/sigil" + "github.com/grafana/sigil-sdk/go/sigil/sigiltest" +) + +const ( + openAISpanErrorCategory = "error.category" + openAISpanInputCount = "gen_ai.embeddings.input_count" + openAISpanDimCount = "gen_ai.embeddings.dimension.count" +) + +func TestConformance_OpenAIResponsesSyncMapping(t *testing.T) { + env := sigiltest.NewEnv(t) + + req := openAIResponsesRequest() + resp := openAIResponsesResponse() + start := sigil.GenerationStart{ + ConversationID: "conv-openai-sync", + ConversationTitle: "OpenAI responses sync", + AgentName: "agent-openai", + AgentVersion: "v-openai", + Model: sigil.ModelRef{Provider: "openai", Name: string(req.Model)}, + } + + generation, err := ResponsesFromRequestResponse( + req, + resp, + WithConversationID(start.ConversationID), + WithConversationTitle(start.ConversationTitle), + WithAgentName(start.AgentName), + WithAgentVersion(start.AgentVersion), + WithTag("tenant", "t-openai"), + ) + sigiltest.RecordGeneration(t, env, start, generation, err) + env.Shutdown(t) + + exported := env.SingleGenerationJSON(t) + + if got := sigiltest.StringValue(t, exported, "mode"); got != "GENERATION_MODE_SYNC" { + t.Fatalf("unexpected mode: got %q want %q\n%s", got, "GENERATION_MODE_SYNC", sigiltest.DebugJSON(exported)) + } + if got := sigiltest.StringValue(t, exported, "response_id"); got != "resp_openai_sync" { + t.Fatalf("unexpected response_id: got %q want %q", got, "resp_openai_sync") + } + if got := sigiltest.StringValue(t, exported, "stop_reason"); got != "stop" { + t.Fatalf("unexpected stop_reason: got %q want %q", got, "stop") + } + if got := sigiltest.StringValue(t, exported, "system_prompt"); got != "Be concise." { + t.Fatalf("unexpected system_prompt: got %q want %q", got, "Be concise.") + } + if got := sigiltest.StringValue(t, exported, "conversation_id"); got != start.ConversationID { + t.Fatalf("unexpected conversation_id: got %q want %q", got, start.ConversationID) + } + if got := sigiltest.StringValue(t, exported, "agent_name"); got != start.AgentName { + t.Fatalf("unexpected agent_name: got %q want %q", got, start.AgentName) + } + if got := sigiltest.StringValue(t, exported, "model", "provider"); got != "openai" { + t.Fatalf("unexpected model.provider: got %q want %q", got, "openai") + } + if got := sigiltest.StringValue(t, exported, "model", "name"); got != "gpt-5" { + t.Fatalf("unexpected model.name: got %q want %q", got, "gpt-5") + } + if got := sigiltest.StringValue(t, exported, "usage", "reasoning_tokens"); got != "3" { + t.Fatalf("unexpected usage.reasoning_tokens: got %q want %q", got, "3") + } + if got := sigiltest.StringValue(t, exported, "usage", "cache_read_input_tokens"); got != "2" { + t.Fatalf("unexpected usage.cache_read_input_tokens: got %q want %q", got, "2") + } + if got := sigiltest.StringValue(t, exported, "input", 1, "role"); got != "MESSAGE_ROLE_TOOL" { + t.Fatalf("unexpected tool input role: got %q want %q", got, "MESSAGE_ROLE_TOOL") + } + if got := sigiltest.StringValue(t, exported, "input", 1, "parts", 0, "metadata", "provider_type"); got != "tool_result" { + t.Fatalf("unexpected tool result provider_type: got %q want %q", got, "tool_result") + } + if got := sigiltest.StringValue(t, exported, "input", 1, "parts", 0, "tool_result", "tool_call_id"); got != "call_weather" { + t.Fatalf("unexpected tool_result.tool_call_id: got %q want %q", got, "call_weather") + } + if got := sigiltest.StringValue(t, exported, "output", 1, "parts", 0, "metadata", "provider_type"); got != "tool_call" { + t.Fatalf("unexpected tool call provider_type: got %q want %q", got, "tool_call") + } + if got := sigiltest.StringValue(t, exported, "output", 1, "parts", 0, "tool_call", "name"); got != "weather" { + t.Fatalf("unexpected tool_call.name: got %q want %q", got, "weather") + } +} + +func TestConformance_OpenAIResponsesStreamMapping(t *testing.T) { + env := sigiltest.NewEnv(t) + + req := openAIResponsesRequest() + summary := openAIResponsesStreamSummary() + start := sigil.GenerationStart{ + ConversationID: "conv-openai-stream", + AgentName: "agent-openai-stream", + AgentVersion: "v-openai-stream", + Model: sigil.ModelRef{Provider: "openai", Name: string(req.Model)}, + } + + generation, err := ResponsesFromStream( + req, + summary, + WithConversationID(start.ConversationID), + WithAgentName(start.AgentName), + WithAgentVersion(start.AgentVersion), + ) + sigiltest.RecordStreamingGeneration(t, env, start, summary.FirstChunkAt, generation, err) + env.Shutdown(t) + + exported := env.SingleGenerationJSON(t) + + if got := sigiltest.StringValue(t, exported, "mode"); got != "GENERATION_MODE_STREAM" { + t.Fatalf("unexpected mode: got %q want %q\n%s", got, "GENERATION_MODE_STREAM", sigiltest.DebugJSON(exported)) + } + if got := sigiltest.StringValue(t, exported, "response_id"); got != "resp_openai_stream" { + t.Fatalf("unexpected response_id: got %q want %q", got, "resp_openai_stream") + } + if got := sigiltest.StringValue(t, exported, "stop_reason"); got != "stop" { + t.Fatalf("unexpected stop_reason: got %q want %q", got, "stop") + } + if got := sigiltest.StringValue(t, exported, "output", 0, "parts", 0, "text"); got != "checking weather" { + t.Fatalf("unexpected streamed output text: got %q want %q", got, "checking weather") + } + if got := sigiltest.StringValue(t, exported, "output", 1, "parts", 0, "metadata", "provider_type"); got != "tool_call" { + t.Fatalf("unexpected streamed tool call provider_type: got %q want %q", got, "tool_call") + } + if got := sigiltest.StringValue(t, exported, "output", 1, "parts", 0, "tool_call", "id"); got != "call_weather" { + t.Fatalf("unexpected streamed tool_call.id: got %q want %q", got, "call_weather") + } + if got := sigiltest.StringValue(t, exported, "output", 1, "parts", 0, "tool_call", "name"); got != "weather" { + t.Fatalf("unexpected streamed tool_call.name: got %q want %q", got, "weather") + } + if got := sigiltest.StringValue(t, exported, "output", 1, "parts", 0, "tool_call", "input_json"); got != "eyJjaXR5IjoiUGFyaXMifQ==" { + t.Fatalf("unexpected streamed tool_call.input_json: got %q want %q", got, "eyJjaXR5IjoiUGFyaXMifQ==") + } + if got := sigiltest.StringValue(t, exported, "usage", "total_tokens"); got != "26" { + t.Fatalf("unexpected usage.total_tokens: got %q want %q", got, "26") + } + if got := sigiltest.StringValue(t, exported, "input", 1, "parts", 0, "tool_result", "tool_call_id"); got != "call_weather" { + t.Fatalf("unexpected streamed tool_result.tool_call_id: got %q want %q", got, "call_weather") + } +} + +func TestConformance_OpenAIErrorMapping(t *testing.T) { + env := sigiltest.NewEnv(t) + + callErr := &osdk.Error{ + StatusCode: http.StatusTooManyRequests, + Request: &http.Request{Method: http.MethodPost, URL: mustURL(t, "https://api.openai.com/v1/responses")}, + Response: &http.Response{StatusCode: http.StatusTooManyRequests, Status: "429 Too Many Requests"}, + } + sigiltest.RecordCallError(t, env, sigil.GenerationStart{ + Model: sigil.ModelRef{Provider: "openai", Name: "gpt-5"}, + }, callErr) + + span := sigiltest.FindSpan(t, env.Spans.Ended(), "generateText gpt-5") + attrs := sigiltest.SpanAttributes(span) + if got := attrs[openAISpanErrorCategory].AsString(); got != "rate_limit" { + t.Fatalf("unexpected error.category: got %q want %q", got, "rate_limit") + } + + env.Shutdown(t) + exported := env.SingleGenerationJSON(t) + callError := sigiltest.StringValue(t, exported, "call_error") + if !strings.Contains(callError, "429") { + t.Fatalf("expected call_error to include status code, got %q", callError) + } +} + +func TestConformance_OpenAIEmbeddingMapping(t *testing.T) { + env := sigiltest.NewEnv(t) + + req := osdk.EmbeddingNewParams{ + Model: osdk.EmbeddingModel("text-embedding-3-small"), + Input: osdk.EmbeddingNewParamsInputUnion{ + OfArrayOfStrings: []string{"hello", "world"}, + }, + } + resp := &osdk.CreateEmbeddingResponse{ + Model: "text-embedding-3-small", + Data: []osdk.Embedding{ + {Embedding: []float64{0.1, 0.2, 0.3}}, + {Embedding: []float64{0.4, 0.5, 0.6}}, + }, + Usage: osdk.CreateEmbeddingResponseUsage{ + PromptTokens: 42, + TotalTokens: 42, + }, + } + dimensions := int64(3) + sigiltest.RecordEmbedding(t, env, sigil.EmbeddingStart{ + Model: sigil.ModelRef{Provider: "openai", Name: string(req.Model)}, + AgentName: "agent-openai-embed", + AgentVersion: "v-openai-embed", + Dimensions: &dimensions, + }, EmbeddingsFromResponse(req, resp)) + + span := sigiltest.FindSpan(t, env.Spans.Ended(), "embeddings text-embedding-3-small") + attrs := sigiltest.SpanAttributes(span) + if got := attrs[openAISpanInputCount].AsInt64(); got != 2 { + t.Fatalf("unexpected gen_ai.embeddings.input_count: got %d want %d", got, 2) + } + if got := attrs[openAISpanDimCount].AsInt64(); got != 3 { + t.Fatalf("unexpected gen_ai.embeddings.dimension.count: got %d want %d", got, 3) + } + + env.Shutdown(t) + sigiltest.RequireRequestCount(t, env, 0) +} + +func openAIResponsesRequest() oresponses.ResponseNewParams { + return oresponses.ResponseNewParams{ + Model: shared.ResponsesModel("gpt-5"), + Instructions: param.NewOpt("Be concise."), + Input: oresponses.ResponseNewParamsInputUnion{ + OfInputItemList: oresponses.ResponseInputParam{ + { + OfMessage: &oresponses.EasyInputMessageParam{ + Role: oresponses.EasyInputMessageRoleUser, + Content: oresponses.EasyInputMessageContentUnionParam{OfString: param.NewOpt("what is the weather in Paris?")}, + }, + }, + { + OfFunctionCallOutput: &oresponses.ResponseInputItemFunctionCallOutputParam{ + CallID: "call_weather", + Output: oresponses.ResponseInputItemFunctionCallOutputOutputUnionParam{OfString: param.NewOpt(`{"temp_c":18}`)}, + }, + }, + }, + }, + MaxOutputTokens: param.NewOpt(int64(320)), + Temperature: param.NewOpt(0.2), + TopP: param.NewOpt(0.85), + Reasoning: shared.ReasoningParam{ + Effort: shared.ReasoningEffortMedium, + }, + } +} + +func openAIResponsesResponse() *oresponses.Response { + return &oresponses.Response{ + ID: "resp_openai_sync", + Model: shared.ResponsesModel("gpt-5"), + Status: oresponses.ResponseStatusCompleted, + Output: []oresponses.ResponseOutputItemUnion{ + { + Type: "message", + Content: []oresponses.ResponseOutputMessageContentUnion{ + {Type: "output_text", Text: "It is 18C and sunny."}, + }, + }, + { + Type: "function_call", + CallID: "call_weather", + Name: "weather", + Arguments: oresponses.ResponseOutputItemUnionArguments{OfString: `{"city":"Paris"}`}, + }, + }, + Usage: oresponses.ResponseUsage{ + InputTokens: 80, + OutputTokens: 20, + TotalTokens: 100, + InputTokensDetails: oresponses.ResponseUsageInputTokensDetails{ + CachedTokens: 2, + }, + OutputTokensDetails: oresponses.ResponseUsageOutputTokensDetails{ + ReasoningTokens: 3, + }, + }, + } +} + +func openAIResponsesStreamSummary() ResponsesStreamSummary { + return ResponsesStreamSummary{ + FirstChunkAt: time.Unix(1_741_780_000, 0).UTC(), + Events: []oresponses.ResponseStreamEventUnion{ + { + Type: "response.output_text.delta", + Delta: "checking ", + Response: oresponses.Response{ + ID: "resp_openai_stream", + Model: shared.ResponsesModel("gpt-5"), + }, + }, + { + Type: "response.output_text.delta", + Delta: "weather", + }, + { + Type: "response.output_item.added", + Item: oresponses.ResponseOutputItemUnion{ + ID: "fc_1", + Type: "function_call", + CallID: "call_weather", + Name: "weather", + }, + OutputIndex: 1, + }, + { + Type: "response.function_call_arguments.delta", + ItemID: "fc_1", + OutputIndex: 1, + Delta: `{"city":"Pa`, + }, + { + Type: "response.function_call_arguments.done", + ItemID: "fc_1", + OutputIndex: 1, + Name: "weather", + Arguments: `{"city":"Paris"}`, + }, + { + Type: "response.completed", + Response: oresponses.Response{ + ID: "resp_openai_stream", + Model: shared.ResponsesModel("gpt-5"), + Status: oresponses.ResponseStatusCompleted, + Usage: oresponses.ResponseUsage{ + InputTokens: 20, + OutputTokens: 6, + TotalTokens: 26, + }, + }, + }, + }, + } +} + +func mustURL(t testing.TB, raw string) *url.URL { + t.Helper() + + parsed, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse url %q: %v", raw, err) + } + return parsed +} + +func TestConformance_ChatCompletionsSyncNormalization(t *testing.T) { + req := osdk.ChatCompletionNewParams{ + Model: shared.ChatModel("gpt-4o-mini"), + Messages: []osdk.ChatCompletionMessageParamUnion{ + osdk.SystemMessage("You are concise."), + osdk.UserMessage("What is the weather in Paris?"), + osdk.ToolMessage(`{"temp_c":18}`, "call_weather"), + }, + Tools: []osdk.ChatCompletionToolUnionParam{ + osdk.ChatCompletionFunctionTool(shared.FunctionDefinitionParam{ + Name: "weather", + Description: osdk.String("Get weather"), + Parameters: shared.FunctionParameters{ + "type": "object", + "properties": map[string]any{ + "city": map[string]any{"type": "string"}, + }, + }, + }), + }, + MaxCompletionTokens: param.NewOpt(int64(128)), + Temperature: param.NewOpt(0.7), + TopP: param.NewOpt(0.9), + ToolChoice: osdk.ToolChoiceOptionFunctionToolChoice(osdk.ChatCompletionNamedToolChoiceFunctionParam{Name: "weather"}), + ReasoningEffort: shared.ReasoningEffortLow, + } + + resp := &osdk.ChatCompletion{ + ID: "chatcmpl_1", + Model: "gpt-4o-mini", + Choices: []osdk.ChatCompletionChoice{ + { + FinishReason: "tool_calls", + Message: osdk.ChatCompletionMessage{ + Content: "Calling tool", + ToolCalls: []osdk.ChatCompletionMessageToolCallUnion{ + { + ID: "call_weather", + Type: "function", + Function: osdk.ChatCompletionMessageFunctionToolCallFunction{ + Name: "weather", + Arguments: `{"city":"Paris"}`, + }, + }, + }, + }, + }, + }, + Usage: osdk.CompletionUsage{ + PromptTokens: 120, + CompletionTokens: 42, + TotalTokens: 162, + PromptTokensDetails: osdk.CompletionUsagePromptTokensDetails{ + CachedTokens: 8, + }, + CompletionTokensDetails: osdk.CompletionUsageCompletionTokensDetails{ + ReasoningTokens: 5, + }, + }, + } + + generation, err := ChatCompletionsFromRequestResponse(req, resp, + WithConversationID("conv-openai-sync"), + WithConversationTitle("Paris weather"), + WithAgentName("agent-openai"), + WithAgentVersion("v-openai"), + WithTag("tenant", "t-123"), + WithRawArtifacts(), + ) + if err != nil { + t.Fatalf("chat completions sync mapping: %v", err) + } + + if generation.Model.Provider != "openai" || generation.Model.Name != "gpt-4o-mini" { + t.Fatalf("unexpected model mapping: %#v", generation.Model) + } + if generation.ConversationID != "conv-openai-sync" || generation.ConversationTitle != "Paris weather" { + t.Fatalf("unexpected conversation mapping: id=%q title=%q", generation.ConversationID, generation.ConversationTitle) + } + if generation.AgentName != "agent-openai" || generation.AgentVersion != "v-openai" { + t.Fatalf("unexpected agent mapping: name=%q version=%q", generation.AgentName, generation.AgentVersion) + } + if generation.ResponseID != "chatcmpl_1" || generation.ResponseModel != "gpt-4o-mini" { + t.Fatalf("unexpected response mapping: id=%q model=%q", generation.ResponseID, generation.ResponseModel) + } + if generation.SystemPrompt != "You are concise." { + t.Fatalf("unexpected system prompt: %q", generation.SystemPrompt) + } + if generation.StopReason != "tool_calls" { + t.Fatalf("unexpected stop reason: %q", generation.StopReason) + } + if generation.Usage.TotalTokens != 162 || generation.Usage.CacheReadInputTokens != 8 || generation.Usage.ReasoningTokens != 5 { + t.Fatalf("unexpected usage mapping: %#v", generation.Usage) + } + if generation.ThinkingEnabled == nil || !*generation.ThinkingEnabled { + t.Fatalf("expected thinking enabled true, got %v", generation.ThinkingEnabled) + } + if len(generation.Output) != 1 || len(generation.Output[0].Parts) != 2 { + t.Fatalf("expected one assistant message with text + tool call, got %#v", generation.Output) + } + if generation.Output[0].Parts[0].Kind != sigil.PartKindText || generation.Output[0].Parts[0].Text != "Calling tool" { + t.Fatalf("unexpected assistant text part: %#v", generation.Output[0].Parts[0]) + } + if generation.Output[0].Parts[1].Kind != sigil.PartKindToolCall { + t.Fatalf("expected tool_call part, got %#v", generation.Output[0].Parts[1]) + } + if generation.Output[0].Parts[1].ToolCall.ID != "call_weather" || generation.Output[0].Parts[1].ToolCall.Name != "weather" { + t.Fatalf("unexpected tool call mapping: %#v", generation.Output[0].Parts[1].ToolCall) + } + if string(generation.Output[0].Parts[1].ToolCall.InputJSON) != `{"city":"Paris"}` { + t.Fatalf("unexpected tool call input: %q", string(generation.Output[0].Parts[1].ToolCall.InputJSON)) + } + if generation.Tags["tenant"] != "t-123" { + t.Fatalf("expected tenant tag") + } + requireOpenAIArtifactKinds(t, generation.Artifacts, + sigil.ArtifactKindRequest, + sigil.ArtifactKindResponse, + sigil.ArtifactKindTools, + ) +} + +func TestConformance_ChatCompletionsStreamNormalization(t *testing.T) { + req := osdk.ChatCompletionNewParams{ + Model: shared.ChatModel("gpt-4o-mini"), + Messages: []osdk.ChatCompletionMessageParamUnion{ + osdk.SystemMessage("You are concise."), + osdk.UserMessage("What is the weather in Paris?"), + }, + Tools: []osdk.ChatCompletionToolUnionParam{ + osdk.ChatCompletionFunctionTool(shared.FunctionDefinitionParam{ + Name: "weather", + }), + }, + MaxCompletionTokens: param.NewOpt(int64(42)), + Temperature: param.NewOpt(0.15), + TopP: param.NewOpt(0.4), + ToolChoice: osdk.ToolChoiceOptionFunctionToolChoice(osdk.ChatCompletionNamedToolChoiceFunctionParam{Name: "weather"}), + ReasoningEffort: shared.ReasoningEffortMedium, + } + + summary := ChatCompletionsStreamSummary{ + Chunks: []osdk.ChatCompletionChunk{ + { + ID: "chatcmpl_stream_1", + Model: "gpt-4o-mini", + Choices: []osdk.ChatCompletionChunkChoice{ + { + Delta: osdk.ChatCompletionChunkChoiceDelta{ + Content: "Calling tool", + ToolCalls: []osdk.ChatCompletionChunkChoiceDeltaToolCall{ + { + Index: 0, + ID: "call_weather", + Function: osdk.ChatCompletionChunkChoiceDeltaToolCallFunction{ + Name: "weather", + Arguments: `{"city":"Pa`, + }, + }, + }, + }, + }, + }, + }, + { + Choices: []osdk.ChatCompletionChunkChoice{ + { + Delta: osdk.ChatCompletionChunkChoiceDelta{ + Content: " now.", + ToolCalls: []osdk.ChatCompletionChunkChoiceDeltaToolCall{ + { + Index: 0, + Function: osdk.ChatCompletionChunkChoiceDeltaToolCallFunction{ + Arguments: `ris"}`, + }, + }, + }, + }, + FinishReason: "tool_calls", + }, + }, + Usage: osdk.CompletionUsage{ + PromptTokens: 20, + CompletionTokens: 5, + TotalTokens: 25, + }, + }, + }, + } + + generation, err := ChatCompletionsFromStream(req, summary, + WithConversationID("conv-openai-stream"), + WithAgentName("agent-openai-stream"), + WithAgentVersion("v-openai-stream"), + WithRawArtifacts(), + ) + if err != nil { + t.Fatalf("chat completions stream mapping: %v", err) + } + + if generation.ConversationID != "conv-openai-stream" || generation.AgentName != "agent-openai-stream" || generation.AgentVersion != "v-openai-stream" { + t.Fatalf("unexpected identity mapping: %#v", generation) + } + if generation.ResponseID != "chatcmpl_stream_1" || generation.ResponseModel != "gpt-4o-mini" { + t.Fatalf("unexpected response mapping: id=%q model=%q", generation.ResponseID, generation.ResponseModel) + } + if generation.StopReason != "tool_calls" { + t.Fatalf("unexpected stop reason: %q", generation.StopReason) + } + if generation.Usage.TotalTokens != 25 { + t.Fatalf("unexpected usage mapping: %#v", generation.Usage) + } + if generation.ThinkingEnabled == nil || !*generation.ThinkingEnabled { + t.Fatalf("expected thinking enabled true, got %v", generation.ThinkingEnabled) + } + if len(generation.Output) != 1 || len(generation.Output[0].Parts) != 2 { + t.Fatalf("expected merged assistant output, got %#v", generation.Output) + } + if generation.Output[0].Parts[0].Text != "Calling tool now." { + t.Fatalf("unexpected streamed text: %q", generation.Output[0].Parts[0].Text) + } + if generation.Output[0].Parts[1].Kind != sigil.PartKindToolCall { + t.Fatalf("expected tool call output, got %#v", generation.Output[0].Parts[1]) + } + if string(generation.Output[0].Parts[1].ToolCall.InputJSON) != `{"city":"Paris"}` { + t.Fatalf("unexpected streamed tool input: %q", string(generation.Output[0].Parts[1].ToolCall.InputJSON)) + } + requireOpenAIArtifactKinds(t, generation.Artifacts, + sigil.ArtifactKindRequest, + sigil.ArtifactKindTools, + sigil.ArtifactKindProviderEvent, + ) +} + +func TestConformance_ResponsesSyncNormalization(t *testing.T) { + req := oresponses.ResponseNewParams{ + Model: shared.ResponsesModel("gpt-5"), + Instructions: param.NewOpt("Be concise."), + Input: oresponses.ResponseNewParamsInputUnion{OfString: param.NewOpt("hello")}, + MaxOutputTokens: param.NewOpt(int64(320)), + Temperature: param.NewOpt(0.2), + TopP: param.NewOpt(0.85), + Reasoning: shared.ReasoningParam{ + Effort: shared.ReasoningEffortMedium, + }, + } + + resp := &oresponses.Response{ + ID: "resp_1", + Model: shared.ResponsesModel("gpt-5"), + Status: oresponses.ResponseStatusCompleted, + Output: []oresponses.ResponseOutputItemUnion{ + { + Type: "message", + Content: []oresponses.ResponseOutputMessageContentUnion{ + {Type: "output_text", Text: "world"}, + }, + }, + { + Type: "function_call", + CallID: "call_weather", + Name: "weather", + Arguments: oresponses.ResponseOutputItemUnionArguments{OfString: `{"city":"Paris"}`}, + }, + }, + Usage: oresponses.ResponseUsage{ + InputTokens: 80, + OutputTokens: 20, + TotalTokens: 100, + InputTokensDetails: oresponses.ResponseUsageInputTokensDetails{ + CachedTokens: 2, + }, + OutputTokensDetails: oresponses.ResponseUsageOutputTokensDetails{ + ReasoningTokens: 3, + }, + }, + } + + generation, err := ResponsesFromRequestResponse(req, resp, WithRawArtifacts()) + if err != nil { + t.Fatalf("responses sync mapping: %v", err) + } + + if generation.Model.Provider != "openai" || generation.Model.Name != "gpt-5" { + t.Fatalf("unexpected model mapping: %#v", generation.Model) + } + if generation.ResponseID != "resp_1" || generation.ResponseModel != "gpt-5" { + t.Fatalf("unexpected response mapping: id=%q model=%q", generation.ResponseID, generation.ResponseModel) + } + if generation.SystemPrompt != "Be concise." { + t.Fatalf("unexpected system prompt: %q", generation.SystemPrompt) + } + if generation.StopReason != "stop" { + t.Fatalf("unexpected stop reason: %q", generation.StopReason) + } + if generation.ThinkingEnabled == nil || !*generation.ThinkingEnabled { + t.Fatalf("expected thinking enabled true, got %v", generation.ThinkingEnabled) + } + if generation.Usage.TotalTokens != 100 || generation.Usage.CacheReadInputTokens != 2 || generation.Usage.ReasoningTokens != 3 { + t.Fatalf("unexpected usage mapping: %#v", generation.Usage) + } + if len(generation.Output) != 2 { + t.Fatalf("expected text + tool call outputs, got %#v", generation.Output) + } + if generation.Output[0].Parts[0].Text != "world" { + t.Fatalf("unexpected response text: %q", generation.Output[0].Parts[0].Text) + } + if generation.Output[1].Parts[0].Kind != sigil.PartKindToolCall { + t.Fatalf("expected response tool call, got %#v", generation.Output[1].Parts[0]) + } + requireOpenAIArtifactKinds(t, generation.Artifacts, + sigil.ArtifactKindRequest, + sigil.ArtifactKindResponse, + ) +} + +func TestConformance_ResponsesStreamNormalization(t *testing.T) { + req := oresponses.ResponseNewParams{ + Model: shared.ResponsesModel("gpt-5"), + Input: oresponses.ResponseNewParamsInputUnion{OfString: param.NewOpt("hello")}, + MaxOutputTokens: param.NewOpt(int64(128)), + } + + summary := ResponsesStreamSummary{ + Events: []oresponses.ResponseStreamEventUnion{ + { + Type: "response.output_text.delta", + Delta: "hello", + }, + { + Type: "response.output_text.delta", + Delta: " world", + }, + { + Type: "response.completed", + Response: oresponses.Response{ + ID: "resp_stream_1", + Model: shared.ResponsesModel("gpt-5"), + }, + }, + }, + } + + generation, err := ResponsesFromStream(req, summary, WithRawArtifacts()) + if err != nil { + t.Fatalf("responses stream mapping: %v", err) + } + + if generation.Model.Provider != "openai" || generation.Model.Name != "gpt-5" { + t.Fatalf("unexpected model mapping: %#v", generation.Model) + } + if generation.ResponseID != "resp_stream_1" || generation.ResponseModel != "gpt-5" { + t.Fatalf("unexpected response mapping: id=%q model=%q", generation.ResponseID, generation.ResponseModel) + } + if generation.StopReason != "stop" { + t.Fatalf("unexpected stop reason: %q", generation.StopReason) + } + if len(generation.Output) != 1 || generation.Output[0].Parts[0].Text != "hello world" { + t.Fatalf("unexpected streamed output: %#v", generation.Output) + } + requireOpenAIArtifactKinds(t, generation.Artifacts, + sigil.ArtifactKindRequest, + sigil.ArtifactKindProviderEvent, + ) +} + +func TestConformance_OpenAIMapperValidationErrors(t *testing.T) { + if _, err := ChatCompletionsFromRequestResponse(osdk.ChatCompletionNewParams{}, nil); err == nil || err.Error() != "response is required" { + t.Fatalf("expected explicit chat response error, got %v", err) + } + if _, err := ChatCompletionsFromStream(osdk.ChatCompletionNewParams{}, ChatCompletionsStreamSummary{}); err == nil || err.Error() != "stream summary has no chunks and no final response" { + t.Fatalf("expected explicit chat stream error, got %v", err) + } + if _, err := ResponsesFromRequestResponse(oresponses.ResponseNewParams{}, nil); err == nil || err.Error() != "response is required" { + t.Fatalf("expected explicit responses response error, got %v", err) + } + if _, err := ResponsesFromStream(oresponses.ResponseNewParams{}, ResponsesStreamSummary{}); err == nil || err.Error() != "stream summary has no events and no final response" { + t.Fatalf("expected explicit responses stream error, got %v", err) + } + + _, err := ChatCompletionsFromRequestResponse( + osdk.ChatCompletionNewParams{Model: shared.ChatModel("gpt-4o-mini")}, + &osdk.ChatCompletion{Model: "gpt-4o-mini"}, + WithProviderName(""), + ) + if err == nil || err.Error() != "generation.model.provider is required" { + t.Fatalf("expected explicit validation error for invalid provider mapping, got %v", err) + } +} + +func requireOpenAIArtifactKinds(t *testing.T, artifacts []sigil.Artifact, want ...sigil.ArtifactKind) { + t.Helper() + + if len(artifacts) != len(want) { + t.Fatalf("expected %d artifacts, got %d", len(want), len(artifacts)) + } + for i, kind := range want { + if artifacts[i].Kind != kind { + t.Fatalf("artifact %d kind mismatch: got %q want %q", i, artifacts[i].Kind, kind) + } + } +} diff --git a/go-providers/openai/go.mod b/go-providers/openai/go.mod index 59659f5..3217beb 100644 --- a/go-providers/openai/go.mod +++ b/go-providers/openai/go.mod @@ -1,30 +1,33 @@ -module github.com/grafana/sigil/sdks/go-providers/openai +module github.com/grafana/sigil-sdk/go-providers/openai go 1.25.6 require ( - github.com/grafana/sigil/sdks/go v0.0.0 - github.com/openai/openai-go/v3 v3.24.0 + github.com/grafana/sigil-sdk/go v0.1.2 + github.com/openai/openai-go/v3 v3.29.0 ) require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.40.0 // indirect - go.opentelemetry.io/otel/metric v1.40.0 // indirect - go.opentelemetry.io/otel/trace v1.40.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + go.opentelemetry.io/otel v1.42.0 // indirect + go.opentelemetry.io/otel/metric v1.42.0 // indirect + go.opentelemetry.io/otel/sdk v1.42.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect + go.opentelemetry.io/otel/trace v1.42.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/grpc v1.79.1 // indirect + google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect ) -replace github.com/grafana/sigil/sdks/go => ../../go +replace github.com/grafana/sigil-sdk/go => ../../go diff --git a/go-providers/openai/go.sum b/go-providers/openai/go.sum index 68b48cb..2f3f140 100644 --- a/go-providers/openai/go.sum +++ b/go-providers/openai/go.sum @@ -1,7 +1,7 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -13,10 +13,10 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/openai/openai-go/v3 v3.24.0 h1:08x6GnYiB+AAejTo6yzPY8RkZMJQ8NpreiOyM5QfyYU= -github.com/openai/openai-go/v3 v3.24.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/openai/openai-go/v3 v3.29.0 h1:dZNJ0w7DxwpgppzKQjSKfLebW27KrtGqgSy4ipJS0U8= +github.com/openai/openai-go/v3 v3.29.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -31,28 +31,30 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= -google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/go-providers/openai/mapper.go b/go-providers/openai/mapper.go index 8c61248..8217d0c 100644 --- a/go-providers/openai/mapper.go +++ b/go-providers/openai/mapper.go @@ -9,7 +9,7 @@ import ( osdk "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/shared" - "github.com/grafana/sigil/sdks/go/sigil" + "github.com/grafana/sigil-sdk/go/sigil" ) const thinkingBudgetMetadataKey = "sigil.gen_ai.request.thinking.budget_tokens" @@ -48,33 +48,34 @@ func ChatCompletionsFromRequestResponse(req osdk.ChatCompletionNewParams, resp * } requestModel := string(req.Model) - responseModel := strings.TrimSpace(resp.Model) + responseModel := resp.Model if responseModel == "" { responseModel = requestModel } maxTokens, temperature, topP, toolChoice, thinkingEnabled, thinkingBudget := mapRequestControls(req) generation := sigil.Generation{ - ConversationID: options.conversationID, - AgentName: options.agentName, - AgentVersion: options.agentVersion, - Model: sigil.ModelRef{Provider: options.providerName, Name: requestModel}, - ResponseID: resp.ID, - ResponseModel: responseModel, - SystemPrompt: systemPrompt, - Input: input, - Output: output, - Tools: mapTools(req.Tools), - MaxTokens: maxTokens, - Temperature: temperature, - TopP: topP, - ToolChoice: toolChoice, - ThinkingEnabled: thinkingEnabled, - Usage: mapUsage(resp.Usage), - StopReason: firstFinishReason(resp.Choices), - Tags: cloneStringMap(options.tags), - Metadata: mergeThinkingBudgetMetadata(options.metadata, thinkingBudget), - Artifacts: artifacts, + ConversationID: options.conversationID, + ConversationTitle: options.conversationTitle, + AgentName: options.agentName, + AgentVersion: options.agentVersion, + Model: sigil.ModelRef{Provider: options.providerName, Name: requestModel}, + ResponseID: resp.ID, + ResponseModel: responseModel, + SystemPrompt: systemPrompt, + Input: input, + Output: output, + Tools: mapTools(req.Tools), + MaxTokens: maxTokens, + Temperature: temperature, + TopP: topP, + ToolChoice: toolChoice, + ThinkingEnabled: thinkingEnabled, + Usage: mapUsage(resp.Usage), + StopReason: firstFinishReason(resp.Choices), + Tags: cloneStringMap(options.tags), + Metadata: mergeThinkingBudgetMetadata(options.metadata, thinkingBudget), + Artifacts: artifacts, } if err := generation.Validate(); err != nil { @@ -96,7 +97,7 @@ func EmbeddingsFromResponse(req osdk.EmbeddingNewParams, resp *osdk.CreateEmbedd } result.InputTokens = resp.Usage.PromptTokens - result.ResponseModel = strings.TrimSpace(resp.Model) + result.ResponseModel = resp.Model if len(resp.Data) > 0 { dimensions := int64(len(resp.Data[0].Embedding)) @@ -119,9 +120,9 @@ func mapRequestMessages(messages []osdk.ChatCompletionMessageParamUnion) ([]sigi for i := range messages { switch { case messages[i].OfSystem != nil: - systemPrompts = appendNonEmpty(systemPrompts, extractTextFromSystem(messages[i].OfSystem)) + systemPrompts = append(systemPrompts, extractTextFromSystem(messages[i].OfSystem)) case messages[i].OfDeveloper != nil: - systemPrompts = appendNonEmpty(systemPrompts, extractTextFromDeveloper(messages[i].OfDeveloper)) + systemPrompts = append(systemPrompts, extractTextFromDeveloper(messages[i].OfDeveloper)) case messages[i].OfUser != nil: parts := mapUserParts(messages[i].OfUser) if len(parts) > 0 { @@ -156,10 +157,10 @@ func mapResponseMessages(choices []osdk.ChatCompletionChoice) []sigil.Message { message := choices[0].Message parts := make([]sigil.Part, 0, 1+len(message.ToolCalls)) - if text := strings.TrimSpace(message.Content); text != "" { + if text := message.Content; text != "" { parts = append(parts, sigil.TextPart(text)) } - if refusal := strings.TrimSpace(message.Refusal); refusal != "" { + if refusal := message.Refusal; refusal != "" { parts = append(parts, sigil.TextPart(refusal)) } for _, call := range message.ToolCalls { @@ -187,12 +188,12 @@ func mapResponseMessages(choices []osdk.ChatCompletionChoice) []sigil.Message { func mapUserParts(message *osdk.ChatCompletionUserMessageParam) []sigil.Part { parts := make([]sigil.Part, 0, 2) if message.Content.OfString.Valid() { - if text := strings.TrimSpace(message.Content.OfString.Value); text != "" { + if text := message.Content.OfString.Value; text != "" { parts = append(parts, sigil.TextPart(text)) } } for _, contentPart := range message.Content.OfArrayOfContentParts { - text := strings.TrimSpace(derefString(contentPart.GetText())) + text := derefString(contentPart.GetText()) if text != "" { parts = append(parts, sigil.TextPart(text)) } @@ -203,20 +204,20 @@ func mapUserParts(message *osdk.ChatCompletionUserMessageParam) []sigil.Part { func mapAssistantParamParts(message *osdk.ChatCompletionAssistantMessageParam) []sigil.Part { parts := make([]sigil.Part, 0, 2+len(message.ToolCalls)) if message.Content.OfString.Valid() { - if text := strings.TrimSpace(message.Content.OfString.Value); text != "" { + if text := message.Content.OfString.Value; text != "" { parts = append(parts, sigil.TextPart(text)) } } for _, contentPart := range message.Content.OfArrayOfContentParts { - if text := strings.TrimSpace(derefString(contentPart.GetText())); text != "" { + if text := derefString(contentPart.GetText()); text != "" { parts = append(parts, sigil.TextPart(text)) } - if refusal := strings.TrimSpace(derefString(contentPart.GetRefusal())); refusal != "" { + if refusal := derefString(contentPart.GetRefusal()); refusal != "" { parts = append(parts, sigil.TextPart(refusal)) } } if message.Refusal.Valid() { - if refusal := strings.TrimSpace(message.Refusal.Value); refusal != "" { + if refusal := message.Refusal.Value; refusal != "" { parts = append(parts, sigil.TextPart(refusal)) } } @@ -226,7 +227,7 @@ func mapAssistantParamParts(message *osdk.ChatCompletionAssistantMessageParam) [ continue } part := sigil.ToolCallPart(sigil.ToolCall{ - ID: strings.TrimSpace(derefString(call.GetID())), + ID: derefString(call.GetID()), Name: function.Name, InputJSON: parseJSONOrString(function.Arguments), }) @@ -239,13 +240,11 @@ func mapAssistantParamParts(message *osdk.ChatCompletionAssistantMessageParam) [ func mapToolMessage(message *osdk.ChatCompletionToolMessageParam) *sigil.Part { content := "" if message.Content.OfString.Valid() { - content = strings.TrimSpace(message.Content.OfString.Value) + content = message.Content.OfString.Value } else { chunks := make([]string, 0, len(message.Content.OfArrayOfContentParts)) for _, part := range message.Content.OfArrayOfContentParts { - if text := strings.TrimSpace(part.Text); text != "" { - chunks = append(chunks, text) - } + chunks = append(chunks, part.Text) } content = strings.Join(chunks, "\n") } @@ -266,7 +265,7 @@ func mapFunctionMessage(message *osdk.ChatCompletionFunctionMessageParam) *sigil if !message.Content.Valid() { return nil } - content := strings.TrimSpace(message.Content.Value) + content := message.Content.Value if content == "" { return nil } @@ -503,48 +502,35 @@ func coerceInt64Pointer(value any) *int64 { func extractTextFromSystem(message *osdk.ChatCompletionSystemMessageParam) string { if message.Content.OfString.Valid() { - return strings.TrimSpace(message.Content.OfString.Value) + return message.Content.OfString.Value } parts := make([]string, 0, len(message.Content.OfArrayOfContentParts)) for _, part := range message.Content.OfArrayOfContentParts { - if text := strings.TrimSpace(part.Text); text != "" { - parts = append(parts, text) - } + parts = append(parts, part.Text) } return strings.Join(parts, "\n") } func extractTextFromDeveloper(message *osdk.ChatCompletionDeveloperMessageParam) string { if message.Content.OfString.Valid() { - return strings.TrimSpace(message.Content.OfString.Value) + return message.Content.OfString.Value } parts := make([]string, 0, len(message.Content.OfArrayOfContentParts)) for _, part := range message.Content.OfArrayOfContentParts { - if text := strings.TrimSpace(part.Text); text != "" { - parts = append(parts, text) - } + parts = append(parts, part.Text) } return strings.Join(parts, "\n") } -func appendNonEmpty(values []string, value string) []string { - value = strings.TrimSpace(value) - if value == "" { - return values - } - return append(values, value) -} - func parseJSONOrString(value string) []byte { - trimmed := strings.TrimSpace(value) - if trimmed == "" { + if value == "" { return nil } - data := []byte(trimmed) + data := []byte(value) if json.Valid(data) { return data } - quoted, err := json.Marshal(trimmed) + quoted, err := json.Marshal(value) if err != nil { return nil } diff --git a/go-providers/openai/mapper_test.go b/go-providers/openai/mapper_test.go index 1f702dc..2ebe7ab 100644 --- a/go-providers/openai/mapper_test.go +++ b/go-providers/openai/mapper_test.go @@ -8,7 +8,7 @@ import ( oresponses "github.com/openai/openai-go/v3/responses" "github.com/openai/openai-go/v3/shared" - "github.com/grafana/sigil/sdks/go/sigil" + "github.com/grafana/sigil-sdk/go/sigil" ) func TestFromRequestResponse(t *testing.T) { @@ -75,6 +75,7 @@ func TestFromRequestResponse(t *testing.T) { generation, err := ChatCompletionsFromRequestResponse(req, resp, WithConversationID("conv-9b2f"), + WithConversationTitle("Paris weather"), WithAgentName("agent-openai"), WithAgentVersion("v-openai"), WithTag("tenant", "t-123"), @@ -92,6 +93,9 @@ func TestFromRequestResponse(t *testing.T) { if generation.ConversationID != "conv-9b2f" { t.Fatalf("expected conv-9b2f, got %q", generation.ConversationID) } + if generation.ConversationTitle != "Paris weather" { + t.Fatalf("expected conversation title Paris weather, got %q", generation.ConversationTitle) + } if generation.AgentName != "agent-openai" { t.Fatalf("expected agent-openai, got %q", generation.AgentName) } @@ -148,6 +152,12 @@ func TestFromRequestResponse(t *testing.T) { for _, message := range generation.Input { if message.Role == sigil.RoleTool { hasToolRole = true + if len(message.Parts) != 1 || message.Parts[0].ToolResult == nil { + t.Fatalf("expected single tool_result part, got %#v", message.Parts) + } + if message.Parts[0].ToolResult.ToolCallID != "call_weather" { + t.Fatalf("expected tool_result tool_call_id call_weather, got %q", message.Parts[0].ToolResult.ToolCallID) + } } } if !hasToolRole { @@ -155,6 +165,58 @@ func TestFromRequestResponse(t *testing.T) { } } +func TestMapFunctionMessageUsesNameFallbackCorrelation(t *testing.T) { + //nolint:staticcheck // OpenAI still exposes deprecated function messages in the union surface we normalize. + part := mapFunctionMessage(&osdk.ChatCompletionFunctionMessageParam{ + Name: "weather", + Content: param.NewOpt("18C and sunny"), + }) + + if part == nil { + t.Fatalf("expected tool result part") + } + if part.ToolResult == nil { + t.Fatalf("expected tool result payload, got %#v", part) + } + if part.ToolResult.ToolCallID != "" { + t.Fatalf("expected empty legacy function-result tool_call_id, got %q", part.ToolResult.ToolCallID) + } + if part.ToolResult.Name != "weather" { + t.Fatalf("expected legacy function-result name fallback weather, got %q", part.ToolResult.Name) + } +} + +func TestMapResponsesRequestInputUsesNameFallbackWhenCallIDMissing(t *testing.T) { + input, systemPrompt := mapResponsesRequestInput(map[string]any{ + "input": []any{ + map[string]any{ + "type": "function_call_output", + "name": "weather", + "output": map[string]any{"temp_c": 18}, + }, + }, + }) + + if systemPrompt != "" { + t.Fatalf("expected empty system prompt, got %q", systemPrompt) + } + if len(input) != 1 { + t.Fatalf("expected one input message, got %#v", input) + } + if input[0].Role != sigil.RoleTool { + t.Fatalf("expected tool role, got %q", input[0].Role) + } + if len(input[0].Parts) != 1 || input[0].Parts[0].ToolResult == nil { + t.Fatalf("expected single tool_result part, got %#v", input[0].Parts) + } + if input[0].Parts[0].ToolResult.ToolCallID != "" { + t.Fatalf("expected missing call id to stay empty, got %q", input[0].Parts[0].ToolResult.ToolCallID) + } + if input[0].Parts[0].ToolResult.Name != "weather" { + t.Fatalf("expected Responses fallback name weather, got %q", input[0].Parts[0].ToolResult.Name) + } +} + func TestFromStream(t *testing.T) { req := osdk.ChatCompletionNewParams{ Model: shared.ChatModel("gpt-4o-mini"), @@ -371,7 +433,7 @@ func TestResponsesFromRequestResponse(t *testing.T) { Type: "function_call", CallID: "call_weather", Name: "weather", - Arguments: `{"city":"Paris"}`, + Arguments: oresponses.ResponseOutputItemUnionArguments{OfString: `{"city":"Paris"}`}, }, }, Usage: oresponses.ResponseUsage{ @@ -453,6 +515,29 @@ func TestResponsesFromStream(t *testing.T) { Type: "response.output_text.delta", Delta: " world", }, + { + Type: "response.output_item.added", + Item: oresponses.ResponseOutputItemUnion{ + ID: "fc_1", + Type: "function_call", + CallID: "call_weather", + Name: "weather", + }, + OutputIndex: 1, + }, + { + Type: "response.function_call_arguments.delta", + ItemID: "fc_1", + OutputIndex: 1, + Delta: `{"city":"Pa`, + }, + { + Type: "response.function_call_arguments.done", + ItemID: "fc_1", + OutputIndex: 1, + Name: "weather", + Arguments: `{"city":"Paris"}`, + }, { Type: "response.completed", }, @@ -476,12 +561,24 @@ func TestResponsesFromStream(t *testing.T) { if generation.MaxTokens == nil || *generation.MaxTokens != 128 { t.Fatalf("expected max tokens 128, got %v", generation.MaxTokens) } - if len(generation.Output) != 1 { - t.Fatalf("expected one output message, got %d", len(generation.Output)) + if len(generation.Output) != 2 { + t.Fatalf("expected text and tool-call output messages, got %d", len(generation.Output)) } if generation.Output[0].Parts[0].Text != "hello world" { t.Fatalf("expected merged stream output, got %q", generation.Output[0].Parts[0].Text) } + if generation.Output[1].Parts[0].Kind != sigil.PartKindToolCall { + t.Fatalf("expected tool call output, got %#v", generation.Output[1].Parts[0]) + } + if generation.Output[1].Parts[0].ToolCall.ID != "call_weather" { + t.Fatalf("expected tool call id call_weather, got %q", generation.Output[1].Parts[0].ToolCall.ID) + } + if generation.Output[1].Parts[0].ToolCall.Name != "weather" { + t.Fatalf("expected tool call name weather, got %q", generation.Output[1].Parts[0].ToolCall.Name) + } + if string(generation.Output[1].Parts[0].ToolCall.InputJSON) != `{"city":"Paris"}` { + t.Fatalf("expected tool call input JSON, got %q", string(generation.Output[1].Parts[0].ToolCall.InputJSON)) + } if len(generation.Artifacts) != 2 { t.Fatalf("expected request and provider_event artifacts, got %d", len(generation.Artifacts)) } @@ -548,3 +645,213 @@ func TestEmbeddingsFromResponseWithTokenInputDoesNotCaptureTexts(t *testing.T) { t.Fatalf("expected no input texts for tokenized input, got %v", result.InputTexts) } } + +func TestChatCompletionsFromRequestResponsePreservesWhitespace(t *testing.T) { + req := osdk.ChatCompletionNewParams{ + Model: shared.ChatModel("gpt-4o-mini"), + Messages: []osdk.ChatCompletionMessageParamUnion{ + osdk.SystemMessage(" system prompt "), + osdk.UserMessage(" user literal \\\\n\\\\n "), + }, + } + resp := &osdk.ChatCompletion{ + ID: "chatcmpl_whitespace", + Model: "gpt-4o-mini", + Choices: []osdk.ChatCompletionChoice{ + { + FinishReason: "stop", + Message: osdk.ChatCompletionMessage{ + Content: "\n assistant output \n", + }, + }, + }, + } + + generation, err := ChatCompletionsFromRequestResponse(req, resp) + if err != nil { + t.Fatalf("from request/response: %v", err) + } + + if generation.SystemPrompt != " system prompt " { + t.Fatalf("unexpected system prompt %q", generation.SystemPrompt) + } + if len(generation.Input) != 1 || len(generation.Input[0].Parts) != 1 { + t.Fatalf("expected single input text part, got %#v", generation.Input) + } + if generation.Input[0].Parts[0].Text != " user literal \\\\n\\\\n " { + t.Fatalf("unexpected input text %q", generation.Input[0].Parts[0].Text) + } + if len(generation.Output) != 1 || len(generation.Output[0].Parts) != 1 { + t.Fatalf("expected single output text part, got %#v", generation.Output) + } + if generation.Output[0].Parts[0].Text != "\n assistant output \n" { + t.Fatalf("unexpected output text %q", generation.Output[0].Parts[0].Text) + } +} + +func TestChatCompletionsFromStreamPreservesWhitespaceOnlyOutput(t *testing.T) { + req := osdk.ChatCompletionNewParams{ + Model: shared.ChatModel("gpt-4o-mini"), + } + + summary := ChatCompletionsStreamSummary{ + Chunks: []osdk.ChatCompletionChunk{ + { + ID: "chatcmpl_stream_whitespace", + Model: "gpt-4o-mini", + Choices: []osdk.ChatCompletionChunkChoice{ + { + Delta: osdk.ChatCompletionChunkChoiceDelta{ + Content: " ", + }, + FinishReason: "stop", + }, + }, + Usage: osdk.CompletionUsage{ + PromptTokens: 1, + CompletionTokens: 1, + TotalTokens: 2, + }, + }, + }, + } + + generation, err := ChatCompletionsFromStream(req, summary) + if err != nil { + t.Fatalf("from stream: %v", err) + } + if len(generation.Output) != 1 || len(generation.Output[0].Parts) != 1 { + t.Fatalf("expected single output text part, got %#v", generation.Output) + } + if generation.Output[0].Parts[0].Text != " " { + t.Fatalf("unexpected output text %q", generation.Output[0].Parts[0].Text) + } +} + +func TestResponsesFromRequestResponsePreservesWhitespace(t *testing.T) { + req := oresponses.ResponseNewParams{ + Model: shared.ResponsesModel("gpt-5"), + Instructions: param.NewOpt(" system instructions "), + Input: oresponses.ResponseNewParamsInputUnion{OfString: param.NewOpt(" user literal \\\\n\\\\n ")}, + } + resp := &oresponses.Response{ + ID: "resp_whitespace", + Model: shared.ResponsesModel("gpt-5"), + Status: oresponses.ResponseStatusCompleted, + Output: []oresponses.ResponseOutputItemUnion{ + { + Type: "message", + Content: []oresponses.ResponseOutputMessageContentUnion{ + {Type: "output_text", Text: "\n assistant output \n"}, + }, + }, + }, + } + + generation, err := ResponsesFromRequestResponse(req, resp) + if err != nil { + t.Fatalf("responses from request/response: %v", err) + } + if generation.SystemPrompt != " system instructions " { + t.Fatalf("unexpected system prompt %q", generation.SystemPrompt) + } + if len(generation.Input) != 1 || len(generation.Input[0].Parts) != 1 { + t.Fatalf("expected single input text part, got %#v", generation.Input) + } + if generation.Input[0].Parts[0].Text != " user literal \\\\n\\\\n " { + t.Fatalf("unexpected input text %q", generation.Input[0].Parts[0].Text) + } + if len(generation.Output) != 1 || len(generation.Output[0].Parts) != 1 { + t.Fatalf("expected single output text part, got %#v", generation.Output) + } + if generation.Output[0].Parts[0].Text != "\n assistant output \n" { + t.Fatalf("unexpected output text %q", generation.Output[0].Parts[0].Text) + } +} + +func TestResponsesFromStreamPreservesWhitespaceOnlyOutput(t *testing.T) { + req := oresponses.ResponseNewParams{ + Model: shared.ResponsesModel("gpt-5"), + } + summary := ResponsesStreamSummary{ + Events: []oresponses.ResponseStreamEventUnion{ + { + Type: "response.output_text.delta", + Delta: " ", + }, + { + Type: "response.completed", + }, + }, + } + + generation, err := ResponsesFromStream(req, summary) + if err != nil { + t.Fatalf("responses from stream: %v", err) + } + if len(generation.Output) != 1 || len(generation.Output[0].Parts) != 1 { + t.Fatalf("expected single output text part, got %#v", generation.Output) + } + if generation.Output[0].Parts[0].Text != " " { + t.Fatalf("unexpected output text %q", generation.Output[0].Parts[0].Text) + } +} + +func TestMapRequestMessagesPreservesEmptySystemEntries(t *testing.T) { + system := osdk.ChatCompletionSystemMessageParam{ + Content: osdk.ChatCompletionSystemMessageParamContentUnion{ + OfString: param.NewOpt(""), + }, + } + developer := osdk.ChatCompletionDeveloperMessageParam{ + Content: osdk.ChatCompletionDeveloperMessageParamContentUnion{ + OfString: param.NewOpt("developer instruction"), + }, + } + + input, systemPrompt := mapRequestMessages([]osdk.ChatCompletionMessageParamUnion{ + {OfSystem: &system}, + {OfDeveloper: &developer}, + }) + + if len(input) != 0 { + t.Fatalf("expected no mapped user/assistant/tool input messages, got %#v", input) + } + if systemPrompt != "\n\ndeveloper instruction" { + t.Fatalf("expected preserved empty system entry before developer prompt, got %q", systemPrompt) + } +} + +func TestMapToolMessagePreservesEmptyParts(t *testing.T) { + part := mapToolMessage(&osdk.ChatCompletionToolMessageParam{ + ToolCallID: "call_1", + Content: osdk.ChatCompletionToolMessageParamContentUnion{ + OfArrayOfContentParts: []osdk.ChatCompletionContentPartTextParam{ + {Text: ""}, + {Text: ""}, + }, + }, + }) + + if part == nil { + t.Fatalf("expected tool result part for empty text segments") + } + if part.ToolResult == nil { + t.Fatalf("expected tool result payload, got %#v", part) + } + if part.ToolResult.Content != "\n" { + t.Fatalf("expected newline-preserved content, got %q", part.ToolResult.Content) + } +} + +func TestParseJSONOrStringPreservesWhitespace(t *testing.T) { + if got := string(parseJSONOrString(" {\"city\":\"Paris\"} ")); got != " {\"city\":\"Paris\"} " { + t.Fatalf("expected JSON bytes to preserve whitespace, got %q", got) + } + if got := string(parseJSONOrString(" raw value ")); got != "\" raw value \"" { + t.Fatalf("expected quoted raw string with whitespace preserved, got %q", got) + } + if got := parseJSONOrString(""); got != nil { + t.Fatalf("expected nil for empty string, got %q", string(got)) + } +} diff --git a/go-providers/openai/options.go b/go-providers/openai/options.go index c0f7006..6ace01f 100644 --- a/go-providers/openai/options.go +++ b/go-providers/openai/options.go @@ -4,12 +4,13 @@ package openai type Option func(*mapperOptions) type mapperOptions struct { - providerName string - conversationID string - agentName string - agentVersion string - tags map[string]string - metadata map[string]any + providerName string + conversationID string + conversationTitle string + agentName string + agentVersion string + tags map[string]string + metadata map[string]any includeRequestArtifact bool includeResponseArtifact bool @@ -45,6 +46,13 @@ func WithConversationID(conversationID string) Option { } } +// WithConversationTitle sets Generation.ConversationTitle. +func WithConversationTitle(conversationTitle string) Option { + return func(options *mapperOptions) { + options.conversationTitle = conversationTitle + } +} + // WithAgentName sets Generation.AgentName. func WithAgentName(agentName string) Option { return func(options *mapperOptions) { diff --git a/go-providers/openai/record.go b/go-providers/openai/record.go index 867c0d3..f5fb43a 100644 --- a/go-providers/openai/record.go +++ b/go-providers/openai/record.go @@ -7,7 +7,7 @@ import ( osdk "github.com/openai/openai-go/v3" oresponses "github.com/openai/openai-go/v3/responses" - "github.com/grafana/sigil/sdks/go/sigil" + "github.com/grafana/sigil-sdk/go/sigil" ) // ChatCompletionsNew calls the OpenAI chat-completions API and records the generation. @@ -19,18 +19,31 @@ func ChatCompletionsNew( provider osdk.Client, req osdk.ChatCompletionNewParams, opts ...Option, +) (*osdk.ChatCompletion, error) { + return chatCompletionsNew(ctx, client, req, func(ctx context.Context, request osdk.ChatCompletionNewParams) (*osdk.ChatCompletion, error) { + return provider.Chat.Completions.New(ctx, request) + }, opts...) +} + +func chatCompletionsNew( + ctx context.Context, + client *sigil.Client, + req osdk.ChatCompletionNewParams, + invoke func(context.Context, osdk.ChatCompletionNewParams) (*osdk.ChatCompletion, error), + opts ...Option, ) (*osdk.ChatCompletion, error) { options := applyOptions(opts) ctx, rec := client.StartGeneration(ctx, sigil.GenerationStart{ - ConversationID: options.conversationID, - AgentName: options.agentName, - AgentVersion: options.agentVersion, - Model: sigil.ModelRef{Provider: options.providerName, Name: string(req.Model)}, + ConversationID: options.conversationID, + ConversationTitle: options.conversationTitle, + AgentName: options.agentName, + AgentVersion: options.agentVersion, + Model: sigil.ModelRef{Provider: options.providerName, Name: string(req.Model)}, }) defer rec.End() - resp, err := provider.Chat.Completions.New(ctx, req) + resp, err := invoke(ctx, req) if err != nil { rec.SetCallError(err) return nil, err @@ -54,10 +67,11 @@ func ChatCompletionsNewStreaming( options := applyOptions(opts) ctx, rec := client.StartStreamingGeneration(ctx, sigil.GenerationStart{ - ConversationID: options.conversationID, - AgentName: options.agentName, - AgentVersion: options.agentVersion, - Model: sigil.ModelRef{Provider: options.providerName, Name: string(req.Model)}, + ConversationID: options.conversationID, + ConversationTitle: options.conversationTitle, + AgentName: options.agentName, + AgentVersion: options.agentVersion, + Model: sigil.ModelRef{Provider: options.providerName, Name: string(req.Model)}, }) defer rec.End() @@ -99,18 +113,31 @@ func ResponsesNew( provider osdk.Client, req oresponses.ResponseNewParams, opts ...Option, +) (*oresponses.Response, error) { + return responsesNew(ctx, client, req, func(ctx context.Context, request oresponses.ResponseNewParams) (*oresponses.Response, error) { + return provider.Responses.New(ctx, request) + }, opts...) +} + +func responsesNew( + ctx context.Context, + client *sigil.Client, + req oresponses.ResponseNewParams, + invoke func(context.Context, oresponses.ResponseNewParams) (*oresponses.Response, error), + opts ...Option, ) (*oresponses.Response, error) { options := applyOptions(opts) ctx, rec := client.StartGeneration(ctx, sigil.GenerationStart{ - ConversationID: options.conversationID, - AgentName: options.agentName, - AgentVersion: options.agentVersion, - Model: sigil.ModelRef{Provider: options.providerName, Name: string(req.Model)}, + ConversationID: options.conversationID, + ConversationTitle: options.conversationTitle, + AgentName: options.agentName, + AgentVersion: options.agentVersion, + Model: sigil.ModelRef{Provider: options.providerName, Name: string(req.Model)}, }) defer rec.End() - resp, err := provider.Responses.New(ctx, req) + resp, err := invoke(ctx, req) if err != nil { rec.SetCallError(err) return nil, err @@ -181,10 +208,11 @@ func ResponsesNewStreaming( options := applyOptions(opts) ctx, rec := client.StartStreamingGeneration(ctx, sigil.GenerationStart{ - ConversationID: options.conversationID, - AgentName: options.agentName, - AgentVersion: options.agentVersion, - Model: sigil.ModelRef{Provider: options.providerName, Name: string(req.Model)}, + ConversationID: options.conversationID, + ConversationTitle: options.conversationTitle, + AgentName: options.agentName, + AgentVersion: options.agentVersion, + Model: sigil.ModelRef{Provider: options.providerName, Name: string(req.Model)}, }) defer rec.End() diff --git a/go-providers/openai/record_test.go b/go-providers/openai/record_test.go index 37ce9ae..f7c21c8 100644 --- a/go-providers/openai/record_test.go +++ b/go-providers/openai/record_test.go @@ -7,12 +7,15 @@ import ( "testing" osdk "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/packages/param" + oresponses "github.com/openai/openai-go/v3/responses" + "github.com/openai/openai-go/v3/shared" - "github.com/grafana/sigil/sdks/go/sigil" + "github.com/grafana/sigil-sdk/go/sigil" ) func TestEmbeddingsNewReturnsRecorderValidationErrorAfterEnd(t *testing.T) { - client := newEmbeddingTestClient(t) + client := newProviderTestClient(t) req := osdk.EmbeddingNewParams{ Model: osdk.EmbeddingModel("text-embedding-3-small"), @@ -49,7 +52,7 @@ func TestEmbeddingsNewReturnsRecorderValidationErrorAfterEnd(t *testing.T) { } func TestEmbeddingsNewPreservesProviderErrors(t *testing.T) { - client := newEmbeddingTestClient(t) + client := newProviderTestClient(t) req := osdk.EmbeddingNewParams{ Model: osdk.EmbeddingModel("text-embedding-3-small"), @@ -73,7 +76,124 @@ func TestEmbeddingsNewPreservesProviderErrors(t *testing.T) { } } -func newEmbeddingTestClient(t *testing.T) *sigil.Client { +func TestConformance_ChatCompletionsNewErrorMapping(t *testing.T) { + client := newProviderTestClient(t) + req := osdk.ChatCompletionNewParams{ + Model: shared.ChatModel("gpt-4o-mini"), + Messages: []osdk.ChatCompletionMessageParamUnion{ + osdk.UserMessage("hello"), + }, + } + + t.Run("provider errors are preserved", func(t *testing.T) { + providerErr := errors.New("provider failed") + + response, err := chatCompletionsNew( + context.Background(), + client, + req, + func(context.Context, osdk.ChatCompletionNewParams) (*osdk.ChatCompletion, error) { + return nil, providerErr + }, + ) + if !errors.Is(err, providerErr) { + t.Fatalf("expected provider error, got %v", err) + } + if response != nil { + t.Fatalf("expected nil response on provider error") + } + }) + + t.Run("mapping failures do not hide provider responses", func(t *testing.T) { + expectedResponse := &osdk.ChatCompletion{ + Model: "gpt-4o-mini", + Choices: []osdk.ChatCompletionChoice{ + { + FinishReason: "stop", + Message: osdk.ChatCompletionMessage{ + Content: "hi", + }, + }, + }, + } + + response, err := chatCompletionsNew( + context.Background(), + client, + req, + func(context.Context, osdk.ChatCompletionNewParams) (*osdk.ChatCompletion, error) { + return expectedResponse, nil + }, + WithProviderName(""), + ) + if err != nil { + t.Fatalf("expected nil local error for mapping failure, got %v", err) + } + if response != expectedResponse { + t.Fatalf("expected wrapper to return provider response pointer") + } + }) +} + +func TestConformance_ResponsesNewErrorMapping(t *testing.T) { + client := newProviderTestClient(t) + req := oresponses.ResponseNewParams{ + Model: shared.ResponsesModel("gpt-5"), + Input: oresponses.ResponseNewParamsInputUnion{OfString: param.NewOpt("hello")}, + } + + t.Run("provider errors are preserved", func(t *testing.T) { + providerErr := errors.New("provider failed") + + response, err := responsesNew( + context.Background(), + client, + req, + func(context.Context, oresponses.ResponseNewParams) (*oresponses.Response, error) { + return nil, providerErr + }, + ) + if !errors.Is(err, providerErr) { + t.Fatalf("expected provider error, got %v", err) + } + if response != nil { + t.Fatalf("expected nil response on provider error") + } + }) + + t.Run("mapping failures do not hide provider responses", func(t *testing.T) { + expectedResponse := &oresponses.Response{ + Model: shared.ResponsesModel("gpt-5"), + Status: oresponses.ResponseStatusCompleted, + Output: []oresponses.ResponseOutputItemUnion{ + { + Type: "message", + Content: []oresponses.ResponseOutputMessageContentUnion{ + {Type: "output_text", Text: "hi"}, + }, + }, + }, + } + + response, err := responsesNew( + context.Background(), + client, + req, + func(context.Context, oresponses.ResponseNewParams) (*oresponses.Response, error) { + return expectedResponse, nil + }, + WithProviderName(""), + ) + if err != nil { + t.Fatalf("expected nil local error for mapping failure, got %v", err) + } + if response != expectedResponse { + t.Fatalf("expected wrapper to return provider response pointer") + } + }) +} + +func newProviderTestClient(t *testing.T) *sigil.Client { t.Helper() cfg := sigil.DefaultConfig() diff --git a/go-providers/openai/responses_mapper.go b/go-providers/openai/responses_mapper.go index e8c6ff8..bd59563 100644 --- a/go-providers/openai/responses_mapper.go +++ b/go-providers/openai/responses_mapper.go @@ -4,12 +4,13 @@ import ( "encoding/json" "errors" "fmt" + "sort" "strings" "time" "github.com/openai/openai-go/v3/responses" - "github.com/grafana/sigil/sdks/go/sigil" + "github.com/grafana/sigil-sdk/go/sigil" ) // ResponsesStreamSummary captures Responses API stream events and an optional final response. @@ -33,7 +34,7 @@ func ResponsesFromRequestResponse(req responses.ResponseNewParams, resp *respons maxTokens, temperature, topP, toolChoice, thinkingEnabled, thinkingBudget := mapResponsesRequestControls(requestPayload) requestModel := string(req.Model) - responseModel := strings.TrimSpace(string(resp.Model)) + responseModel := string(resp.Model) if responseModel == "" { responseModel = requestModel } @@ -62,26 +63,27 @@ func ResponsesFromRequestResponse(req responses.ResponseNewParams, resp *respons } generation := sigil.Generation{ - ConversationID: options.conversationID, - AgentName: options.agentName, - AgentVersion: options.agentVersion, - Model: sigil.ModelRef{Provider: options.providerName, Name: requestModel}, - ResponseID: resp.ID, - ResponseModel: responseModel, - SystemPrompt: systemPrompt, - Input: input, - Output: output, - Tools: tools, - MaxTokens: maxTokens, - Temperature: temperature, - TopP: topP, - ToolChoice: toolChoice, - ThinkingEnabled: thinkingEnabled, - Usage: mapResponsesUsage(resp.Usage), - StopReason: normalizeResponsesStopReason(resp), - Tags: cloneStringMap(options.tags), - Metadata: mergeThinkingBudgetMetadata(options.metadata, thinkingBudget), - Artifacts: artifacts, + ConversationID: options.conversationID, + ConversationTitle: options.conversationTitle, + AgentName: options.agentName, + AgentVersion: options.agentVersion, + Model: sigil.ModelRef{Provider: options.providerName, Name: requestModel}, + ResponseID: resp.ID, + ResponseModel: responseModel, + SystemPrompt: systemPrompt, + Input: input, + Output: output, + Tools: tools, + MaxTokens: maxTokens, + Temperature: temperature, + TopP: topP, + ToolChoice: toolChoice, + ThinkingEnabled: thinkingEnabled, + Usage: mapResponsesUsage(resp.Usage), + StopReason: normalizeResponsesStopReason(resp), + Tags: cloneStringMap(options.tags), + Metadata: mergeThinkingBudgetMetadata(options.metadata, thinkingBudget), + Artifacts: artifacts, } if err := generation.Validate(); err != nil { @@ -116,14 +118,16 @@ func ResponsesFromStream(req responses.ResponseNewParams, summary ResponsesStrea usage := sigil.TokenUsage{} stopReason := "" text := strings.Builder{} + toolCalls := map[string]*responsesStreamToolCall{} + toolCallOrder := []string{} for i := range summary.Events { event := summary.Events[i] - eventType := strings.TrimSpace(event.Type) + eventType := event.Type if event.Response.ID != "" { responseID = event.Response.ID - if model := strings.TrimSpace(string(event.Response.Model)); model != "" { + if model := string(event.Response.Model); model != "" { responseModel = model } usage = mapResponsesUsage(event.Response.Usage) @@ -145,6 +149,35 @@ func ResponsesFromStream(req responses.ResponseNewParams, summary ResponsesStrea if text.Len() == 0 && event.Refusal != "" { text.WriteString(event.Refusal) } + case "response.output_item.added", "response.output_item.done": + if event.Item.Type != "function_call" { + break + } + call := ensureResponsesStreamToolCall(toolCalls, &toolCallOrder, event.Item.ID, event.OutputIndex) + if call.callID == "" { + call.callID = strings.TrimSpace(event.Item.CallID) + } + if call.name == "" { + call.name = strings.TrimSpace(event.Item.Name) + } + if arguments := stringifyResponsesOutputArguments(event.Item.Arguments); arguments != "" { + call.arguments.Reset() + call.arguments.WriteString(arguments) + } + case "response.function_call_arguments.delta": + call := ensureResponsesStreamToolCall(toolCalls, &toolCallOrder, event.ItemID, event.OutputIndex) + if event.Delta != "" { + call.arguments.WriteString(event.Delta) + } + case "response.function_call_arguments.done": + call := ensureResponsesStreamToolCall(toolCalls, &toolCallOrder, event.ItemID, event.OutputIndex) + if name := strings.TrimSpace(event.Name); name != "" { + call.name = name + } + if event.Arguments != "" { + call.arguments.Reset() + call.arguments.WriteString(event.Arguments) + } case "response.completed": if stopReason == "" { stopReason = "stop" @@ -170,9 +203,10 @@ func ResponsesFromStream(req responses.ResponseNewParams, summary ResponsesStrea } output := []sigil.Message{} - if generated := strings.TrimSpace(text.String()); generated != "" { + if generated := text.String(); generated != "" { output = append(output, sigil.Message{Role: sigil.RoleAssistant, Parts: []sigil.Part{sigil.TextPart(generated)}}) } + output = append(output, mapResponsesStreamToolCalls(toolCalls, toolCallOrder)...) artifacts := make([]sigil.Artifact, 0, 3) if options.includeRequestArtifact { @@ -198,26 +232,27 @@ func ResponsesFromStream(req responses.ResponseNewParams, summary ResponsesStrea } generation := sigil.Generation{ - ConversationID: options.conversationID, - AgentName: options.agentName, - AgentVersion: options.agentVersion, - Model: sigil.ModelRef{Provider: options.providerName, Name: string(req.Model)}, - ResponseID: responseID, - ResponseModel: responseModel, - SystemPrompt: systemPrompt, - Input: input, - Output: output, - Tools: tools, - MaxTokens: maxTokens, - Temperature: temperature, - TopP: topP, - ToolChoice: toolChoice, - ThinkingEnabled: thinkingEnabled, - Usage: usage, - StopReason: stopReason, - Tags: cloneStringMap(options.tags), - Metadata: mergeThinkingBudgetMetadata(options.metadata, thinkingBudget), - Artifacts: artifacts, + ConversationID: options.conversationID, + ConversationTitle: options.conversationTitle, + AgentName: options.agentName, + AgentVersion: options.agentVersion, + Model: sigil.ModelRef{Provider: options.providerName, Name: string(req.Model)}, + ResponseID: responseID, + ResponseModel: responseModel, + SystemPrompt: systemPrompt, + Input: input, + Output: output, + Tools: tools, + MaxTokens: maxTokens, + Temperature: temperature, + TopP: topP, + ToolChoice: toolChoice, + ThinkingEnabled: thinkingEnabled, + Usage: usage, + StopReason: stopReason, + Tags: cloneStringMap(options.tags), + Metadata: mergeThinkingBudgetMetadata(options.metadata, thinkingBudget), + Artifacts: artifacts, } if err := generation.Validate(); err != nil { @@ -227,6 +262,74 @@ func ResponsesFromStream(req responses.ResponseNewParams, summary ResponsesStrea return generation, nil } +type responsesStreamToolCall struct { + itemID string + callID string + name string + outputIndex int64 + order int + arguments strings.Builder +} + +func ensureResponsesStreamToolCall(calls map[string]*responsesStreamToolCall, order *[]string, itemID string, outputIndex int64) *responsesStreamToolCall { + key := strings.TrimSpace(itemID) + if key == "" { + key = fmt.Sprintf("output-%d", outputIndex) + } + if existing, ok := calls[key]; ok { + if existing.outputIndex == 0 && outputIndex != 0 { + existing.outputIndex = outputIndex + } + return existing + } + + call := &responsesStreamToolCall{ + itemID: key, + outputIndex: outputIndex, + order: len(*order), + } + calls[key] = call + *order = append(*order, key) + return call +} + +func mapResponsesStreamToolCalls(calls map[string]*responsesStreamToolCall, order []string) []sigil.Message { + if len(order) == 0 { + return nil + } + + ordered := make([]*responsesStreamToolCall, 0, len(order)) + for _, key := range order { + call := calls[key] + if call == nil || strings.TrimSpace(call.name) == "" { + continue + } + ordered = append(ordered, call) + } + sort.SliceStable(ordered, func(i, j int) bool { + if ordered[i].outputIndex == ordered[j].outputIndex { + return ordered[i].order < ordered[j].order + } + return ordered[i].outputIndex < ordered[j].outputIndex + }) + + out := make([]sigil.Message, 0, len(ordered)) + for _, call := range ordered { + callID := strings.TrimSpace(call.callID) + if callID == "" { + callID = call.itemID + } + part := sigil.ToolCallPart(sigil.ToolCall{ + ID: callID, + Name: call.name, + InputJSON: parseJSONOrString(call.arguments.String()), + }) + part.Metadata.ProviderType = "tool_call" + out = append(out, sigil.Message{Role: sigil.RoleAssistant, Parts: []sigil.Part{part}}) + } + return out +} + func appendResponsesStreamEventsArtifact(generation sigil.Generation, events []responses.ResponseStreamEventUnion, opts []Option) (sigil.Generation, error) { if len(events) == 0 { return generation, nil @@ -250,7 +353,7 @@ func mapResponsesRequestInput(payload map[string]any) ([]sigil.Message, string) systemPrompts := make([]string, 0, 2) if instructions, ok := payload["instructions"].(string); ok { - systemPrompts = appendNonEmpty(systemPrompts, instructions) + systemPrompts = append(systemPrompts, instructions) } rawInput, hasInput := payload["input"] @@ -260,7 +363,7 @@ func mapResponsesRequestInput(payload map[string]any) ([]sigil.Message, string) switch typed := rawInput.(type) { case string: - if text := strings.TrimSpace(typed); text != "" { + if text := typed; text != "" { input = append(input, sigil.Message{Role: sigil.RoleUser, Parts: []sigil.Part{sigil.TextPart(text)}}) } case []any: @@ -274,7 +377,7 @@ func mapResponsesRequestInput(payload map[string]any) ([]sigil.Message, string) role := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", item["role"]))) if itemType == "message" && (role == "system" || role == "developer") { - systemPrompts = appendNonEmpty(systemPrompts, extractResponsesText(item["content"])) + systemPrompts = append(systemPrompts, extractResponsesText(item["content"])) continue } @@ -283,11 +386,12 @@ func mapResponsesRequestInput(payload map[string]any) ([]sigil.Message, string) if content == "" { content = jsonValueText(item["output"]) } - if strings.TrimSpace(content) == "" { + if content == "" { continue } part := sigil.ToolResultPart(sigil.ToolResult{ - ToolCallID: strings.TrimSpace(fmt.Sprintf("%v", item["call_id"])), + ToolCallID: responsesMapString(item, "call_id", "callId"), + Name: responsesMapString(item, "name"), Content: content, ContentJSON: parseJSONOrString(content), }) @@ -298,7 +402,7 @@ func mapResponsesRequestInput(payload map[string]any) ([]sigil.Message, string) if itemType == "message" || role != "" { content := extractResponsesText(item["content"]) - if strings.TrimSpace(content) == "" { + if content == "" { continue } @@ -329,7 +433,7 @@ func mapResponsesOutput(items []responses.ResponseOutputItemUnion) []sigil.Messa switch item.Type { case "message": text := extractResponsesOutputMessageText(item.Content) - if strings.TrimSpace(text) == "" { + if text == "" { continue } out = append(out, sigil.Message{Role: sigil.RoleAssistant, Parts: []sigil.Part{sigil.TextPart(text)}}) @@ -340,12 +444,12 @@ func mapResponsesOutput(items []responses.ResponseOutputItemUnion) []sigil.Messa part := sigil.ToolCallPart(sigil.ToolCall{ ID: item.CallID, Name: item.Name, - InputJSON: parseJSONOrString(item.Arguments), + InputJSON: parseResponsesOutputArguments(item.Arguments), }) part.Metadata.ProviderType = "tool_call" out = append(out, sigil.Message{Role: sigil.RoleAssistant, Parts: []sigil.Part{part}}) default: - fallback := strings.TrimSpace(extractResponsesOutputFallback(item)) + fallback := extractResponsesOutputFallback(item) if fallback != "" { out = append(out, sigil.Message{Role: sigil.RoleAssistant, Parts: []sigil.Part{sigil.TextPart(fallback)}}) } @@ -369,13 +473,13 @@ func mapResponsesTools(value any) []sigil.ToolDefinition { } toolType := strings.TrimSpace(fmt.Sprintf("%v", tool["type"])) if toolType == "function" { - name := strings.TrimSpace(fmt.Sprintf("%v", tool["name"])) - if name == "" { + name := fmt.Sprintf("%v", tool["name"]) + if strings.TrimSpace(name) == "" { continue } definition := sigil.ToolDefinition{ Name: name, - Description: strings.TrimSpace(fmt.Sprintf("%v", tool["description"])), + Description: fmt.Sprintf("%v", tool["description"]), Type: "function", } if parameters, exists := tool["parameters"]; exists { @@ -385,8 +489,8 @@ func mapResponsesTools(value any) []sigil.ToolDefinition { continue } - name := strings.TrimSpace(fmt.Sprintf("%v", tool["name"])) - if toolType != "" && name != "" { + name := fmt.Sprintf("%v", tool["name"]) + if toolType != "" && strings.TrimSpace(name) != "" { out = append(out, sigil.ToolDefinition{Name: name, Type: toolType}) } } @@ -442,7 +546,7 @@ func mapResponsesRequestControls(payload map[string]any) (*int64, *float64, *flo func extractResponsesText(value any) string { switch typed := value.(type) { case string: - return strings.TrimSpace(typed) + return typed case []any: parts := make([]string, 0, len(typed)) for i := range typed { @@ -453,13 +557,13 @@ func extractResponsesText(value any) string { return strings.Join(parts, "\n") case map[string]any: if text, ok := typed["text"].(string); ok { - return strings.TrimSpace(text) + return text } if text, ok := typed["content"].(string); ok { - return strings.TrimSpace(text) + return text } if refusal, ok := typed["refusal"].(string); ok { - return strings.TrimSpace(refusal) + return refusal } } return "" @@ -471,11 +575,11 @@ func extractResponsesOutputMessageText(content []responses.ResponseOutputMessage item := content[i] switch item.Type { case "output_text": - if text := strings.TrimSpace(item.Text); text != "" { + if text := item.Text; text != "" { parts = append(parts, text) } case "refusal": - if refusal := strings.TrimSpace(item.Refusal); refusal != "" { + if refusal := item.Refusal; refusal != "" { parts = append(parts, refusal) } } @@ -493,12 +597,31 @@ func extractResponsesOutputFallback(item responses.ResponseOutputItemUnion) stri if item.Error != "" { return item.Error } - if item.Name != "" && item.Arguments != "" { - return fmt.Sprintf("%s(%s)", item.Name, item.Arguments) + arguments := stringifyResponsesOutputArguments(item.Arguments) + if item.Name != "" && arguments != "" { + return fmt.Sprintf("%s(%s)", item.Name, arguments) } return "" } +func parseResponsesOutputArguments(arguments responses.ResponseOutputItemUnionArguments) []byte { + return parseJSONOrString(stringifyResponsesOutputArguments(arguments)) +} + +func stringifyResponsesOutputArguments(arguments responses.ResponseOutputItemUnionArguments) string { + if arguments.OfString != "" { + return arguments.OfString + } + if arguments.OfResponseToolSearchCallArguments == nil { + return "" + } + data, err := json.Marshal(arguments.OfResponseToolSearchCallArguments) + if err != nil { + return "" + } + return string(data) +} + func marshalAny(value any) map[string]any { raw, err := json.Marshal(value) if err != nil { @@ -527,3 +650,16 @@ func jsonValueText(value any) string { } return string(data) } + +func responsesMapString(item map[string]any, keys ...string) string { + for _, key := range keys { + value, ok := item[key] + if !ok { + continue + } + if text, ok := value.(string); ok { + return strings.TrimSpace(text) + } + } + return "" +} diff --git a/go-providers/openai/sdk_example_test.go b/go-providers/openai/sdk_example_test.go index 2eaf995..185b521 100644 --- a/go-providers/openai/sdk_example_test.go +++ b/go-providers/openai/sdk_example_test.go @@ -4,7 +4,7 @@ import ( "context" "os" - "github.com/grafana/sigil/sdks/go/sigil" + "github.com/grafana/sigil-sdk/go/sigil" osdk "github.com/openai/openai-go/v3" osdkoption "github.com/openai/openai-go/v3/option" "github.com/openai/openai-go/v3/shared" diff --git a/go-providers/openai/stream_mapper.go b/go-providers/openai/stream_mapper.go index 8e2fbc3..6bec6bd 100644 --- a/go-providers/openai/stream_mapper.go +++ b/go-providers/openai/stream_mapper.go @@ -7,7 +7,7 @@ import ( osdk "github.com/openai/openai-go/v3" - "github.com/grafana/sigil/sdks/go/sigil" + "github.com/grafana/sigil-sdk/go/sigil" ) // ChatCompletionsStreamSummary captures chat-completions stream chunks and an optional final response. @@ -94,7 +94,7 @@ func ChatCompletionsFromStream(req osdk.ChatCompletionNewParams, summary ChatCom } assistantParts := make([]sigil.Part, 0, 1+len(order)) - if generated := strings.TrimSpace(text.String()); generated != "" { + if generated := text.String(); generated != "" { assistantParts = append(assistantParts, sigil.TextPart(generated)) } for _, index := range order { @@ -141,26 +141,27 @@ func ChatCompletionsFromStream(req osdk.ChatCompletionNewParams, summary ChatCom } generation := sigil.Generation{ - ConversationID: options.conversationID, - AgentName: options.agentName, - AgentVersion: options.agentVersion, - Model: sigil.ModelRef{Provider: options.providerName, Name: string(req.Model)}, - ResponseID: responseID, - ResponseModel: modelName, - SystemPrompt: systemPrompt, - Input: input, - Output: output, - Tools: mapTools(req.Tools), - MaxTokens: maxTokens, - Temperature: temperature, - TopP: topP, - ToolChoice: toolChoice, - ThinkingEnabled: thinkingEnabled, - Usage: usage, - StopReason: stopReason, - Tags: cloneStringMap(options.tags), - Metadata: mergeThinkingBudgetMetadata(options.metadata, thinkingBudget), - Artifacts: artifacts, + ConversationID: options.conversationID, + ConversationTitle: options.conversationTitle, + AgentName: options.agentName, + AgentVersion: options.agentVersion, + Model: sigil.ModelRef{Provider: options.providerName, Name: string(req.Model)}, + ResponseID: responseID, + ResponseModel: modelName, + SystemPrompt: systemPrompt, + Input: input, + Output: output, + Tools: mapTools(req.Tools), + MaxTokens: maxTokens, + Temperature: temperature, + TopP: topP, + ToolChoice: toolChoice, + ThinkingEnabled: thinkingEnabled, + Usage: usage, + StopReason: stopReason, + Tags: cloneStringMap(options.tags), + Metadata: mergeThinkingBudgetMetadata(options.metadata, thinkingBudget), + Artifacts: artifacts, } if err := generation.Validate(); err != nil { diff --git a/go.work b/go.work new file mode 100644 index 0000000..107e75b --- /dev/null +++ b/go.work @@ -0,0 +1,9 @@ +go 1.25.6 + +use ( + ./go + ./go-frameworks/google-adk + ./go-providers/anthropic + ./go-providers/gemini + ./go-providers/openai +) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..2a13fa5 --- /dev/null +++ b/go.work.sum @@ -0,0 +1 @@ +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= diff --git a/go/LICENSE b/go/LICENSE index ae8c60c..626a3ab 100644 --- a/go/LICENSE +++ b/go/LICENSE @@ -1,3 +1,3 @@ SPDX-License-Identifier: Apache-2.0 -See /sdks/LICENSE at repository root for full license text. +See /LICENSE at repository root for full license text. diff --git a/go/README.md b/go/README.md index d09a719..f76600f 100644 --- a/go/README.md +++ b/go/README.md @@ -6,13 +6,13 @@ The Go SDK is the current production-ready baseline for normalized generation re Cross-language parity tracks are available for: -- Python: `sdks/python` -- TypeScript/JavaScript: `sdks/js` -- .NET/C#: `sdks/dotnet` +- Python: `python/` +- TypeScript/JavaScript: `js/` +- .NET/C#: `dotnet/` Framework modules: -- Google ADK helper: `../go-frameworks/google-adk/README.md` +- Google ADK helper: [`go-frameworks/google-adk`](../go-frameworks/google-adk/README.md) ## Core model @@ -22,8 +22,10 @@ Framework modules: - `SYNC` -> `generateText` - `STREAM` -> `streamText` - `ModelRef` bundles `provider + model`. +- `ConversationTitle` is an optional human-readable label for the conversation. - `AgentName` and `AgentVersion` are optional generation/tool identity fields. - `SystemPrompt` is separate from messages. +- `ToolDefinition.Deferred` records whether a tool is marked as deferred. - Request controls are optional first-class fields: - `MaxTokens` - `Temperature` @@ -31,6 +33,10 @@ Framework modules: - `ToolChoice` - `ThinkingEnabled` - `Message` contains typed parts: `text`, `thinking`, `tool_call`, `tool_result`. +- Normalized `tool_result` correlation is provider-safe: + - Preserve `tool_result.tool_call_id` whenever the upstream provider exposes a stable per-call identifier. + - When the upstream surface omits a per-call ID, populate `tool_result.name` with the tool/function name as the fallback correlation key. + - Local validation requires at least one of `tool_result.tool_call_id` or `tool_result.name`. - `TokenUsage` includes token/cache/reasoning fields. - Raw provider `Artifacts` are optional debug payloads. @@ -56,6 +62,7 @@ Framework modules: - Normalized generation metadata always includes the same SDK identity key; conflicting caller values are overwritten. - Context helpers are available for defaults: - `WithConversationID(ctx, id)` + - `WithConversationTitle(ctx, title)` - `WithAgentName(ctx, name)` - `WithAgentVersion(ctx, version)` @@ -82,6 +89,9 @@ cfg.GenerationExport.QueueSize = 2000 cfg.GenerationExport.MaxRetries = 5 cfg.GenerationExport.InitialBackoff = 100 * time.Millisecond cfg.GenerationExport.MaxBackoff = 5 * time.Second +cfg.GenerationExport.GRPCMaxSendMessageBytes = 16 << 20 +cfg.GenerationExport.GRPCMaxReceiveMessageBytes = 16 << 20 +cfg.GenerationExport.PayloadMaxBytes = 16 << 20 // Sigil API base used by helpers like SubmitConversationRating. cfg.API.Endpoint = "http://localhost:8080" @@ -123,6 +133,7 @@ Auth is configured for generation export. - `none` - `tenant` (requires `TenantID`, injects `X-Scope-OrgID`) - `bearer` (requires `BearerToken`, injects `Authorization: Bearer `) +- `basic` (requires `BasicPassword` + `BasicUser` or `TenantID`, injects `Authorization: Basic `; also injects `X-Scope-OrgID` when `TenantID` is set — for self-hosted multi-tenancy only, not needed for Grafana Cloud) Invalid combinations fail fast during `NewClient(...)`. @@ -135,6 +146,29 @@ cfg.GenerationExport.Auth = sigil.AuthConfig{ Explicit transport headers remain the highest-precedence escape hatch. If `Headers` already contains `Authorization` or `X-Scope-OrgID`, the SDK does not overwrite them. +### Grafana Cloud auth (basic) + +For Grafana Cloud, use `basic` auth mode. The username is your Grafana Cloud instance/tenant ID and the password is your Grafana Cloud API key: + +```go +cfg.GenerationExport.Auth = sigil.AuthConfig{ + Mode: sigil.ExportAuthModeBasic, + TenantID: os.Getenv("GRAFANA_CLOUD_INSTANCE_ID"), + BasicPassword: os.Getenv("GRAFANA_CLOUD_API_KEY"), +} +``` + +If your deployment requires a distinct username (different from the tenant ID), set `BasicUser` explicitly: + +```go +cfg.GenerationExport.Auth = sigil.AuthConfig{ + Mode: sigil.ExportAuthModeBasic, + TenantID: os.Getenv("GRAFANA_CLOUD_INSTANCE_ID"), + BasicUser: os.Getenv("GRAFANA_CLOUD_INSTANCE_ID"), + BasicPassword: os.Getenv("GRAFANA_CLOUD_API_KEY"), +} +``` + ## Env-secret wiring example The SDK does not auto-load env vars. Read env values in your app and assign config explicitly. @@ -151,7 +185,8 @@ if genToken != "" { Common topology: -- Generations direct to Sigil: generation `tenant` mode. +- Grafana Cloud: generation `basic` mode with instance ID and API key. +- Self-hosted direct to Sigil: generation `tenant` mode. - Traces/metrics via OTEL Collector/Alloy: configure exporters in your app OTEL SDK setup. - Enterprise proxy: generation `bearer` mode to proxy; proxy authenticates and forwards tenant header upstream. @@ -193,6 +228,15 @@ The SDK emits four OTel histograms automatically through your configured OTel me - `gen_ai.client.time_to_first_token` - `gen_ai.client.tool_calls_per_operation` +## Conformance harness + +The Go SDK ships a local no-Docker conformance harness for the current cross-SDK baseline. + +- Shared spec: `docs/references/sdk-conformance-spec.md` (in the sigil repo) +- Default local command: `mise run sdk:conformance` +- Direct Go command: `cd go && GOWORK=off go test ./sigil -run '^TestConformance' -count=1` +- Current baseline coverage: sync roundtrip, conversation title resolution, user ID resolution, agent name/version resolution, streaming mode + TTFT, tool execution, embeddings, validation/error handling, rating submission, and shutdown flush semantics across exported generation payloads, OTLP spans, OTLP metrics, and local rating HTTP capture + ## Explicit flow example ```go @@ -288,9 +332,9 @@ Provider modules are documented wrapper-first for ergonomics and include explici Current Go provider helpers: -- `sdks/go-providers/openai` (OpenAI Chat Completions + Responses wrappers and mappers) -- `sdks/go-providers/anthropic` -- `sdks/go-providers/gemini` +- `go-providers/openai` (OpenAI Chat Completions + Responses wrappers and mappers) +- `go-providers/anthropic` (Anthropic Messages wrappers and mappers; embeddings currently unsupported by the upstream SDK/API surface) +- `go-providers/gemini` ## Raw artifact policy diff --git a/go/cmd/devex-emitter/go.mod b/go/cmd/devex-emitter/go.mod deleted file mode 100644 index 8b5ed4f..0000000 --- a/go/cmd/devex-emitter/go.mod +++ /dev/null @@ -1,62 +0,0 @@ -module github.com/grafana/sigil/sdks/go/cmd/devex-emitter - -go 1.25.6 - -require ( - github.com/anthropics/anthropic-sdk-go v1.26.0 - github.com/grafana/sigil/sdks/go v0.0.0 - github.com/grafana/sigil/sdks/go-providers/anthropic v0.0.0 - github.com/grafana/sigil/sdks/go-providers/gemini v0.0.0 - github.com/grafana/sigil/sdks/go-providers/openai v0.0.0 - github.com/openai/openai-go/v3 v3.24.0 - go.opentelemetry.io/otel v1.40.0 - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 - go.opentelemetry.io/otel/metric v1.40.0 - go.opentelemetry.io/otel/sdk v1.40.0 - go.opentelemetry.io/otel/sdk/metric v1.40.0 - google.golang.org/genai v1.47.0 -) - -require ( - cloud.google.com/go v0.116.0 // indirect - cloud.google.com/go/auth v0.9.3 // indirect - cloud.google.com/go/compute/metadata v0.9.0 // indirect - github.com/cenkalti/backoff/v5 v5.0.3 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/google/s2a-go v0.1.8 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect - github.com/gorilla/websocket v1.5.3 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect - github.com/tidwall/gjson v1.18.0 // indirect - github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/pretty v1.2.1 // indirect - github.com/tidwall/sjson v1.2.5 // indirect - go.opencensus.io v0.24.0 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect - go.opentelemetry.io/otel/trace v1.40.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect - golang.org/x/crypto v0.47.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/grpc v1.79.1 // indirect - google.golang.org/protobuf v1.36.11 // indirect -) - -replace github.com/grafana/sigil/sdks/go => ../.. - -replace github.com/grafana/sigil/sdks/go-providers/anthropic => ../../../go-providers/anthropic - -replace github.com/grafana/sigil/sdks/go-providers/gemini => ../../../go-providers/gemini - -replace github.com/grafana/sigil/sdks/go-providers/openai => ../../../go-providers/openai diff --git a/go/cmd/devex-emitter/go.sum b/go/cmd/devex-emitter/go.sum deleted file mode 100644 index 9119604..0000000 --- a/go/cmd/devex-emitter/go.sum +++ /dev/null @@ -1,192 +0,0 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= -cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= -cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U= -cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk= -cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= -cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= -github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= -github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= -github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= -github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= -github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= -github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= -github.com/openai/openai-go/v3 v3.24.0 h1:08x6GnYiB+AAejTo6yzPY8RkZMJQ8NpreiOyM5QfyYU= -github.com/openai/openai-go/v3 v3.24.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= -github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= -github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= -github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= -go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= -go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0 h1:NOyNnS19BF2SUDApbOKbDtWZ0IK7b8FJ2uAGdIWOGb0= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0/go.mod h1:VL6EgVikRLcJa9ftukrHu/ZkkhFBSo1lzvdBC9CF1ss= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/genai v1.47.0 h1:iWCS7gEdO6rctOqfCYLOrZGKu2D+N42aTnCEcBvB1jo= -google.golang.org/genai v1.47.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= -google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/go/cmd/devex-emitter/main.go b/go/cmd/devex-emitter/main.go deleted file mode 100644 index a555675..0000000 --- a/go/cmd/devex-emitter/main.go +++ /dev/null @@ -1,1052 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "log" - "math/rand" - "os" - "strconv" - "strings" - "time" - - asdk "github.com/anthropics/anthropic-sdk-go" - goanthropic "github.com/grafana/sigil/sdks/go-providers/anthropic" - gogemini "github.com/grafana/sigil/sdks/go-providers/gemini" - goopenai "github.com/grafana/sigil/sdks/go-providers/openai" - "github.com/grafana/sigil/sdks/go/sigil" - osdk "github.com/openai/openai-go/v3" - "github.com/openai/openai-go/v3/packages/param" - oresponses "github.com/openai/openai-go/v3/responses" - "github.com/openai/openai-go/v3/shared" - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/codes" - "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" - "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" - sdkmetric "go.opentelemetry.io/otel/sdk/metric" - "go.opentelemetry.io/otel/sdk/resource" - sdktrace "go.opentelemetry.io/otel/sdk/trace" - oteltrace "go.opentelemetry.io/otel/trace" - "google.golang.org/genai" -) - -const ( - languageName = "go" - traceServiceName = "sigil-sdk-traffic-go" - traceServiceEnv = "sigil-devex" - traceShutdownGrace = 5 * time.Second - metricFlushInterval = 2 * time.Second - minSyntheticSpans = 15 - maxSyntheticSpans = 30 -) - -type runtimeConfig struct { - interval time.Duration - streamPercent int - conversations int - rotateTurns int - maxCycles int - customProvider string - genGRPC string - traceGRPC string -} - -type source string - -const ( - sourceOpenAI source = "openai" - sourceAnthropic source = "anthropic" - sourceGemini source = "gemini" - sourceCustom source = "mistral" -) - -type threadState struct { - conversationID string - turn int -} - -type tagEnvelope struct { - agentPersona string - tags map[string]string - metadata map[string]any -} - -func main() { - cfg := loadConfig() - randSeed := rand.New(rand.NewSource(time.Now().UnixNano())) - telemetryShutdown, err := configureTelemetry(context.Background(), cfg) - if err != nil { - log.Fatalf("[go-emitter] telemetry setup failed: %v", err) - } - defer func() { - shutdownCtx, cancel := context.WithTimeout(context.Background(), traceShutdownGrace) - defer cancel() - if err := telemetryShutdown(shutdownCtx); err != nil { - log.Printf("[go-emitter] telemetry shutdown error: %v", err) - } - }() - - clientCfg := sigil.DefaultConfig() - clientCfg.GenerationExport.Protocol = sigil.GenerationExportProtocolGRPC - clientCfg.GenerationExport.Endpoint = cfg.genGRPC - clientCfg.GenerationExport.Auth = sigil.AuthConfig{Mode: sigil.ExportAuthModeNone} - - client := sigil.NewClient(clientCfg) - defer func() { - if err := client.Shutdown(context.Background()); err != nil { - log.Printf("[go-emitter] shutdown error: %v", err) - } - }() - - sources := []source{sourceOpenAI, sourceAnthropic, sourceGemini, sourceCustom} - threads := make(map[source][]threadState, len(sources)) - nextSlot := make(map[source]int, len(sources)) - for _, src := range sources { - threads[src] = make([]threadState, cfg.conversations) - } - - log.Printf( - "[go-emitter] started interval=%s stream_percent=%d conversations=%d rotate_turns=%d custom_provider=%s trace_grpc=%s", - cfg.interval, - cfg.streamPercent, - cfg.conversations, - cfg.rotateTurns, - cfg.customProvider, - cfg.traceGRPC, - ) - cycles := 0 - - for { - for _, src := range sources { - slot := nextSlot[src] % cfg.conversations - nextSlot[src]++ - - thread := &threads[src][slot] - ensureThread(thread, cfg.rotateTurns, src, slot) - mode := chooseMode(randSeed.Intn(100), cfg.streamPercent) - - if err := emitForSource(client, cfg, randSeed, src, slot, thread, mode); err != nil { - log.Fatalf("[go-emitter] emit failed source=%s slot=%d turn=%d: %v", src, slot, thread.turn, err) - } - thread.turn++ - } - - cycles++ - if cfg.maxCycles > 0 && cycles >= cfg.maxCycles { - return - } - - jitterMs := randSeed.Intn(401) - 200 - sleep := cfg.interval + time.Duration(jitterMs)*time.Millisecond - if sleep < 200*time.Millisecond { - sleep = 200 * time.Millisecond - } - time.Sleep(sleep) - } -} - -func emitForSource(client *sigil.Client, cfg runtimeConfig, randSeed *rand.Rand, src source, slot int, thread *threadState, mode sigil.GenerationMode) error { - envelope := buildTagEnvelope(src, mode, thread.turn, slot) - agentName := fmt.Sprintf("devex-%s-%s-%s", languageName, src, envelope.agentPersona) - agentVersion := "devex-1" - - ctx := context.Background() - tracer := otel.Tracer("sigil.devex.synthetic") - ctx, conversationSpan := tracer.Start( - ctx, - fmt.Sprintf("conversation.%s.turn", src), - oteltrace.WithAttributes( - attribute.String("sigil.synthetic.trace_type", "llm_conversation"), - attribute.String("sigil.devex.provider", string(src)), - attribute.String("sigil.devex.mode", string(mode)), - attribute.String("sigil.devex.conversation_id", thread.conversationID), - attribute.Int("sigil.devex.turn", thread.turn), - attribute.Int("sigil.devex.slot", slot), - attribute.String("sigil.devex.scenario", envelope.tags["sigil.devex.scenario"]), - ), - ) - defer conversationSpan.End() - syntheticCount := emitSyntheticLifecycleSpans(ctx, randSeed) - conversationSpan.SetAttributes(attribute.Int("sigil.synthetic.span_count", syntheticCount)) - - switch src { - case sourceOpenAI: - if mode == sigil.GenerationModeStream { - if openAIUsesResponses(thread.turn) { - return emitOpenAIResponsesStream(ctx, client, thread.conversationID, agentName, agentVersion, envelope.tags, envelope.metadata, thread.turn) - } - return emitOpenAIChatCompletionsStream(ctx, client, thread.conversationID, agentName, agentVersion, envelope.tags, envelope.metadata, thread.turn) - } - if openAIUsesResponses(thread.turn) { - return emitOpenAIResponsesSync(ctx, client, thread.conversationID, agentName, agentVersion, envelope.tags, envelope.metadata, thread.turn) - } - return emitOpenAIChatCompletionsSync(ctx, client, thread.conversationID, agentName, agentVersion, envelope.tags, envelope.metadata, thread.turn) - case sourceAnthropic: - if mode == sigil.GenerationModeStream { - return emitAnthropicStream(ctx, client, thread.conversationID, agentName, agentVersion, envelope.tags, envelope.metadata, thread.turn) - } - return emitAnthropicSync(ctx, client, thread.conversationID, agentName, agentVersion, envelope.tags, envelope.metadata, thread.turn) - case sourceGemini: - if mode == sigil.GenerationModeStream { - return emitGeminiStream(ctx, client, thread.conversationID, agentName, agentVersion, envelope.tags, envelope.metadata, thread.turn) - } - return emitGeminiSync(ctx, client, thread.conversationID, agentName, agentVersion, envelope.tags, envelope.metadata, thread.turn) - case sourceCustom: - provider := cfg.customProvider - if provider == "" { - provider = string(sourceCustom) - } - if mode == sigil.GenerationModeStream { - return emitCustomStream(ctx, client, provider, thread.conversationID, agentName, agentVersion, envelope.tags, envelope.metadata, thread.turn, randSeed) - } - return emitCustomSync(ctx, client, provider, thread.conversationID, agentName, agentVersion, envelope.tags, envelope.metadata, thread.turn, randSeed) - default: - return fmt.Errorf("unknown source %q", src) - } -} - -func emitSyntheticLifecycleSpans(ctx context.Context, randSeed *rand.Rand) int { - if randSeed == nil { - randSeed = rand.New(rand.NewSource(time.Now().UnixNano())) - } - operations := []struct { - name string - category string - component string - }{ - {name: "auth.validate_session", category: "auth", component: "auth-service"}, - {name: "auth.refresh_token", category: "auth", component: "auth-service"}, - {name: "db.load_conversation_context", category: "database", component: "postgres"}, - {name: "db.store_generation_metadata", category: "database", component: "postgres"}, - {name: "cache.redis_get", category: "cache", component: "redis"}, - {name: "cache.redis_set", category: "cache", component: "redis"}, - {name: "retrieval.vector_search", category: "retrieval", component: "vector-db"}, - {name: "retrieval.rerank_documents", category: "retrieval", component: "reranker"}, - {name: "tools.web_search.call", category: "tool_call", component: "tool-runner"}, - {name: "tools.sql_query.call", category: "tool_call", component: "tool-runner"}, - {name: "tools.code_interpreter.call", category: "tool_call", component: "tool-runner"}, - {name: "policy.safety_screen", category: "guardrail", component: "safety-service"}, - {name: "prompt.assemble_context", category: "prompting", component: "prompt-builder"}, - {name: "llm.request", category: "model", component: "provider-gateway"}, - {name: "llm.first_token_wait", category: "model", component: "provider-gateway"}, - {name: "output.stream_chunks", category: "streaming", component: "stream-router"}, - {name: "external.crm_lookup", category: "external_service", component: "crm-api"}, - {name: "external.calendar_lookup", category: "external_service", component: "calendar-api"}, - {name: "external.slack_post", category: "external_service", component: "slack-api"}, - {name: "observability.emit_metrics", category: "telemetry", component: "metrics-pipeline"}, - } - - spanCount := minSyntheticSpans + randSeed.Intn(maxSyntheticSpans-minSyntheticSpans+1) - tracer := otel.Tracer("sigil.devex.synthetic") - - for i := 0; i < spanCount; i++ { - op := operations[randSeed.Intn(len(operations))] - duration := syntheticDuration(op.category, randSeed) - endTime := time.Now() - startTime := endTime.Add(-duration) - - _, span := tracer.Start( - ctx, - op.name, - oteltrace.WithTimestamp(startTime), - oteltrace.WithAttributes( - attribute.String("sigil.synthetic.category", op.category), - attribute.String("sigil.synthetic.component", op.component), - attribute.Int("sigil.synthetic.step_index", i), - attribute.Int64("sigil.synthetic.simulated_duration_ms", duration.Milliseconds()), - ), - ) - - if op.category == "database" { - span.SetAttributes( - attribute.String("db.system", "postgresql"), - attribute.String("db.operation", []string{"SELECT", "INSERT", "UPDATE"}[randSeed.Intn(3)]), - ) - } - if op.category == "tool_call" { - toolNames := []string{"web_search", "sql_query", "code_interpreter", "ticket_lookup"} - span.SetAttributes(attribute.String("gen_ai.tool.name", toolNames[randSeed.Intn(len(toolNames))])) - } - if op.category == "external_service" { - host := []string{"crm.internal", "calendar.internal", "slack.com"}[randSeed.Intn(3)] - span.SetAttributes(attribute.String("server.address", host)) - } - if op.category == "model" { - span.SetAttributes( - attribute.String("gen_ai.operation.name", []string{"generateText", "streamText"}[randSeed.Intn(2)]), - attribute.String("gen_ai.request.model", []string{"gpt-5", "claude-sonnet-4-5", "gemini-2.5-pro"}[randSeed.Intn(3)]), - ) - } - - // Keep failures sparse but present so UI/testing can exercise error states. - if randSeed.Intn(100) < 12 { - errorType := []string{"timeout", "rate_limit", "upstream_503", "validation_error"}[randSeed.Intn(4)] - span.SetStatus(codes.Error, errorType) - span.SetAttributes( - attribute.String("error.type", errorType), - attribute.Bool("error", true), - ) - } - - span.End(oteltrace.WithTimestamp(endTime)) - } - - return spanCount -} - -func syntheticDuration(category string, randSeed *rand.Rand) time.Duration { - switch category { - case "auth": - return time.Duration(8+randSeed.Intn(24)) * time.Millisecond - case "database": - return time.Duration(18+randSeed.Intn(120)) * time.Millisecond - case "cache": - return time.Duration(2+randSeed.Intn(10)) * time.Millisecond - case "retrieval": - return time.Duration(25+randSeed.Intn(150)) * time.Millisecond - case "tool_call": - return time.Duration(45+randSeed.Intn(260)) * time.Millisecond - case "guardrail": - return time.Duration(15+randSeed.Intn(70)) * time.Millisecond - case "prompting": - return time.Duration(10+randSeed.Intn(40)) * time.Millisecond - case "model": - return time.Duration(90+randSeed.Intn(520)) * time.Millisecond - case "streaming": - return time.Duration(25+randSeed.Intn(130)) * time.Millisecond - case "external_service": - return time.Duration(40+randSeed.Intn(220)) * time.Millisecond - default: - return time.Duration(10+randSeed.Intn(100)) * time.Millisecond - } -} - -func emitOpenAIChatCompletionsSync( - ctx context.Context, - client *sigil.Client, - conversationID string, - agentName string, - agentVersion string, - tags map[string]string, - metadata map[string]any, - turn int, -) error { - req := osdk.ChatCompletionNewParams{ - Model: shared.ChatModel("gpt-5"), - Messages: []osdk.ChatCompletionMessageParamUnion{ - osdk.SystemMessage("You are a concise planner that always returns action bullets."), - osdk.UserMessage(fmt.Sprintf("Plan run %d for shipping issue triage.", turn)), - }, - } - resp := &osdk.ChatCompletion{ - ID: fmt.Sprintf("go-openai-sync-%d", turn), - Model: "gpt-5", - Choices: []osdk.ChatCompletionChoice{ - { - FinishReason: "stop", - Message: osdk.ChatCompletionMessage{ - Content: "1. Pull recent incidents\n2. Group by owner\n3. Draft next action", - }, - }, - }, - Usage: osdk.CompletionUsage{ - PromptTokens: int64(80 + turn%15), - CompletionTokens: int64(28 + turn%9), - TotalTokens: int64(108 + turn%24), - }, - } - - mapped, err := goopenai.ChatCompletionsFromRequestResponse(req, resp, - goopenai.WithConversationID(conversationID), - goopenai.WithAgentName(agentName), - goopenai.WithAgentVersion(agentVersion), - goopenai.WithTags(tags), - goopenai.WithMetadata(metadata), - ) - if err != nil { - return err - } - - _, rec := client.StartGeneration(ctx, sigil.GenerationStart{Model: sigil.ModelRef{Provider: "openai", Name: "gpt-5"}}) - rec.SetResult(mapped, nil) - rec.End() - return rec.Err() -} - -func emitOpenAIChatCompletionsStream( - ctx context.Context, - client *sigil.Client, - conversationID string, - agentName string, - agentVersion string, - tags map[string]string, - metadata map[string]any, - turn int, -) error { - req := osdk.ChatCompletionNewParams{ - Model: shared.ChatModel("gpt-5"), - Messages: []osdk.ChatCompletionMessageParamUnion{ - osdk.UserMessage(fmt.Sprintf("Stream an execution status update for ticket %d.", turn)), - }, - } - summary := goopenai.ChatCompletionsStreamSummary{ - Chunks: []osdk.ChatCompletionChunk{ - { - ID: fmt.Sprintf("go-openai-stream-%d", turn), - Model: "gpt-5", - Choices: []osdk.ChatCompletionChunkChoice{ - { - Delta: osdk.ChatCompletionChunkChoiceDelta{Content: "Starting rollout checks..."}, - }, - { - Delta: osdk.ChatCompletionChunkChoiceDelta{Content: " completed."}, - FinishReason: "stop", - }, - }, - Usage: osdk.CompletionUsage{ - PromptTokens: 42, - CompletionTokens: 14, - TotalTokens: 56, - }, - }, - }, - } - - mapped, err := goopenai.ChatCompletionsFromStream(req, summary, - goopenai.WithConversationID(conversationID), - goopenai.WithAgentName(agentName), - goopenai.WithAgentVersion(agentVersion), - goopenai.WithTags(tags), - goopenai.WithMetadata(metadata), - ) - if err != nil { - return err - } - - _, rec := client.StartStreamingGeneration(ctx, sigil.GenerationStart{Model: sigil.ModelRef{Provider: "openai", Name: "gpt-5"}}) - rec.SetFirstTokenAt(time.Now().UTC()) - rec.SetResult(mapped, nil) - rec.End() - return rec.Err() -} - -func emitOpenAIResponsesSync( - ctx context.Context, - client *sigil.Client, - conversationID string, - agentName string, - agentVersion string, - tags map[string]string, - metadata map[string]any, - turn int, -) error { - req := oresponses.ResponseNewParams{ - Model: shared.ResponsesModel("gpt-5"), - Instructions: param.NewOpt("You are a concise planner that always returns action bullets."), - Input: oresponses.ResponseNewParamsInputUnion{OfString: param.NewOpt(fmt.Sprintf("Plan run %d for shipping issue triage.", turn))}, - MaxOutputTokens: param.NewOpt(int64(256)), - } - resp := &oresponses.Response{ - ID: fmt.Sprintf("go-openai-responses-sync-%d", turn), - Model: shared.ResponsesModel("gpt-5"), - Status: oresponses.ResponseStatusCompleted, - Output: []oresponses.ResponseOutputItemUnion{ - { - Type: "message", - Content: []oresponses.ResponseOutputMessageContentUnion{ - {Type: "output_text", Text: "1. Pull recent incidents\n2. Group by owner\n3. Draft next action"}, - }, - }, - }, - Usage: oresponses.ResponseUsage{ - InputTokens: int64(80 + turn%15), - OutputTokens: int64(28 + turn%9), - TotalTokens: int64(108 + turn%24), - }, - } - - mapped, err := goopenai.ResponsesFromRequestResponse(req, resp, - goopenai.WithConversationID(conversationID), - goopenai.WithAgentName(agentName), - goopenai.WithAgentVersion(agentVersion), - goopenai.WithTags(tags), - goopenai.WithMetadata(metadata), - ) - if err != nil { - return err - } - - _, rec := client.StartGeneration(ctx, sigil.GenerationStart{Model: sigil.ModelRef{Provider: "openai", Name: "gpt-5"}}) - rec.SetResult(mapped, nil) - rec.End() - return rec.Err() -} - -func emitOpenAIResponsesStream( - ctx context.Context, - client *sigil.Client, - conversationID string, - agentName string, - agentVersion string, - tags map[string]string, - metadata map[string]any, - turn int, -) error { - req := oresponses.ResponseNewParams{ - Model: shared.ResponsesModel("gpt-5"), - Input: oresponses.ResponseNewParamsInputUnion{ - OfString: param.NewOpt(fmt.Sprintf("Stream an execution status update for ticket %d.", turn)), - }, - MaxOutputTokens: param.NewOpt(int64(128)), - } - - summary := goopenai.ResponsesStreamSummary{ - Events: []oresponses.ResponseStreamEventUnion{ - { - Type: "response.output_text.delta", - Delta: "Starting rollout checks...", - }, - { - Type: "response.output_text.delta", - Delta: " completed.", - }, - { - Type: "response.completed", - Response: oresponses.Response{ - ID: fmt.Sprintf("go-openai-responses-stream-%d", turn), - Model: shared.ResponsesModel("gpt-5"), - Status: oresponses.ResponseStatusCompleted, - Usage: oresponses.ResponseUsage{ - InputTokens: 42, - OutputTokens: 14, - TotalTokens: 56, - }, - }, - }, - }, - } - - mapped, err := goopenai.ResponsesFromStream(req, summary, - goopenai.WithConversationID(conversationID), - goopenai.WithAgentName(agentName), - goopenai.WithAgentVersion(agentVersion), - goopenai.WithTags(tags), - goopenai.WithMetadata(metadata), - ) - if err != nil { - return err - } - - _, rec := client.StartStreamingGeneration(ctx, sigil.GenerationStart{Model: sigil.ModelRef{Provider: "openai", Name: "gpt-5"}}) - rec.SetFirstTokenAt(time.Now().UTC()) - rec.SetResult(mapped, nil) - rec.End() - return rec.Err() -} - -func emitAnthropicSync( - ctx context.Context, - client *sigil.Client, - conversationID string, - agentName string, - agentVersion string, - tags map[string]string, - metadata map[string]any, - turn int, -) error { - req := asdk.BetaMessageNewParams{ - Model: asdk.Model("claude-sonnet-4-5"), - System: []asdk.BetaTextBlockParam{{ - Text: "Think in short phases and include rationale.", - }}, - Messages: []asdk.BetaMessageParam{{ - Role: asdk.BetaMessageParamRoleUser, - Content: []asdk.BetaContentBlockParamUnion{ - asdk.NewBetaTextBlock(fmt.Sprintf("Summarize weekly reliability drift (%d).", turn)), - }, - }}, - } - resp := &asdk.BetaMessage{ - ID: fmt.Sprintf("go-anthropic-sync-%d", turn), - Model: asdk.Model("claude-sonnet-4-5"), - Content: []asdk.BetaContentBlockUnion{ - {Type: "thinking", Thinking: "identify top two drift vectors"}, - {Type: "text", Text: "Drift rose in retries and latency on EU shards."}, - }, - StopReason: asdk.BetaStopReasonEndTurn, - Usage: asdk.BetaUsage{ - InputTokens: 75, - OutputTokens: 31, - }, - } - - mapped, err := goanthropic.FromRequestResponse(req, resp, - goanthropic.WithConversationID(conversationID), - goanthropic.WithAgentName(agentName), - goanthropic.WithAgentVersion(agentVersion), - goanthropic.WithTags(tags), - goanthropic.WithMetadata(metadata), - ) - if err != nil { - return err - } - - _, rec := client.StartGeneration(ctx, sigil.GenerationStart{Model: sigil.ModelRef{Provider: "anthropic", Name: "claude-sonnet-4-5"}}) - rec.SetResult(mapped, nil) - rec.End() - return rec.Err() -} - -func emitAnthropicStream( - ctx context.Context, - client *sigil.Client, - conversationID string, - agentName string, - agentVersion string, - tags map[string]string, - metadata map[string]any, - turn int, -) error { - req := asdk.BetaMessageNewParams{ - Model: asdk.Model("claude-sonnet-4-5"), - Messages: []asdk.BetaMessageParam{{ - Role: asdk.BetaMessageParamRoleUser, - Content: []asdk.BetaContentBlockParamUnion{ - asdk.NewBetaTextBlock(fmt.Sprintf("Stream a live mitigation status for deployment %d.", turn)), - }, - }}, - } - - summary := goanthropic.StreamSummary{ - Events: []asdk.BetaRawMessageStreamEventUnion{ - { - Type: "message_start", - Message: asdk.BetaMessage{ - ID: fmt.Sprintf("go-anthropic-stream-%d", turn), - Model: asdk.Model("claude-sonnet-4-5"), - }, - }, - { - Type: "content_block_start", - ContentBlock: asdk.BetaRawContentBlockStartEventContentBlockUnion{ - Type: "text", - Text: "Mitigation running on canary set.", - }, - }, - { - Type: "message_delta", - Delta: asdk.BetaRawMessageStreamEventUnionDelta{ - StopReason: asdk.BetaStopReasonEndTurn, - }, - }, - }, - } - - mapped, err := goanthropic.FromStream(req, summary, - goanthropic.WithConversationID(conversationID), - goanthropic.WithAgentName(agentName), - goanthropic.WithAgentVersion(agentVersion), - goanthropic.WithTags(tags), - goanthropic.WithMetadata(metadata), - ) - if err != nil { - return err - } - - _, rec := client.StartStreamingGeneration(ctx, sigil.GenerationStart{Model: sigil.ModelRef{Provider: "anthropic", Name: "claude-sonnet-4-5"}}) - rec.SetFirstTokenAt(time.Now().UTC()) - rec.SetResult(mapped, nil) - rec.End() - return rec.Err() -} - -func emitGeminiSync( - ctx context.Context, - client *sigil.Client, - conversationID string, - agentName string, - agentVersion string, - tags map[string]string, - metadata map[string]any, - turn int, -) error { - model := "gemini-2.5-pro" - contents := []*genai.Content{ - genai.NewContentFromText(fmt.Sprintf("Draft a short launch note for sprint %d.", turn), genai.RoleUser), - } - var requestConfig *genai.GenerateContentConfig - resp := &genai.GenerateContentResponse{ - ResponseID: fmt.Sprintf("go-gemini-sync-%d", turn), - ModelVersion: "gemini-2.5-pro-001", - Candidates: []*genai.Candidate{ - { - FinishReason: genai.FinishReasonStop, - Content: genai.NewContentFromText("Launch note: rollout green, no regressions observed.", genai.RoleModel), - }, - }, - UsageMetadata: &genai.GenerateContentResponseUsageMetadata{ - PromptTokenCount: 54, - CandidatesTokenCount: 18, - TotalTokenCount: 72, - }, - } - - mapped, err := gogemini.FromRequestResponse(model, contents, requestConfig, resp, - gogemini.WithConversationID(conversationID), - gogemini.WithAgentName(agentName), - gogemini.WithAgentVersion(agentVersion), - gogemini.WithTags(tags), - gogemini.WithMetadata(metadata), - ) - if err != nil { - return err - } - - _, rec := client.StartGeneration(ctx, sigil.GenerationStart{Model: sigil.ModelRef{Provider: "gemini", Name: "gemini-2.5-pro"}}) - rec.SetResult(mapped, nil) - rec.End() - return rec.Err() -} - -func emitGeminiStream( - ctx context.Context, - client *sigil.Client, - conversationID string, - agentName string, - agentVersion string, - tags map[string]string, - metadata map[string]any, - turn int, -) error { - model := "gemini-2.5-pro" - contents := []*genai.Content{ - genai.NewContentFromText(fmt.Sprintf("Stream a migration checklist status for wave %d.", turn), genai.RoleUser), - } - var requestConfig *genai.GenerateContentConfig - summary := gogemini.StreamSummary{ - Responses: []*genai.GenerateContentResponse{ - { - ResponseID: fmt.Sprintf("go-gemini-stream-%d", turn), - ModelVersion: "gemini-2.5-pro-001", - Candidates: []*genai.Candidate{ - { - Content: genai.NewContentFromText("Checklist in progress...", genai.RoleModel), - FinishReason: genai.FinishReasonUnspecified, - }, - }, - }, - { - ResponseID: fmt.Sprintf("go-gemini-stream-%d", turn), - ModelVersion: "gemini-2.5-pro-001", - Candidates: []*genai.Candidate{ - { - Content: genai.NewContentFromText("Checklist complete. All gates passed.", genai.RoleModel), - FinishReason: genai.FinishReasonStop, - }, - }, - UsageMetadata: &genai.GenerateContentResponseUsageMetadata{ - PromptTokenCount: 44, - CandidatesTokenCount: 16, - TotalTokenCount: 60, - }, - }, - }, - } - - mapped, err := gogemini.FromStream(model, contents, requestConfig, summary, - gogemini.WithConversationID(conversationID), - gogemini.WithAgentName(agentName), - gogemini.WithAgentVersion(agentVersion), - gogemini.WithTags(tags), - gogemini.WithMetadata(metadata), - ) - if err != nil { - return err - } - - _, rec := client.StartStreamingGeneration(ctx, sigil.GenerationStart{Model: sigil.ModelRef{Provider: "gemini", Name: "gemini-2.5-pro"}}) - rec.SetFirstTokenAt(time.Now().UTC()) - rec.SetResult(mapped, nil) - rec.End() - return rec.Err() -} - -func emitCustomSync( - ctx context.Context, - client *sigil.Client, - provider string, - conversationID string, - agentName string, - agentVersion string, - tags map[string]string, - metadata map[string]any, - turn int, - randSeed *rand.Rand, -) error { - _, rec := client.StartGeneration(ctx, sigil.GenerationStart{ - ConversationID: conversationID, - AgentName: agentName, - AgentVersion: agentVersion, - Model: sigil.ModelRef{ - Provider: provider, - Name: "mistral-large-devex", - }, - Tags: tags, - Metadata: metadata, - }) - result := sigil.Generation{ - Input: []sigil.Message{ - sigil.UserTextMessage(fmt.Sprintf("Generate custom provider narrative for checkpoint %d.", turn)), - }, - Output: []sigil.Message{ - sigil.AssistantTextMessage("Custom provider: checkpoint healthy, drift below threshold."), - }, - Usage: sigil.TokenUsage{ - InputTokens: int64(30 + randSeed.Intn(10)), - OutputTokens: int64(16 + randSeed.Intn(6)), - }, - StopReason: "stop", - } - rec.SetResult(result, nil) - rec.End() - return rec.Err() -} - -func emitCustomStream( - ctx context.Context, - client *sigil.Client, - provider string, - conversationID string, - agentName string, - agentVersion string, - tags map[string]string, - metadata map[string]any, - turn int, - randSeed *rand.Rand, -) error { - _, rec := client.StartStreamingGeneration(ctx, sigil.GenerationStart{ - ConversationID: conversationID, - AgentName: agentName, - AgentVersion: agentVersion, - Model: sigil.ModelRef{ - Provider: provider, - Name: "mistral-large-devex", - }, - Tags: tags, - Metadata: metadata, - }) - rec.SetFirstTokenAt(time.Now().UTC()) - - result := sigil.Generation{ - Input: []sigil.Message{ - sigil.UserTextMessage(fmt.Sprintf("Stream a custom remediation summary for slot %d turn %d.", metadata["conversation_slot"], turn)), - }, - Output: []sigil.Message{ - { - Role: sigil.RoleAssistant, - Parts: []sigil.Part{ - sigil.ThinkingPart("composing synthetic stream segments"), - sigil.TextPart("Segment A complete. Segment B complete."), - }, - }, - }, - Usage: sigil.TokenUsage{ - InputTokens: int64(26 + randSeed.Intn(12)), - OutputTokens: int64(18 + randSeed.Intn(7)), - }, - StopReason: "end_turn", - } - rec.SetResult(result, nil) - rec.End() - return rec.Err() -} - -func sourceTagFor(src source) string { - if src == sourceCustom { - return "core_custom" - } - return "provider_wrapper" -} - -func ensureThread(thread *threadState, rotateTurns int, src source, slot int) { - if thread.conversationID == "" || thread.turn >= rotateTurns { - thread.conversationID = newConversationID(languageName, string(src), slot) - thread.turn = 0 - } -} - -func chooseMode(roll int, streamPercent int) sigil.GenerationMode { - if roll < streamPercent { - return sigil.GenerationModeStream - } - return sigil.GenerationModeSync -} - -func buildTagEnvelope(src source, mode sigil.GenerationMode, turn int, slot int) tagEnvelope { - agentPersona := personaForTurn(turn) - return tagEnvelope{ - agentPersona: agentPersona, - tags: map[string]string{ - "sigil.devex.language": languageName, - "sigil.devex.provider": string(src), - "sigil.devex.source": sourceTagFor(src), - "sigil.devex.scenario": scenarioFor(src, turn), - "sigil.devex.mode": string(mode), - }, - metadata: map[string]any{ - "turn_index": turn, - "conversation_slot": slot, - "agent_persona": agentPersona, - "emitter": "sdk-traffic", - "provider_shape": providerShapeFor(src, turn), - }, - } -} - -func scenarioFor(src source, turn int) string { - switch src { - case sourceOpenAI: - if turn%2 == 0 { - return "planning_brief" - } - return "status_stream" - case sourceAnthropic: - if turn%2 == 0 { - return "reasoning_digest" - } - return "delta_stream" - case sourceGemini: - if turn%2 == 0 { - return "launch_note" - } - return "checklist_stream" - default: - if turn%2 == 0 { - return "custom_sync" - } - return "custom_stream" - } -} - -func providerShapeFor(src source, turn int) string { - switch src { - case sourceOpenAI: - if openAIUsesResponses(turn) { - return "openai_responses" - } - return "openai_chat_completions" - case sourceAnthropic: - return "messages" - case sourceGemini: - return "generate_content" - default: - return "core_generation" - } -} - -func openAIUsesResponses(turn int) bool { - return turn%2 != 0 -} - -func personaForTurn(turn int) string { - personas := []string{"planner", "retriever", "executor"} - return personas[turn%len(personas)] -} - -func newConversationID(language, provider string, slot int) string { - return fmt.Sprintf("devex-%s-%s-%d-%d", language, provider, slot, time.Now().UnixMilli()) -} - -func loadConfig() runtimeConfig { - return runtimeConfig{ - interval: time.Duration(intFromEnv("SIGIL_TRAFFIC_INTERVAL_MS", 2000)) * time.Millisecond, - streamPercent: intFromEnv("SIGIL_TRAFFIC_STREAM_PERCENT", 30), - conversations: intFromEnv("SIGIL_TRAFFIC_CONVERSATIONS", 3), - rotateTurns: intFromEnv("SIGIL_TRAFFIC_ROTATE_TURNS", 24), - maxCycles: intFromEnv("SIGIL_TRAFFIC_MAX_CYCLES", 0), - customProvider: strings.TrimSpace(stringFromEnv("SIGIL_TRAFFIC_CUSTOM_PROVIDER", "mistral")), - genGRPC: stringFromEnv("SIGIL_TRAFFIC_GEN_GRPC_ENDPOINT", "sigil:4317"), - traceGRPC: stringFromEnv("SIGIL_TRAFFIC_TRACE_GRPC_ENDPOINT", "alloy:4317"), - } -} - -func configureTelemetry(ctx context.Context, cfg runtimeConfig) (func(context.Context) error, error) { - telemetryEndpoint := strings.TrimSpace(cfg.traceGRPC) - if telemetryEndpoint == "" { - return func(context.Context) error { return nil }, nil - } - - traceExporter, err := otlptracegrpc.New( - ctx, - otlptracegrpc.WithEndpoint(telemetryEndpoint), - otlptracegrpc.WithInsecure(), - ) - if err != nil { - return nil, fmt.Errorf("init otlp trace exporter: %w", err) - } - - metricExporter, err := otlpmetricgrpc.New( - ctx, - otlpmetricgrpc.WithEndpoint(telemetryEndpoint), - otlpmetricgrpc.WithInsecure(), - ) - if err != nil { - return nil, fmt.Errorf("init otlp metric exporter: %w", err) - } - - metricReader := sdkmetric.NewPeriodicReader(metricExporter, sdkmetric.WithInterval(metricFlushInterval)) - tracerProvider, meterProvider := installTelemetryProviders(traceExporter, metricReader) - return func(ctx context.Context) error { - metricErr := meterProvider.Shutdown(ctx) - traceErr := tracerProvider.Shutdown(ctx) - return errors.Join(metricErr, traceErr) - }, nil -} - -func installTelemetryProviders( - traceExporter sdktrace.SpanExporter, - metricReader sdkmetric.Reader, -) (*sdktrace.TracerProvider, *sdkmetric.MeterProvider) { - res := resource.NewSchemaless( - attribute.String("service.name", traceServiceName), - attribute.String("service.namespace", traceServiceEnv), - attribute.String("sigil.devex.language", languageName), - ) - - tracerProvider := sdktrace.NewTracerProvider( - sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.AlwaysSample())), - sdktrace.WithBatcher(traceExporter), - sdktrace.WithResource(res), - ) - meterProvider := sdkmetric.NewMeterProvider( - sdkmetric.WithReader(metricReader), - sdkmetric.WithResource(res), - ) - - otel.SetTracerProvider(tracerProvider) - otel.SetMeterProvider(meterProvider) - - return tracerProvider, meterProvider -} - -func intFromEnv(key string, defaultValue int) int { - value := strings.TrimSpace(os.Getenv(key)) - if value == "" { - return defaultValue - } - parsed, err := strconv.Atoi(value) - if err != nil || parsed <= 0 { - return defaultValue - } - return parsed -} - -func stringFromEnv(key, defaultValue string) string { - value := strings.TrimSpace(os.Getenv(key)) - if value == "" { - return defaultValue - } - return value -} diff --git a/go/cmd/devex-emitter/main_test.go b/go/cmd/devex-emitter/main_test.go deleted file mode 100644 index 7b8c27d..0000000 --- a/go/cmd/devex-emitter/main_test.go +++ /dev/null @@ -1,210 +0,0 @@ -package main - -import ( - "context" - "math/rand" - "testing" - "time" - - "github.com/grafana/sigil/sdks/go/sigil" - "go.opentelemetry.io/otel" - sdkmetric "go.opentelemetry.io/otel/sdk/metric" - "go.opentelemetry.io/otel/sdk/trace/tracetest" -) - -func TestBuildTagEnvelopeIncludesRequiredContractFields(t *testing.T) { - envelope := buildTagEnvelope(sourceOpenAI, sigil.GenerationModeSync, 2, 1) - - if envelope.tags["sigil.devex.language"] != languageName { - t.Fatalf("expected language tag %q, got %q", languageName, envelope.tags["sigil.devex.language"]) - } - if envelope.tags["sigil.devex.provider"] != "openai" { - t.Fatalf("expected provider tag openai, got %q", envelope.tags["sigil.devex.provider"]) - } - if envelope.tags["sigil.devex.source"] != "provider_wrapper" { - t.Fatalf("expected source tag provider_wrapper, got %q", envelope.tags["sigil.devex.source"]) - } - if envelope.tags["sigil.devex.mode"] != "SYNC" { - t.Fatalf("expected mode tag SYNC, got %q", envelope.tags["sigil.devex.mode"]) - } - - if envelope.metadata["turn_index"] != 2 { - t.Fatalf("expected turn_index metadata 2, got %#v", envelope.metadata["turn_index"]) - } - if envelope.metadata["conversation_slot"] != 1 { - t.Fatalf("expected conversation_slot metadata 1, got %#v", envelope.metadata["conversation_slot"]) - } - if envelope.metadata["emitter"] != "sdk-traffic" { - t.Fatalf("expected emitter metadata sdk-traffic, got %#v", envelope.metadata["emitter"]) - } - if envelope.metadata["provider_shape"] != "openai_chat_completions" { - t.Fatalf("expected provider_shape openai_chat_completions, got %#v", envelope.metadata["provider_shape"]) - } - if envelope.agentPersona == "" { - t.Fatalf("expected non-empty agent persona") - } -} - -func TestBuildTagEnvelopeOpenAIAlternatesProviderShape(t *testing.T) { - chatEnvelope := buildTagEnvelope(sourceOpenAI, sigil.GenerationModeSync, 0, 0) - if chatEnvelope.metadata["provider_shape"] != "openai_chat_completions" { - t.Fatalf("expected openai_chat_completions, got %#v", chatEnvelope.metadata["provider_shape"]) - } - - responsesEnvelope := buildTagEnvelope(sourceOpenAI, sigil.GenerationModeSync, 1, 0) - if responsesEnvelope.metadata["provider_shape"] != "openai_responses" { - t.Fatalf("expected openai_responses, got %#v", responsesEnvelope.metadata["provider_shape"]) - } -} - -func TestSourceTagForCustomProvider(t *testing.T) { - if got := sourceTagFor(sourceCustom); got != "core_custom" { - t.Fatalf("expected core_custom, got %q", got) - } - if got := sourceTagFor(sourceGemini); got != "provider_wrapper" { - t.Fatalf("expected provider_wrapper, got %q", got) - } -} - -func TestChooseModeUsesThreshold(t *testing.T) { - if got := chooseMode(10, 30); got != sigil.GenerationModeStream { - t.Fatalf("expected STREAM, got %s", got) - } - if got := chooseMode(30, 30); got != sigil.GenerationModeSync { - t.Fatalf("expected SYNC, got %s", got) - } -} - -func TestEnsureThreadRotatesConversationAtThreshold(t *testing.T) { - thread := &threadState{} - ensureThread(thread, 3, sourceOpenAI, 0) - if thread.turn != 0 { - t.Fatalf("expected initial turn 0, got %d", thread.turn) - } - if thread.conversationID == "" { - t.Fatalf("expected conversation id to be set") - } - - firstID := thread.conversationID - thread.turn = 3 - // newConversationID uses Unix millis, so ensure the timestamp can advance. - time.Sleep(2 * time.Millisecond) - ensureThread(thread, 3, sourceOpenAI, 0) - if thread.turn != 0 { - t.Fatalf("expected rotated turn 0, got %d", thread.turn) - } - if thread.conversationID == firstID { - t.Fatalf("expected rotated conversation id to change") - } -} - -func TestLoadConfigReadsTraceGRPCEndpoint(t *testing.T) { - t.Setenv("SIGIL_TRAFFIC_TRACE_GRPC_ENDPOINT", "collector:14317") - - cfg := loadConfig() - - if cfg.traceGRPC != "collector:14317" { - t.Fatalf("expected trace GRPC endpoint collector:14317, got %q", cfg.traceGRPC) - } -} - -func TestInstallTelemetryProvidersExportsSpans(t *testing.T) { - previousProvider := otel.GetTracerProvider() - previousMeterProvider := otel.GetMeterProvider() - exporter := tracetest.NewInMemoryExporter() - tracerProvider, meterProvider := installTelemetryProviders(exporter, sdkmetric.NewManualReader()) - t.Cleanup(func() { - otel.SetTracerProvider(previousProvider) - otel.SetMeterProvider(previousMeterProvider) - _ = tracerProvider.Shutdown(context.Background()) - _ = meterProvider.Shutdown(context.Background()) - }) - - _, span := otel.Tracer("devex-emitter-test").Start(context.Background(), "synthetic-span") - span.End() - if err := tracerProvider.ForceFlush(context.Background()); err != nil { - t.Fatalf("force flush: %v", err) - } - - spans := exporter.GetSpans() - if len(spans) != 1 { - t.Fatalf("expected 1 exported span, got %d", len(spans)) - } - - serviceName := "" - for _, kv := range spans[0].Resource.Attributes() { - if string(kv.Key) == "service.name" { - serviceName = kv.Value.AsString() - break - } - } - if serviceName == "" { - t.Fatalf("expected service.name resource attribute") - } - if serviceName != traceServiceName { - t.Fatalf("expected service.name %q, got %q", traceServiceName, serviceName) - } -} - -func TestEmitSyntheticLifecycleSpansProducesTraceRichSpanCount(t *testing.T) { - previousProvider := otel.GetTracerProvider() - previousMeterProvider := otel.GetMeterProvider() - exporter := tracetest.NewInMemoryExporter() - tracerProvider, meterProvider := installTelemetryProviders(exporter, sdkmetric.NewManualReader()) - t.Cleanup(func() { - otel.SetTracerProvider(previousProvider) - otel.SetMeterProvider(previousMeterProvider) - _ = tracerProvider.Shutdown(context.Background()) - _ = meterProvider.Shutdown(context.Background()) - }) - - ctx, root := otel.Tracer("devex-emitter-test").Start(context.Background(), "root") - syntheticCount := emitSyntheticLifecycleSpans(ctx, rand.New(rand.NewSource(42))) - root.End() - if err := tracerProvider.ForceFlush(context.Background()); err != nil { - t.Fatalf("force flush: %v", err) - } - - if syntheticCount < minSyntheticSpans || syntheticCount > maxSyntheticSpans { - t.Fatalf("expected synthetic count in [%d,%d], got %d", minSyntheticSpans, maxSyntheticSpans, syntheticCount) - } - - spans := exporter.GetSpans() - if len(spans) != syntheticCount+1 { - t.Fatalf("expected root + synthetic spans (%d), got %d", syntheticCount+1, len(spans)) - } - - syntheticSeen := 0 - for _, span := range spans { - if span.Name == "root" { - continue - } - syntheticSeen++ - foundCategory := false - var simulatedDurationMs int64 = -1 - for _, kv := range span.Attributes { - if string(kv.Key) == "sigil.synthetic.category" && kv.Value.AsString() != "" { - foundCategory = true - } - if string(kv.Key) == "sigil.synthetic.simulated_duration_ms" { - simulatedDurationMs = kv.Value.AsInt64() - } - } - if !span.EndTime.After(span.StartTime) { - t.Fatalf("expected synthetic span %q to have end time after start time", span.Name) - } - if !foundCategory { - t.Fatalf("expected synthetic span %q to include sigil.synthetic.category attribute", span.Name) - } - if simulatedDurationMs <= 0 { - t.Fatalf("expected synthetic span %q to include positive simulated duration attribute", span.Name) - } - actualDurationMs := span.EndTime.Sub(span.StartTime).Milliseconds() - if actualDurationMs != simulatedDurationMs { - t.Fatalf("expected synthetic span %q duration to match simulated duration: actual=%dms simulated=%dms", span.Name, actualDurationMs, simulatedDurationMs) - } - } - if syntheticSeen != syntheticCount { - t.Fatalf("expected %d synthetic spans, saw %d", syntheticCount, syntheticSeen) - } -} diff --git a/go/cmd/devex-emitter/telemetry_test.go b/go/cmd/devex-emitter/telemetry_test.go deleted file mode 100644 index d7941b0..0000000 --- a/go/cmd/devex-emitter/telemetry_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package main - -import ( - "context" - "testing" - - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/metric" - sdkmetric "go.opentelemetry.io/otel/sdk/metric" - "go.opentelemetry.io/otel/sdk/metric/metricdata" - sdktrace "go.opentelemetry.io/otel/sdk/trace" - "go.opentelemetry.io/otel/sdk/trace/tracetest" -) - -func TestInstallTelemetryProvidersSetsTracerAndMeterProviders(t *testing.T) { - previousTracerProvider := otel.GetTracerProvider() - previousMeterProvider := otel.GetMeterProvider() - defer func() { - otel.SetTracerProvider(previousTracerProvider) - otel.SetMeterProvider(previousMeterProvider) - }() - - traceExporter := tracetest.NewInMemoryExporter() - metricReader := sdkmetric.NewManualReader() - - tracerProvider, meterProvider := installTelemetryProviders(traceExporter, metricReader) - t.Cleanup(func() { - _ = tracerProvider.Shutdown(context.Background()) - _ = meterProvider.Shutdown(context.Background()) - }) - - _, span := otel.Tracer("sigil/devex-telemetry-test").Start(context.Background(), "test-span") - span.End() - if err := tracerProvider.ForceFlush(context.Background()); err != nil { - t.Fatalf("force flush tracer provider: %v", err) - } - - if got := len(traceExporter.GetSpans()); got == 0 { - t.Fatalf("expected at least one span to be exported, got %d", got) - } - - counter, err := otel.Meter("sigil/devex-telemetry-test").Int64Counter("devex_telemetry_counter") - if err != nil { - t.Fatalf("create counter: %v", err) - } - counter.Add(context.Background(), 1, metric.WithAttributes(attribute.String("source", "test"))) - - var collected metricdata.ResourceMetrics - if err := metricReader.Collect(context.Background(), &collected); err != nil { - t.Fatalf("collect metrics: %v", err) - } - - foundCounter := false - for _, scopeMetrics := range collected.ScopeMetrics { - for _, metric := range scopeMetrics.Metrics { - if metric.Name == "devex_telemetry_counter" { - foundCounter = true - break - } - } - } - if !foundCounter { - t.Fatal("expected devex_telemetry_counter metric to be collected") - } -} - -var _ sdktrace.SpanExporter = (*tracetest.InMemoryExporter)(nil) diff --git a/go/cmd/devex-emitter/ttft_test.go b/go/cmd/devex-emitter/ttft_test.go deleted file mode 100644 index 92563d8..0000000 --- a/go/cmd/devex-emitter/ttft_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package main - -import ( - "context" - "math/rand" - "testing" - - "github.com/grafana/sigil/sdks/go/sigil" - "go.opentelemetry.io/otel" - sdkmetric "go.opentelemetry.io/otel/sdk/metric" - "go.opentelemetry.io/otel/sdk/metric/metricdata" - "go.opentelemetry.io/otel/sdk/trace/tracetest" -) - -func TestStreamEmittersRecordTTFTMetric(t *testing.T) { - previousTracerProvider := otel.GetTracerProvider() - previousMeterProvider := otel.GetMeterProvider() - metricReader := sdkmetric.NewManualReader() - traceExporter := tracetest.NewInMemoryExporter() - tracerProvider, meterProvider := installTelemetryProviders(traceExporter, metricReader) - t.Cleanup(func() { - otel.SetTracerProvider(previousTracerProvider) - otel.SetMeterProvider(previousMeterProvider) - _ = tracerProvider.Shutdown(context.Background()) - _ = meterProvider.Shutdown(context.Background()) - }) - - clientCfg := sigil.DefaultConfig() - clientCfg.GenerationExport.Protocol = sigil.GenerationExportProtocolNone - clientCfg.GenerationExport.Auth = sigil.AuthConfig{Mode: sigil.ExportAuthModeNone} - client := sigil.NewClient(clientCfg) - t.Cleanup(func() { - _ = client.Shutdown(context.Background()) - }) - - tags := map[string]string{"sigil.devex.test": "true"} - metadata := map[string]any{"conversation_slot": 0} - - if err := emitOpenAIChatCompletionsStream(context.Background(), client, "conv-openai-chat", "agent-openai-chat", "v1", tags, metadata, 1); err != nil { - t.Fatalf("emit openai chat stream: %v", err) - } - if err := emitOpenAIResponsesStream(context.Background(), client, "conv-openai-responses", "agent-openai-responses", "v1", tags, metadata, 2); err != nil { - t.Fatalf("emit openai responses stream: %v", err) - } - if err := emitAnthropicStream(context.Background(), client, "conv-anthropic", "agent-anthropic", "v1", tags, metadata, 3); err != nil { - t.Fatalf("emit anthropic stream: %v", err) - } - if err := emitGeminiStream(context.Background(), client, "conv-gemini", "agent-gemini", "v1", tags, metadata, 4); err != nil { - t.Fatalf("emit gemini stream: %v", err) - } - if err := emitCustomStream( - context.Background(), - client, - "mistral", - "conv-custom", - "agent-custom", - "v1", - tags, - metadata, - 5, - rand.New(rand.NewSource(42)), - ); err != nil { - t.Fatalf("emit custom stream: %v", err) - } - - if err := client.Flush(context.Background()); err != nil { - t.Fatalf("flush client: %v", err) - } - - var collected metricdata.ResourceMetrics - if err := metricReader.Collect(context.Background(), &collected); err != nil { - t.Fatalf("collect metrics: %v", err) - } - - ttftCount := histogramCount(collected, "gen_ai.client.time_to_first_token") - if ttftCount < 5 { - t.Fatalf("expected TTFT histogram count >= 5 from stream emitters, got %d", ttftCount) - } -} - -func histogramCount(collected metricdata.ResourceMetrics, metricName string) uint64 { - var total uint64 - for _, scopeMetrics := range collected.ScopeMetrics { - for _, m := range scopeMetrics.Metrics { - if m.Name != metricName { - continue - } - histogram, ok := m.Data.(metricdata.Histogram[float64]) - if !ok { - continue - } - for _, point := range histogram.DataPoints { - total += point.Count - } - } - } - return total -} diff --git a/go/cmd/sigil-probe/main.go b/go/cmd/sigil-probe/main.go new file mode 100644 index 0000000..13bcb24 --- /dev/null +++ b/go/cmd/sigil-probe/main.go @@ -0,0 +1,559 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "errors" + "flag" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/grafana/sigil-sdk/go/sigil" +) + +const ( + defaultEndpoint = "sigil-dev-001.grafana-dev.net:443" + defaultTenantID = "4130" + defaultUserID = "4130" + defaultTokenEnv = "GRAFANA_ASSISTANT_ACCESS_TOKEN" + defaultDotEnv = ".env" +) + +func main() { + if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func run(args []string, stdout, stderr io.Writer) error { + fs := flag.NewFlagSet("sigil-probe", flag.ContinueOnError) + fs.SetOutput(stderr) + + var endpoint string + var tenantID string + var userID string + var token string + var tokenEnv string + var dotEnvPath string + var verifyRead bool + var readBaseURL string + var readPollInterval time.Duration + var timeout time.Duration + var insecure bool + var verbose bool + + fs.StringVar(&endpoint, "endpoint", defaultEndpoint, "gRPC endpoint host:port") + fs.StringVar(&tenantID, "tenant", defaultTenantID, "tenant value sent as X-Scope-OrgID metadata") + fs.StringVar(&userID, "user", defaultUserID, "basic auth username") + fs.StringVar(&token, "token", "", "basic auth password/token value (overrides env/.env)") + fs.StringVar(&tokenEnv, "token-env", defaultTokenEnv, "environment variable name that stores basic auth password/token") + fs.StringVar(&dotEnvPath, "dotenv", defaultDotEnv, "path to .env file used as fallback when env var is empty") + fs.BoolVar(&verifyRead, "verify-read", true, "verify read path by fetching /api/v1/generations/{id} for the HTTP push generation") + fs.StringVar(&readBaseURL, "read-base-url", "", "HTTP API base URL (default derived from endpoint)") + fs.DurationVar(&readPollInterval, "read-poll-interval", 500*time.Millisecond, "poll interval while waiting for read visibility") + fs.DurationVar(&timeout, "timeout", 20*time.Second, "overall timeout for probe") + fs.BoolVar(&insecure, "insecure", false, "use insecure plaintext gRPC transport (local/dev)") + fs.BoolVar(&verbose, "verbose", false, "print SDK logs") + + if err := fs.Parse(args); err != nil { + return err + } + if strings.TrimSpace(endpoint) == "" { + return errors.New("endpoint is required") + } + if strings.TrimSpace(tenantID) == "" { + return errors.New("tenant is required") + } + if strings.TrimSpace(tokenEnv) == "" { + return errors.New("token-env is required") + } + if strings.TrimSpace(userID) == "" { + return errors.New("user is required") + } + if timeout <= 0 { + return errors.New("timeout must be > 0") + } + if verifyRead && readPollInterval <= 0 { + return errors.New("read-poll-interval must be > 0") + } + + resolvedToken, tokenSource, err := resolveToken(token, tokenEnv, dotEnvPath) + if err != nil { + return err + } + + apiBaseURL, err := resolveReadBaseURL(endpoint, readBaseURL, insecure) + if err != nil { + return err + } + httpExportEndpoint := apiBaseURL + "/api/v1/generations:export" + + now := time.Now().UTC() + grpcGenerationID := fmt.Sprintf("grpc-probe-%d", now.UnixNano()) + httpGenerationID := fmt.Sprintf("http-probe-%d", now.UnixNano()+1) + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + results := make([]probeStepResult, 0, 4) + + grpcErr := pushGeneration( + ctx, + pushGenerationOptions{ + Protocol: sigil.GenerationExportProtocolGRPC, + Endpoint: endpoint, + Insecure: insecure, + UserID: strings.TrimSpace(userID), + Token: resolvedToken, + TenantID: strings.TrimSpace(tenantID), + GenerationID: grpcGenerationID, + AgentName: "sigil-probe-grpc", + ProviderName: "grpc-connectivity", + Verbose: verbose, + Stderr: stderr, + }, + ) + results = appendResult(results, "grpc_push", grpcGenerationID, grpcErr) + + httpErr := pushGeneration( + ctx, + pushGenerationOptions{ + Protocol: sigil.GenerationExportProtocolHTTP, + Endpoint: httpExportEndpoint, + Insecure: insecure, + UserID: strings.TrimSpace(userID), + Token: resolvedToken, + TenantID: strings.TrimSpace(tenantID), + GenerationID: httpGenerationID, + AgentName: "sigil-probe-http", + ProviderName: "http-connectivity", + Verbose: verbose, + Stderr: stderr, + }, + ) + results = appendResult(results, "http_push", httpGenerationID, httpErr) + + if verifyRead { + if httpErr != nil { + results = appendSkipResult(results, "http_get", httpGenerationID, "skipped because http_push failed") + } else { + readErr := verifyGenerationReadable( + ctx, + apiBaseURL, + strings.TrimSpace(userID), + resolvedToken, + strings.TrimSpace(tenantID), + httpGenerationID, + readPollInterval, + ) + results = appendResult(results, "http_get", httpGenerationID, readErr) + } + } else { + results = appendSkipResult(results, "http_get", httpGenerationID, "disabled by -verify-read=false") + } + + if _, err := fmt.Fprintf( + stdout, + "endpoint=%s\napi_base_url=%s\ntenant=%s\nuser=%s\nauth_mode=basic\ntoken_source=%s\n\n", + endpoint, + apiBaseURL, + strings.TrimSpace(tenantID), + strings.TrimSpace(userID), + tokenSource, + ); err != nil { + return fmt.Errorf("write probe header: %w", err) + } + if err := renderResultsTable(stdout, results); err != nil { + return fmt.Errorf("write results table: %w", err) + } + + failures := countFailedResults(results) + if failures > 0 { + return fmt.Errorf("%d probe step(s) failed", failures) + } + return nil +} + +type probeStepStatus string + +const ( + probeStatusOK probeStepStatus = "ok" + probeStatusFail probeStepStatus = "fail" + probeStatusSkip probeStepStatus = "skip" +) + +type probeStepResult struct { + Step string + Status probeStepStatus + GenerationID string + Detail string +} + +type pushGenerationOptions struct { + Protocol sigil.GenerationExportProtocol + Endpoint string + Insecure bool + UserID string + Token string + TenantID string + GenerationID string + AgentName string + ProviderName string + Verbose bool + Stderr io.Writer +} + +func pushGeneration(ctx context.Context, options pushGenerationOptions) error { + logBuffer := &bytes.Buffer{} + loggerOut := io.Writer(logBuffer) + if options.Verbose { + loggerOut = io.MultiWriter(options.Stderr, logBuffer) + } + + cfg := sigil.DefaultConfig() + cfg.GenerationExport.Protocol = options.Protocol + cfg.GenerationExport.Endpoint = options.Endpoint + cfg.GenerationExport.Insecure = options.Insecure + cfg.GenerationExport.BatchSize = 1 + cfg.GenerationExport.FlushInterval = time.Hour + cfg.GenerationExport.Auth = sigil.AuthConfig{Mode: sigil.ExportAuthModeNone} + cfg.GenerationExport.Headers = map[string]string{ + "X-Scope-OrgID": options.TenantID, + "Authorization": formatBasicAuth(options.UserID, options.Token), + } + cfg.Logger = log.New(loggerOut, "", log.LstdFlags) + + client := sigil.NewClient(cfg) + shutdownDone := false + defer func() { + if !shutdownDone { + _ = client.Shutdown(context.Background()) + } + }() + + conversationID := fmt.Sprintf("%s-conv", options.GenerationID) + _, recorder := client.StartGeneration(ctx, sigil.GenerationStart{ + ID: options.GenerationID, + ConversationID: conversationID, + AgentName: options.AgentName, + AgentVersion: "0.1.0", + Model: sigil.ModelRef{ + Provider: "probe", + Name: options.ProviderName, + }, + Mode: sigil.GenerationModeSync, + Tags: map[string]string{ + "probe": "sigil-probe", + }, + }) + recorder.SetResult(sigil.Generation{ + Input: []sigil.Message{sigil.UserTextMessage("ping")}, + Output: []sigil.Message{sigil.AssistantTextMessage("pong")}, + }, nil) + recorder.End() + if err := recorder.Err(); err != nil { + return fmt.Errorf("local generation record failed: %w", err) + } + + if err := client.Flush(ctx); err != nil { + return fmt.Errorf("export failed: %w", err) + } + if err := client.Shutdown(ctx); err != nil { + return fmt.Errorf("shutdown failed: %w", err) + } + shutdownDone = true + + if rejectionErr := findRejectionForGeneration(logBuffer.String(), options.GenerationID); rejectionErr != "" { + return fmt.Errorf("server rejected generation %s: %s", options.GenerationID, rejectionErr) + } + return nil +} + +func appendResult(results []probeStepResult, step, generationID string, err error) []probeStepResult { + if err != nil { + return append(results, probeStepResult{ + Step: step, + Status: probeStatusFail, + GenerationID: generationID, + Detail: err.Error(), + }) + } + return append(results, probeStepResult{ + Step: step, + Status: probeStatusOK, + GenerationID: generationID, + Detail: "success", + }) +} + +func appendSkipResult(results []probeStepResult, step, generationID, detail string) []probeStepResult { + return append(results, probeStepResult{ + Step: step, + Status: probeStatusSkip, + GenerationID: generationID, + Detail: detail, + }) +} + +func renderResultsTable(out io.Writer, results []probeStepResult) error { + if _, err := fmt.Fprintln(out, "| step | status | generation_id | detail |"); err != nil { + return err + } + if _, err := fmt.Fprintln(out, "| --- | --- | --- | --- |"); err != nil { + return err + } + for _, result := range results { + if _, err := fmt.Fprintf( + out, + "| %s | %s | %s | %s |\n", + tableCell(result.Step), + tableCell(string(result.Status)), + tableCell(result.GenerationID), + tableCell(result.Detail), + ); err != nil { + return err + } + } + return nil +} + +func tableCell(input string) string { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "-" + } + replaced := strings.ReplaceAll(trimmed, "|", "\\|") + replaced = strings.ReplaceAll(replaced, "\n", " ") + replaced = strings.ReplaceAll(replaced, "\r", " ") + const maxLen = 180 + if len(replaced) > maxLen { + return replaced[:maxLen-3] + "..." + } + return replaced +} + +func countFailedResults(results []probeStepResult) int { + count := 0 + for _, result := range results { + if result.Status == probeStatusFail { + count++ + } + } + return count +} + +func resolveToken(explicitToken, tokenEnv, dotEnvPath string) (token string, source string, err error) { + if trimmed := strings.TrimSpace(explicitToken); trimmed != "" { + return trimmed, "flag:-token", nil + } + + if trimmed := strings.TrimSpace(os.Getenv(tokenEnv)); trimmed != "" { + return trimmed, "env:" + tokenEnv, nil + } + + for _, path := range candidateDotEnvPaths(dotEnvPath) { + value, readErr := readDotEnvValue(path, tokenEnv) + if readErr != nil { + if errors.Is(readErr, os.ErrNotExist) { + continue + } + return "", "", fmt.Errorf("read %s: %w", path, readErr) + } + if strings.TrimSpace(value) == "" { + continue + } + return strings.TrimSpace(value), "dotenv:" + path, nil + } + + return "", "", fmt.Errorf("%s is empty; pass -token, export %s, or set it in %s", tokenEnv, tokenEnv, dotEnvPath) +} + +func candidateDotEnvPaths(path string) []string { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return nil + } + if filepath.IsAbs(trimmed) { + return []string{trimmed} + } + return []string{ + trimmed, + filepath.Join("..", trimmed), + filepath.Join("..", "..", trimmed), + } +} + +func readDotEnvValue(path, key string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer func() { + _ = file.Close() + }() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "export ") { + line = strings.TrimSpace(strings.TrimPrefix(line, "export ")) + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + currentKey := strings.TrimSpace(parts[0]) + if currentKey != key { + continue + } + return normalizeEnvValue(parts[1]), nil + } + if err := scanner.Err(); err != nil { + return "", err + } + return "", nil +} + +func normalizeEnvValue(raw string) string { + value := strings.TrimSpace(raw) + if value == "" { + return "" + } + + if strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"") && len(value) >= 2 { + unquoted, err := strconv.Unquote(value) + if err == nil { + return strings.TrimSpace(unquoted) + } + } + if strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'") && len(value) >= 2 { + return strings.TrimSpace(value[1 : len(value)-1]) + } + + if idx := strings.Index(value, " #"); idx >= 0 { + value = strings.TrimSpace(value[:idx]) + } + return value +} + +func formatBasicAuth(user, token string) string { + credentials := user + ":" + strings.TrimSpace(token) + return "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials)) +} + +func resolveReadBaseURL(grpcEndpoint, readBaseURL string, insecure bool) (string, error) { + trimmed := strings.TrimSpace(readBaseURL) + if trimmed != "" { + if !strings.HasPrefix(trimmed, "http://") && !strings.HasPrefix(trimmed, "https://") { + return "", fmt.Errorf("read-base-url %q must include http:// or https://", readBaseURL) + } + return strings.TrimRight(trimmed, "/"), nil + } + + host := strings.TrimSpace(grpcEndpoint) + if host == "" { + return "", errors.New("endpoint is required to derive read base url") + } + endpointScheme := "" + if strings.Contains(host, "://") { + parsed, err := url.Parse(host) + if err != nil { + return "", fmt.Errorf("parse endpoint %q: %w", grpcEndpoint, err) + } + endpointScheme = parsed.Scheme + host = parsed.Host + } + + host = strings.TrimSuffix(host, ":443") + host = strings.TrimSuffix(host, ":80") + + scheme := "https" + if insecure || endpointScheme == "http" { + scheme = "http" + } + return scheme + "://" + host, nil +} + +func verifyGenerationReadable( + ctx context.Context, + baseURL string, + user string, + token string, + tenantID string, + generationID string, + pollInterval time.Duration, +) error { + trimmedBaseURL := strings.TrimRight(strings.TrimSpace(baseURL), "/") + if trimmedBaseURL == "" { + return errors.New("read base url is required") + } + readURL := trimmedBaseURL + "/api/v1/generations/" + url.PathEscape(strings.TrimSpace(generationID)) + httpClient := &http.Client{Timeout: 10 * time.Second} + + for { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, readURL, nil) + if err != nil { + return fmt.Errorf("build read request: %w", err) + } + req.SetBasicAuth(user, strings.TrimSpace(token)) + req.Header.Set("X-Scope-OrgID", strings.TrimSpace(tenantID)) + + resp, err := httpClient.Do(req) + if err != nil { + if ctx.Err() != nil { + return fmt.Errorf("read verification timeout: %w", ctx.Err()) + } + return fmt.Errorf("execute read request: %w", err) + } + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096)) + _ = resp.Body.Close() + if readErr != nil { + return fmt.Errorf("read response body: %w", readErr) + } + + switch resp.StatusCode { + case http.StatusOK: + return nil + case http.StatusNotFound, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: + select { + case <-ctx.Done(): + return fmt.Errorf("read verification timeout waiting for generation %s (last status=%d body=%s)", generationID, resp.StatusCode, strings.TrimSpace(string(body))) + case <-time.After(pollInterval): + } + continue + default: + return fmt.Errorf("read verification failed status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body))) + } + } +} + +func findRejectionForGeneration(logOutput, generationID string) string { + for _, line := range strings.Split(logOutput, "\n") { + if !strings.Contains(line, "sigil generation rejected") { + continue + } + if !strings.Contains(line, "id="+generationID) { + continue + } + errorIndex := strings.Index(line, "error=") + if errorIndex < 0 { + return strings.TrimSpace(line) + } + return strings.TrimSpace(line[errorIndex+len("error="):]) + } + return "" +} diff --git a/go/cmd/sigil-probe/main_test.go b/go/cmd/sigil-probe/main_test.go new file mode 100644 index 0000000..1e042b4 --- /dev/null +++ b/go/cmd/sigil-probe/main_test.go @@ -0,0 +1,199 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunRejectsZeroTimeout(t *testing.T) { + testCases := []struct { + name string + timeout string + }{ + {name: "zero timeout", timeout: "0s"}, + {name: "negative timeout", timeout: "-1s"}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := run( + []string{"-endpoint", "localhost:4317", "-token", "tok", "-timeout", testCase.timeout}, + &stdout, + &stderr, + ) + if err == nil { + t.Fatal("expected timeout validation error, got nil") + } + if !strings.Contains(err.Error(), "timeout must be > 0") { + t.Fatalf("expected timeout validation error, got %v", err) + } + }) + } +} + +func TestReadDotEnvValue(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + content := "" + + "# ignored\n" + + "PLAIN=value-a\n" + + "QUOTED=\"value b\"\n" + + "EXPORTED='value-c'\n" + + "export TOKEN=token-from-dotenv # trailing\n" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write .env: %v", err) + } + + testCases := []struct { + name string + key string + want string + }{ + {name: "plain", key: "PLAIN", want: "value-a"}, + {name: "double quoted", key: "QUOTED", want: "value b"}, + {name: "single quoted", key: "EXPORTED", want: "value-c"}, + {name: "export syntax with comment", key: "TOKEN", want: "token-from-dotenv"}, + {name: "missing", key: "UNKNOWN", want: ""}, + } + + for _, testCase := range testCases { + got, err := readDotEnvValue(path, testCase.key) + if err != nil { + t.Fatalf("%s: readDotEnvValue returned error: %v", testCase.name, err) + } + if got != testCase.want { + t.Fatalf("%s: expected %q, got %q", testCase.name, testCase.want, got) + } + } +} + +func TestResolveTokenPriority(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + const tokenEnv = "SIGIL_TEST_TOKEN" + if err := os.WriteFile(path, []byte(tokenEnv+"=dotenv-token\n"), 0o644); err != nil { + t.Fatalf("write .env: %v", err) + } + + t.Setenv(tokenEnv, "") + + token, source, err := resolveToken("flag-token", tokenEnv, path) + if err != nil { + t.Fatalf("resolve with flag token returned error: %v", err) + } + if token != "flag-token" || source != "flag:-token" { + t.Fatalf("expected flag token precedence, got token=%q source=%q", token, source) + } + + t.Setenv(tokenEnv, "env-token") + token, source, err = resolveToken("", tokenEnv, path) + if err != nil { + t.Fatalf("resolve with env token returned error: %v", err) + } + if token != "env-token" || source != "env:"+tokenEnv { + t.Fatalf("expected env token precedence, got token=%q source=%q", token, source) + } + + t.Setenv(tokenEnv, "") + token, source, err = resolveToken("", tokenEnv, path) + if err != nil { + t.Fatalf("resolve with dotenv token returned error: %v", err) + } + if token != "dotenv-token" || source != "dotenv:"+path { + t.Fatalf("expected dotenv token fallback, got token=%q source=%q", token, source) + } +} + +func TestFindRejectionForGeneration(t *testing.T) { + logOutput := "" + + "2026/03/02 10:00:00 sigil generation rejected id=gen-a error=invalid tenant\n" + + "2026/03/02 10:00:01 sigil generation rejected id=gen-b error=missing input\n" + + if got := findRejectionForGeneration(logOutput, "gen-a"); got != "invalid tenant" { + t.Fatalf("expected invalid tenant, got %q", got) + } + if got := findRejectionForGeneration(logOutput, "gen-b"); got != "missing input" { + t.Fatalf("expected missing input, got %q", got) + } + if got := findRejectionForGeneration(logOutput, "gen-c"); got != "" { + t.Fatalf("expected empty string, got %q", got) + } +} + +func TestFormatBasicAuth(t *testing.T) { + got := formatBasicAuth("4130", "abc123") + if got != "Basic NDEzMDphYmMxMjM=" { + t.Fatalf("unexpected basic auth header: %q", got) + } +} + +func TestResolveReadBaseURL(t *testing.T) { + testCases := []struct { + name string + endpoint string + readBaseURL string + insecure bool + want string + expectErr bool + }{ + { + name: "derive from tls endpoint", + endpoint: "sigil-dev-001.grafana-dev.net:443", + insecure: false, + want: "https://sigil-dev-001.grafana-dev.net", + }, + { + name: "derive from insecure endpoint", + endpoint: "localhost:4317", + insecure: true, + want: "http://localhost:4317", + }, + { + name: "explicit base url", + endpoint: "localhost:4317", + readBaseURL: "https://example.com/", + insecure: false, + want: "https://example.com", + }, + { + name: "invalid explicit url", + endpoint: "localhost:4317", + readBaseURL: "example.com", + expectErr: true, + }, + { + name: "http scheme endpoint without insecure flag", + endpoint: "http://localhost:4317", + insecure: false, + want: "http://localhost:4317", + }, + { + name: "https scheme endpoint with insecure flag", + endpoint: "https://sigil.example.com:443", + insecure: true, + want: "http://sigil.example.com", + }, + } + + for _, testCase := range testCases { + got, err := resolveReadBaseURL(testCase.endpoint, testCase.readBaseURL, testCase.insecure) + if testCase.expectErr { + if err == nil { + t.Fatalf("%s: expected error, got nil", testCase.name) + } + continue + } + if err != nil { + t.Fatalf("%s: unexpected error: %v", testCase.name, err) + } + if got != testCase.want { + t.Fatalf("%s: expected %q, got %q", testCase.name, testCase.want, got) + } + } +} diff --git a/go/go.mod b/go/go.mod index 6840a22..1514528 100644 --- a/go/go.mod +++ b/go/go.mod @@ -1,25 +1,27 @@ -module github.com/grafana/sigil/sdks/go +module github.com/grafana/sigil-sdk/go go 1.25.6 require ( - go.opentelemetry.io/otel v1.40.0 - go.opentelemetry.io/otel/metric v1.40.0 - go.opentelemetry.io/otel/sdk v1.40.0 - go.opentelemetry.io/otel/sdk/metric v1.40.0 - go.opentelemetry.io/otel/trace v1.40.0 - google.golang.org/grpc v1.79.1 + go.opentelemetry.io/otel v1.42.0 + go.opentelemetry.io/otel/metric v1.42.0 + go.opentelemetry.io/otel/sdk v1.42.0 + go.opentelemetry.io/otel/sdk/metric v1.42.0 + go.opentelemetry.io/otel/trace v1.42.0 + google.golang.org/grpc v1.79.3 google.golang.org/protobuf v1.36.11 ) require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect ) diff --git a/go/go.sum b/go/go.sum index 43766d2..377d78e 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,7 +1,7 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -13,36 +13,36 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= -google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/go/sigil/client.go b/go/sigil/client.go index 3d0e811..bf84d05 100644 --- a/go/sigil/client.go +++ b/go/sigil/client.go @@ -47,12 +47,17 @@ const ( ExportAuthModeNone ExportAuthMode = "none" ExportAuthModeTenant ExportAuthMode = "tenant" ExportAuthModeBearer ExportAuthMode = "bearer" + ExportAuthModeBasic ExportAuthMode = "basic" ) type AuthConfig struct { Mode ExportAuthMode TenantID string BearerToken string + // BasicUser is the username for basic auth. When empty, TenantID is used. + BasicUser string + // BasicPassword is the password/token for basic auth. + BasicPassword string } type GenerationExportProtocol string @@ -64,31 +69,44 @@ const ( ) type GenerationExportConfig struct { - Protocol GenerationExportProtocol - Endpoint string - Headers map[string]string - Auth AuthConfig - Insecure bool - BatchSize int - FlushInterval time.Duration - QueueSize int - MaxRetries int - InitialBackoff time.Duration - MaxBackoff time.Duration - PayloadMaxBytes int + Protocol GenerationExportProtocol + Endpoint string + Headers map[string]string + Auth AuthConfig + Insecure bool + // GRPCMaxSendMessageBytes controls the gRPC per-message send cap used by + // the SDK generation exporter. + GRPCMaxSendMessageBytes int + // GRPCMaxReceiveMessageBytes controls the gRPC per-message receive cap used + // by the SDK generation exporter. + GRPCMaxReceiveMessageBytes int + BatchSize int + FlushInterval time.Duration + QueueSize int + MaxRetries int + InitialBackoff time.Duration + MaxBackoff time.Duration + PayloadMaxBytes int } type APIConfig struct { Endpoint string } -const instrumentationName = "github.com/grafana/sigil/sdks/go/sigil" +const instrumentationName = "github.com/grafana/sigil-sdk/go/sigil" const ( + defaultGRPCMaxSendMessageBytes = 16 << 20 + defaultGRPCMaxReceiveMessageBytes = 16 << 20 + defaultGenerationPayloadMaxBytes = 16 << 20 + sdkMetadataKeyName = "sigil.sdk.name" + metadataUserIDKey = "sigil.user.id" sdkName = "sdk-go" spanAttrGenerationID = "sigil.generation.id" spanAttrConversationID = "gen_ai.conversation.id" + spanAttrConversationTitle = "sigil.conversation.title" + spanAttrUserID = "user.id" spanAttrAgentName = "gen_ai.agent.name" spanAttrAgentVersion = "gen_ai.agent.version" spanAttrErrorType = "error.type" @@ -147,17 +165,19 @@ var ( func DefaultConfig() Config { return Config{ GenerationExport: GenerationExportConfig{ - Protocol: GenerationExportProtocolGRPC, - Endpoint: "localhost:4317", - Auth: AuthConfig{Mode: ExportAuthModeNone}, - Insecure: true, - BatchSize: 100, - FlushInterval: time.Second, - QueueSize: 2000, - MaxRetries: 5, - InitialBackoff: 100 * time.Millisecond, - MaxBackoff: 5 * time.Second, - PayloadMaxBytes: 4 << 20, + Protocol: GenerationExportProtocolGRPC, + Endpoint: "localhost:4317", + Auth: AuthConfig{Mode: ExportAuthModeNone}, + Insecure: true, + GRPCMaxSendMessageBytes: defaultGRPCMaxSendMessageBytes, + GRPCMaxReceiveMessageBytes: defaultGRPCMaxReceiveMessageBytes, + BatchSize: 100, + FlushInterval: time.Second, + QueueSize: 2000, + MaxRetries: 5, + InitialBackoff: 100 * time.Millisecond, + MaxBackoff: 5 * time.Second, + PayloadMaxBytes: defaultGenerationPayloadMaxBytes, }, API: APIConfig{ Endpoint: "http://localhost:8080", @@ -356,6 +376,9 @@ func (c *Client) startGeneration(ctx context.Context, start GenerationStart, def if c == nil { return ctx, &GenerationRecorder{} } + if ctx == nil { + ctx = context.Background() + } seed := cloneGenerationStart(start) if seed.Mode == "" { @@ -370,6 +393,16 @@ func (c *Client) startGeneration(ctx context.Context, start GenerationStart, def seed.ConversationID = id } } + if seed.ConversationTitle == "" { + if title, ok := ConversationTitleFromContext(ctx); ok { + seed.ConversationTitle = title + } + } + if seed.UserID == "" { + if userID, ok := UserIDFromContext(ctx); ok { + seed.UserID = userID + } + } if seed.AgentName == "" { if name, ok := AgentNameFromContext(ctx); ok { seed.AgentName = name @@ -390,32 +423,36 @@ func (c *Client) startGeneration(ctx context.Context, start GenerationStart, def seed.StartedAt = startedAt callCtx, span := c.startSpan(ctx, Generation{ - ID: seed.ID, - ConversationID: seed.ConversationID, - AgentName: seed.AgentName, - AgentVersion: seed.AgentVersion, - Mode: seed.Mode, - OperationName: seed.OperationName, - Model: seed.Model, - MaxTokens: cloneInt64Ptr(seed.MaxTokens), - Temperature: cloneFloat64Ptr(seed.Temperature), - TopP: cloneFloat64Ptr(seed.TopP), - ToolChoice: cloneStringPtr(seed.ToolChoice), - ThinkingEnabled: cloneBoolPtr(seed.ThinkingEnabled), + ID: seed.ID, + ConversationID: seed.ConversationID, + ConversationTitle: seed.ConversationTitle, + UserID: seed.UserID, + AgentName: seed.AgentName, + AgentVersion: seed.AgentVersion, + Mode: seed.Mode, + OperationName: seed.OperationName, + Model: seed.Model, + MaxTokens: cloneInt64Ptr(seed.MaxTokens), + Temperature: cloneFloat64Ptr(seed.Temperature), + TopP: cloneFloat64Ptr(seed.TopP), + ToolChoice: cloneStringPtr(seed.ToolChoice), + ThinkingEnabled: cloneBoolPtr(seed.ThinkingEnabled), }, trace.SpanKindClient, startedAt) span.SetAttributes(generationSpanAttributes(Generation{ - ID: seed.ID, - ConversationID: seed.ConversationID, - AgentName: seed.AgentName, - AgentVersion: seed.AgentVersion, - Mode: seed.Mode, - OperationName: seed.OperationName, - Model: seed.Model, - MaxTokens: cloneInt64Ptr(seed.MaxTokens), - Temperature: cloneFloat64Ptr(seed.Temperature), - TopP: cloneFloat64Ptr(seed.TopP), - ToolChoice: cloneStringPtr(seed.ToolChoice), - ThinkingEnabled: cloneBoolPtr(seed.ThinkingEnabled), + ID: seed.ID, + ConversationID: seed.ConversationID, + ConversationTitle: seed.ConversationTitle, + UserID: seed.UserID, + AgentName: seed.AgentName, + AgentVersion: seed.AgentVersion, + Mode: seed.Mode, + OperationName: seed.OperationName, + Model: seed.Model, + MaxTokens: cloneInt64Ptr(seed.MaxTokens), + Temperature: cloneFloat64Ptr(seed.Temperature), + TopP: cloneFloat64Ptr(seed.TopP), + ToolChoice: cloneStringPtr(seed.ToolChoice), + ThinkingEnabled: cloneBoolPtr(seed.ThinkingEnabled), })...) return callCtx, &GenerationRecorder{ @@ -485,6 +522,9 @@ func (c *Client) StartToolExecution(ctx context.Context, start ToolExecutionStar if c == nil { return ctx, &ToolExecutionRecorder{} } + if ctx == nil { + ctx = context.Background() + } seed := start seed.ToolName = strings.TrimSpace(seed.ToolName) @@ -498,6 +538,11 @@ func (c *Client) StartToolExecution(ctx context.Context, start ToolExecutionStar seed.ConversationID = id } } + if seed.ConversationTitle == "" { + if title, ok := ConversationTitleFromContext(ctx); ok { + seed.ConversationTitle = title + } + } if seed.AgentName == "" { if name, ok := AgentNameFromContext(ctx); ok { seed.AgentName = name @@ -517,7 +562,16 @@ func (c *Client) StartToolExecution(ctx context.Context, start ToolExecutionStar } seed.StartedAt = startedAt - callCtx, span := c.startSpan(ctx, Generation{OperationName: "execute_tool", Model: ModelRef{Name: seed.ToolName}}, trace.SpanKindInternal, startedAt) + tracer := c.tracer + if tracer == nil { + tracer = otel.Tracer(instrumentationName) + } + callCtx, span := tracer.Start( + ctx, + toolSpanName(seed.ToolName), + trace.WithSpanKind(trace.SpanKindInternal), + trace.WithTimestamp(startedAt), + ) attrs := toolSpanAttributes(seed) span.SetAttributes(attrs...) @@ -902,6 +956,12 @@ func (r *GenerationRecorder) normalizeGeneration(raw Generation, completedAt tim if g.ConversationID == "" { g.ConversationID = r.seed.ConversationID } + if g.ConversationTitle == "" { + g.ConversationTitle = r.seed.ConversationTitle + } + if g.UserID == "" { + g.UserID = r.seed.UserID + } if g.AgentName == "" { g.AgentName = r.seed.AgentName } @@ -952,6 +1012,25 @@ func (r *GenerationRecorder) normalizeGeneration(raw Generation, completedAt tim if g.Metadata == nil { g.Metadata = map[string]any{} } + conversationTitle := strings.TrimSpace(g.ConversationTitle) + if conversationTitle == "" { + conversationTitle = metadataString(g.Metadata, spanAttrConversationTitle) + } + g.ConversationTitle = conversationTitle + if conversationTitle != "" { + g.Metadata[spanAttrConversationTitle] = conversationTitle + } + if g.UserID == "" { + g.UserID = metadataString(g.Metadata, metadataUserIDKey) + } + if g.UserID == "" { + // Backward-compatibility with older builds that mirrored user id under the span key. + g.UserID = metadataString(g.Metadata, spanAttrUserID) + } + if userID := strings.TrimSpace(g.UserID); userID != "" { + g.UserID = userID + g.Metadata[metadataUserIDKey] = userID + } g.Metadata[sdkMetadataKeyName] = sdkName if g.StartedAt.IsZero() { @@ -1115,6 +1194,12 @@ func generationSpanAttributes(g Generation) []attribute.KeyValue { if conversationID := strings.TrimSpace(g.ConversationID); conversationID != "" { attrs = append(attrs, attribute.String(spanAttrConversationID, conversationID)) } + if conversationTitle := strings.TrimSpace(g.ConversationTitle); conversationTitle != "" { + attrs = append(attrs, attribute.String(spanAttrConversationTitle, conversationTitle)) + } + if userID := strings.TrimSpace(g.UserID); userID != "" { + attrs = append(attrs, attribute.String(spanAttrUserID, userID)) + } if agentName := strings.TrimSpace(g.AgentName); agentName != "" { attrs = append(attrs, attribute.String(spanAttrAgentName, agentName)) } @@ -1207,6 +1292,21 @@ func thinkingBudgetFromMetadata(metadata map[string]any) (int64, bool) { return coerced, true } +func metadataString(metadata map[string]any, key string) string { + if len(metadata) == 0 { + return "" + } + raw, ok := metadata[key] + if !ok { + return "" + } + asString, ok := raw.(string) + if !ok { + return "" + } + return strings.TrimSpace(asString) +} + func coerceInt64(value any) (int64, bool) { switch typed := value.(type) { case int: @@ -1564,8 +1664,9 @@ func (c *Client) recordToolExecutionMetrics(seed ToolExecutionStart, startedAt t duration, metric.WithAttributes( attribute.String(spanAttrOperationName, "execute_tool"), - attribute.String(spanAttrProviderName, ""), - attribute.String(spanAttrRequestModel, strings.TrimSpace(seed.ToolName)), + attribute.String(spanAttrProviderName, strings.TrimSpace(seed.RequestProvider)), + attribute.String(spanAttrRequestModel, strings.TrimSpace(seed.RequestModel)), + attribute.String(spanAttrToolName, strings.TrimSpace(seed.ToolName)), attribute.String(spanAttrAgentName, strings.TrimSpace(seed.AgentName)), attribute.String(spanAttrErrorType, errorType), attribute.String(spanAttrErrorCategory, errorCategory), @@ -1612,12 +1713,21 @@ func toolSpanAttributes(start ToolExecutionStart) []attribute.KeyValue { if conversationID := strings.TrimSpace(start.ConversationID); conversationID != "" { attrs = append(attrs, attribute.String(spanAttrConversationID, conversationID)) } + if conversationTitle := strings.TrimSpace(start.ConversationTitle); conversationTitle != "" { + attrs = append(attrs, attribute.String(spanAttrConversationTitle, conversationTitle)) + } if agentName := strings.TrimSpace(start.AgentName); agentName != "" { attrs = append(attrs, attribute.String(spanAttrAgentName, agentName)) } if agentVersion := strings.TrimSpace(start.AgentVersion); agentVersion != "" { attrs = append(attrs, attribute.String(spanAttrAgentVersion, agentVersion)) } + if provider := strings.TrimSpace(start.RequestProvider); provider != "" { + attrs = append(attrs, attribute.String(spanAttrProviderName, provider)) + } + if model := strings.TrimSpace(start.RequestModel); model != "" { + attrs = append(attrs, attribute.String(spanAttrRequestModel, model)) + } return attrs } diff --git a/go/sigil/client_test.go b/go/sigil/client_test.go index 5d24c3b..bd93b8e 100644 --- a/go/sigil/client_test.go +++ b/go/sigil/client_test.go @@ -9,7 +9,7 @@ import ( "time" "unicode/utf8" - sigilv1 "github.com/grafana/sigil/sdks/go/sigil/internal/gen/sigil/v1" + sigilv1 "github.com/grafana/sigil-sdk/go/sigil/internal/gen/sigil/v1" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" sdktrace "go.opentelemetry.io/otel/sdk/trace" @@ -17,6 +17,20 @@ import ( "go.opentelemetry.io/otel/trace" ) +func TestDefaultConfigGenerationExportMessageAndPayloadLimits(t *testing.T) { + cfg := DefaultConfig() + + if cfg.GenerationExport.GRPCMaxSendMessageBytes != defaultGRPCMaxSendMessageBytes { + t.Fatalf("expected grpc max send %d, got %d", defaultGRPCMaxSendMessageBytes, cfg.GenerationExport.GRPCMaxSendMessageBytes) + } + if cfg.GenerationExport.GRPCMaxReceiveMessageBytes != defaultGRPCMaxReceiveMessageBytes { + t.Fatalf("expected grpc max receive %d, got %d", defaultGRPCMaxReceiveMessageBytes, cfg.GenerationExport.GRPCMaxReceiveMessageBytes) + } + if cfg.GenerationExport.PayloadMaxBytes != defaultGenerationPayloadMaxBytes { + t.Fatalf("expected payload max bytes %d, got %d", defaultGenerationPayloadMaxBytes, cfg.GenerationExport.PayloadMaxBytes) + } +} + func TestStartGenerationEnqueuesArtifacts(t *testing.T) { client, recorder, _ := newTestClient(t, Config{ Now: func() time.Time { @@ -39,10 +53,11 @@ func TestStartGenerationEnqueuesArtifacts(t *testing.T) { } _, generationRecorder := client.StartGeneration(context.Background(), GenerationStart{ - ID: "gen_test_externalize", - ConversationID: "conv-1", - AgentName: "agent-support", - AgentVersion: "v1.2.3", + ID: "gen_test_externalize", + ConversationID: "conv-1", + ConversationTitle: "Ticket triage", + AgentName: "agent-support", + AgentVersion: "v1.2.3", Model: ModelRef{ Provider: "anthropic", Name: "claude-sonnet-4-5", @@ -76,6 +91,12 @@ func TestStartGenerationEnqueuesArtifacts(t *testing.T) { if generationRecorder.lastGeneration.AgentVersion != "v1.2.3" { t.Fatalf("expected agent version v1.2.3, got %q", generationRecorder.lastGeneration.AgentVersion) } + if generationRecorder.lastGeneration.ConversationTitle != "Ticket triage" { + t.Fatalf("expected conversation title Ticket triage, got %q", generationRecorder.lastGeneration.ConversationTitle) + } + if got, ok := generationRecorder.lastGeneration.Metadata[spanAttrConversationTitle]; !ok || got != "Ticket triage" { + t.Fatalf("expected generation metadata %s=Ticket triage, got %#v", spanAttrConversationTitle, generationRecorder.lastGeneration.Metadata) + } span := onlyGenerationSpan(t, recorder.Ended()) attrs := spanAttributeMap(span) @@ -85,6 +106,9 @@ func TestStartGenerationEnqueuesArtifacts(t *testing.T) { if attrs[spanAttrConversationID].AsString() != "conv-1" { t.Fatalf("expected gen_ai.conversation.id=conv-1") } + if attrs[spanAttrConversationTitle].AsString() != "Ticket triage" { + t.Fatalf("expected sigil.conversation.title=Ticket triage") + } if attrs[spanAttrAgentName].AsString() != "agent-support" { t.Fatalf("expected gen_ai.agent.name=agent-support") } @@ -718,6 +742,32 @@ func TestNilClientReturnsNoOpEmbeddingRecorder(t *testing.T) { } } +func TestStartGenerationNilContextUsesBackgroundContext(t *testing.T) { + client, recorder, _ := newTestClient(t, Config{}) + + //nolint:staticcheck // Intentional nil context to verify StartGeneration fallback behavior. + callCtx, generationRecorder := client.StartGeneration(nil, GenerationStart{ + Model: ModelRef{Provider: "openai", Name: "gpt-5"}, + }) + if callCtx == nil { + t.Fatalf("expected non-nil context") + } + if !trace.SpanContextFromContext(callCtx).IsValid() { + t.Fatalf("expected valid span context in callCtx") + } + + generationRecorder.End() + if err := generationRecorder.Err(); err != nil { + t.Fatalf("unexpected generation recorder error: %v", err) + } + + span := onlyGenerationSpan(t, recorder.Ended()) + attrs := spanAttributeMap(span) + if _, ok := attrs[spanAttrUserID]; ok { + t.Fatalf("did not expect %s attribute when user id is unset", spanAttrUserID) + } +} + func TestStartEmbeddingNilContextUsesBackgroundContext(t *testing.T) { client, recorder, _ := newTestClient(t, Config{}) @@ -968,13 +1018,16 @@ func TestEmptyToolNameReturnsNoOpRecorder(t *testing.T) { func TestStartToolExecutionSetsExecuteToolAttributes(t *testing.T) { client, recorder, _ := newTestClient(t, Config{}) callCtx, toolRecorder := client.StartToolExecution(context.Background(), ToolExecutionStart{ - ToolName: "weather", - ToolCallID: "call_weather", - ToolType: "function", - ToolDescription: "Get weather", - ConversationID: "conv-tool", - AgentName: "agent-tools", - AgentVersion: "2026.02.12", + ToolName: "weather", + ToolCallID: "call_weather", + ToolType: "function", + ToolDescription: "Get weather", + ConversationID: "conv-tool", + ConversationTitle: "Weather lookup", + AgentName: "agent-tools", + AgentVersion: "2026.02.12", + RequestProvider: "openai", + RequestModel: "gpt-5", }) if !trace.SpanContextFromContext(callCtx).IsValid() { @@ -1012,12 +1065,21 @@ func TestStartToolExecutionSetsExecuteToolAttributes(t *testing.T) { if attrs[spanAttrConversationID].AsString() != "conv-tool" { t.Fatalf("expected gen_ai.conversation.id=conv-tool") } + if attrs[spanAttrConversationTitle].AsString() != "Weather lookup" { + t.Fatalf("expected sigil.conversation.title=Weather lookup") + } if attrs[spanAttrAgentName].AsString() != "agent-tools" { t.Fatalf("expected gen_ai.agent.name=agent-tools") } if attrs[spanAttrAgentVersion].AsString() != "2026.02.12" { t.Fatalf("expected gen_ai.agent.version=2026.02.12") } + if attrs[spanAttrProviderName].AsString() != "openai" { + t.Fatalf("expected gen_ai.provider.name=openai") + } + if attrs[spanAttrRequestModel].AsString() != "gpt-5" { + t.Fatalf("expected gen_ai.request.model=gpt-5") + } if attrs[sdkMetadataKeyName].AsString() != sdkName { t.Fatalf("expected %s=%s", sdkMetadataKeyName, sdkName) } @@ -1140,6 +1202,47 @@ func TestConversationIDFromContext(t *testing.T) { } } +func TestConversationTitleFromContext(t *testing.T) { + client, recorder, _ := newTestClient(t, Config{}) + + ctx := WithConversationTitle(context.Background(), "Conversation from context") + _, generationRecorder := client.StartGeneration(ctx, GenerationStart{ + Model: ModelRef{ + Provider: "anthropic", + Name: "claude-sonnet-4-5", + }, + }) + generationRecorder.End() + + span := onlyGenerationSpan(t, recorder.Ended()) + attrs := spanAttributeMap(span) + if attrs[spanAttrConversationTitle].AsString() != "Conversation from context" { + t.Fatalf("expected sigil.conversation.title=Conversation from context, got %q", attrs[spanAttrConversationTitle].AsString()) + } +} + +func TestUserIDFromContext(t *testing.T) { + client, recorder, _ := newTestClient(t, Config{}) + + ctx := WithUserID(context.Background(), "user-ctx") + _, generationRecorder := client.StartGeneration(ctx, GenerationStart{ + Model: ModelRef{ + Provider: "anthropic", + Name: "claude-sonnet-4-5", + }, + }) + generationRecorder.End() + + span := onlyGenerationSpan(t, recorder.Ended()) + attrs := spanAttributeMap(span) + if attrs[spanAttrUserID].AsString() != "user-ctx" { + t.Fatalf("expected %s=user-ctx, got %q", spanAttrUserID, attrs[spanAttrUserID].AsString()) + } + if got, ok := generationRecorder.lastGeneration.Metadata[metadataUserIDKey]; !ok || got != "user-ctx" { + t.Fatalf("expected generation metadata %s=user-ctx, got %#v", metadataUserIDKey, generationRecorder.lastGeneration.Metadata) + } +} + func TestAgentNameAndVersionFromContext(t *testing.T) { client, recorder, _ := newTestClient(t, Config{}) @@ -1183,6 +1286,98 @@ func TestExplicitConversationIDOverridesContext(t *testing.T) { } } +func TestExplicitConversationTitleOverridesContext(t *testing.T) { + client, recorder, _ := newTestClient(t, Config{}) + + ctx := WithConversationTitle(context.Background(), "context-title") + _, generationRecorder := client.StartGeneration(ctx, GenerationStart{ + ConversationTitle: "explicit-title", + Model: ModelRef{ + Provider: "anthropic", + Name: "claude-sonnet-4-5", + }, + }) + generationRecorder.End() + + span := onlyGenerationSpan(t, recorder.Ended()) + attrs := spanAttributeMap(span) + if attrs[spanAttrConversationTitle].AsString() != "explicit-title" { + t.Fatalf("expected sigil.conversation.title=explicit-title, got %q", attrs[spanAttrConversationTitle].AsString()) + } +} + +func TestWhitespaceConversationTitleFallsBackToMetadata(t *testing.T) { + client, recorder, _ := newTestClient(t, Config{}) + + _, generationRecorder := client.StartGeneration(context.Background(), GenerationStart{ + ConversationTitle: " ", + Metadata: map[string]any{ + spanAttrConversationTitle: "Metadata title", + }, + Model: ModelRef{ + Provider: "anthropic", + Name: "claude-sonnet-4-5", + }, + }) + generationRecorder.End() + + if generationRecorder.lastGeneration.ConversationTitle != "Metadata title" { + t.Fatalf("expected conversation title from metadata fallback, got %q", generationRecorder.lastGeneration.ConversationTitle) + } + if got, ok := generationRecorder.lastGeneration.Metadata[spanAttrConversationTitle]; !ok || got != "Metadata title" { + t.Fatalf("expected generation metadata %s=Metadata title, got %#v", spanAttrConversationTitle, generationRecorder.lastGeneration.Metadata) + } + + span := onlyGenerationSpan(t, recorder.Ended()) + attrs := spanAttributeMap(span) + if attrs[spanAttrConversationTitle].AsString() != "Metadata title" { + t.Fatalf("expected sigil.conversation.title=Metadata title, got %q", attrs[spanAttrConversationTitle].AsString()) + } +} + +func TestWhitespaceConversationTitleNormalizesToEmpty(t *testing.T) { + client, recorder, _ := newTestClient(t, Config{}) + + _, generationRecorder := client.StartGeneration(context.Background(), GenerationStart{ + ConversationTitle: " ", + Model: ModelRef{ + Provider: "anthropic", + Name: "claude-sonnet-4-5", + }, + }) + generationRecorder.End() + + if generationRecorder.lastGeneration.ConversationTitle != "" { + t.Fatalf("expected conversation title to normalize to empty, got %q", generationRecorder.lastGeneration.ConversationTitle) + } + + span := onlyGenerationSpan(t, recorder.Ended()) + attrs := spanAttributeMap(span) + if _, ok := attrs[spanAttrConversationTitle]; ok { + t.Fatalf("did not expect %s attribute when conversation title is whitespace-only", spanAttrConversationTitle) + } +} + +func TestExplicitUserIDOverridesContext(t *testing.T) { + client, recorder, _ := newTestClient(t, Config{}) + + ctx := WithUserID(context.Background(), "context-user") + _, generationRecorder := client.StartGeneration(ctx, GenerationStart{ + UserID: "explicit-user", + Model: ModelRef{ + Provider: "anthropic", + Name: "claude-sonnet-4-5", + }, + }) + generationRecorder.End() + + span := onlyGenerationSpan(t, recorder.Ended()) + attrs := spanAttributeMap(span) + if attrs[spanAttrUserID].AsString() != "explicit-user" { + t.Fatalf("expected %s=explicit-user, got %q", spanAttrUserID, attrs[spanAttrUserID].AsString()) + } +} + func TestExplicitAgentNameAndVersionOverrideContext(t *testing.T) { client, recorder, _ := newTestClient(t, Config{}) @@ -1224,6 +1419,39 @@ func TestToolExecutionConversationIDFromContext(t *testing.T) { } } +func TestToolExecutionConversationTitleFromContext(t *testing.T) { + client, recorder, _ := newTestClient(t, Config{}) + + ctx := WithConversationTitle(context.Background(), "tool conversation") + _, toolRecorder := client.StartToolExecution(ctx, ToolExecutionStart{ + ToolName: "weather", + }) + toolRecorder.End() + + span := onlyToolSpan(t, recorder.Ended()) + attrs := spanAttributeMap(span) + if attrs[spanAttrConversationTitle].AsString() != "tool conversation" { + t.Fatalf("expected sigil.conversation.title=tool conversation, got %q", attrs[spanAttrConversationTitle].AsString()) + } +} + +func TestToolExecutionExplicitConversationTitleOverridesContext(t *testing.T) { + client, recorder, _ := newTestClient(t, Config{}) + + ctx := WithConversationTitle(context.Background(), "context-title") + _, toolRecorder := client.StartToolExecution(ctx, ToolExecutionStart{ + ToolName: "weather", + ConversationTitle: "explicit-title", + }) + toolRecorder.End() + + span := onlyToolSpan(t, recorder.Ended()) + attrs := spanAttributeMap(span) + if attrs[spanAttrConversationTitle].AsString() != "explicit-title" { + t.Fatalf("expected sigil.conversation.title=explicit-title, got %q", attrs[spanAttrConversationTitle].AsString()) + } +} + func TestToolExecutionAgentNameAndVersionFromContext(t *testing.T) { client, recorder, _ := newTestClient(t, Config{}) @@ -1282,6 +1510,54 @@ func TestGenerationResultAgentFieldsOverrideSeed(t *testing.T) { } } +func TestGenerationMetadataUserIDFallbackSetsSpanAttribute(t *testing.T) { + client, recorder, _ := newTestClient(t, Config{}) + + _, rec := client.StartGeneration(context.Background(), GenerationStart{ + Metadata: map[string]any{ + metadataUserIDKey: "metadata-user", + }, + Model: ModelRef{ + Provider: "anthropic", + Name: "claude-sonnet-4-5", + }, + }) + rec.End() + + span := onlyGenerationSpan(t, recorder.Ended()) + attrs := spanAttributeMap(span) + if attrs[spanAttrUserID].AsString() != "metadata-user" { + t.Fatalf("expected %s=metadata-user, got %q", spanAttrUserID, attrs[spanAttrUserID].AsString()) + } + if got, ok := rec.lastGeneration.Metadata[metadataUserIDKey]; !ok || got != "metadata-user" { + t.Fatalf("expected generation metadata %s=metadata-user, got %#v", metadataUserIDKey, rec.lastGeneration.Metadata) + } +} + +func TestGenerationMetadataLegacyUserIDFallbackSetsSpanAttribute(t *testing.T) { + client, recorder, _ := newTestClient(t, Config{}) + + _, rec := client.StartGeneration(context.Background(), GenerationStart{ + Metadata: map[string]any{ + spanAttrUserID: "legacy-user", + }, + Model: ModelRef{ + Provider: "anthropic", + Name: "claude-sonnet-4-5", + }, + }) + rec.End() + + span := onlyGenerationSpan(t, recorder.Ended()) + attrs := spanAttributeMap(span) + if attrs[spanAttrUserID].AsString() != "legacy-user" { + t.Fatalf("expected %s=legacy-user, got %q", spanAttrUserID, attrs[spanAttrUserID].AsString()) + } + if got, ok := rec.lastGeneration.Metadata[metadataUserIDKey]; !ok || got != "legacy-user" { + t.Fatalf("expected generation metadata %s=legacy-user, got %#v", metadataUserIDKey, rec.lastGeneration.Metadata) + } +} + func TestGenerationRecorderSDKMetadataOverridesConflictingValues(t *testing.T) { client, _, _ := newTestClient(t, Config{}) diff --git a/go/sigil/conformance_helpers_test.go b/go/sigil/conformance_helpers_test.go new file mode 100644 index 0000000..6268d53 --- /dev/null +++ b/go/sigil/conformance_helpers_test.go @@ -0,0 +1,621 @@ +package sigil_test + +import ( + "context" + "io" + "math" + "net" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + sigil "github.com/grafana/sigil-sdk/go/sigil" + sigilv1 "github.com/grafana/sigil-sdk/go/sigil/internal/gen/sigil/v1" + "go.opentelemetry.io/otel/attribute" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "google.golang.org/grpc" + "google.golang.org/protobuf/proto" +) + +const ( + conformanceOperationName = "generateText" + conformanceStreamOperation = "streamText" + conformanceToolOperation = "execute_tool" + conformanceEmbeddingOperation = "embeddings" + metadataKeyConversation = "sigil.conversation.title" + metadataKeyCanonicalUserID = "sigil.user.id" + metadataKeyLegacyUserID = "user.id" + metadataKeyThinkingBudget = "sigil.gen_ai.request.thinking.budget_tokens" + metadataKeySDKName = "sigil.sdk.name" + sdkMetadataKeyName = metadataKeySDKName + sdkNameGo = "sdk-go" + spanAttrOperationName = "gen_ai.operation.name" + spanAttrGenerationID = "sigil.generation.id" + spanAttrConversationID = "gen_ai.conversation.id" + spanAttrConversationTitle = "sigil.conversation.title" + spanAttrUserID = "user.id" + spanAttrAgentName = "gen_ai.agent.name" + spanAttrAgentVersion = "gen_ai.agent.version" + spanAttrErrorType = "error.type" + spanAttrErrorCategory = "error.category" + spanAttrProviderName = "gen_ai.provider.name" + spanAttrRequestModel = "gen_ai.request.model" + spanAttrRequestMaxTokens = "gen_ai.request.max_tokens" + spanAttrRequestTemperature = "gen_ai.request.temperature" + spanAttrRequestTopP = "gen_ai.request.top_p" + spanAttrRequestToolChoice = "sigil.gen_ai.request.tool_choice" + spanAttrRequestThinkingEnabled = "sigil.gen_ai.request.thinking.enabled" + spanAttrRequestThinkingBudget = metadataKeyThinkingBudget + spanAttrEmbeddingInputCount = "gen_ai.embeddings.input_count" + spanAttrEmbeddingDimCount = "gen_ai.embeddings.dimension.count" + spanAttrToolName = "gen_ai.tool.name" + spanAttrToolCallID = "gen_ai.tool.call.id" + spanAttrToolType = "gen_ai.tool.type" + spanAttrToolDescription = "gen_ai.tool.description" + spanAttrToolCallArguments = "gen_ai.tool.call.arguments" + spanAttrToolCallResult = "gen_ai.tool.call.result" + spanAttrResponseID = "gen_ai.response.id" + spanAttrResponseModel = "gen_ai.response.model" + spanAttrFinishReasons = "gen_ai.response.finish_reasons" + spanAttrInputTokens = "gen_ai.usage.input_tokens" + spanAttrOutputTokens = "gen_ai.usage.output_tokens" + spanAttrCacheReadTokens = "gen_ai.usage.cache_read_input_tokens" + spanAttrCacheWriteTokens = "gen_ai.usage.cache_write_input_tokens" + spanAttrCacheCreationTokens = "gen_ai.usage.cache_creation_input_tokens" + spanAttrReasoningTokens = "gen_ai.usage.reasoning_tokens" + metricOperationDuration = "gen_ai.client.operation.duration" + metricTokenUsage = "gen_ai.client.token.usage" + metricTimeToFirstToken = "gen_ai.client.time_to_first_token" + metricToolCallsPerOperation = "gen_ai.client.tool_calls_per_operation" + metricAttrTokenType = "gen_ai.token.type" + metricTokenTypeInput = "input" + metricTokenTypeOutput = "output" + metricTokenTypeCacheRead = "cache_read" + metricTokenTypeCacheWrite = "cache_write" + metricTokenTypeCacheCreation = "cache_creation" + metricTokenTypeReasoning = "reasoning" +) + +var conformanceModel = sigil.ModelRef{ + Provider: "openai", + Name: "gpt-5", +} + +type conformanceEnv struct { + Client *sigil.Client + Ingest *fakeIngestServer + Spans *tracetest.SpanRecorder + Metrics *sdkmetric.ManualReader + Rating *fakeRatingServer + + tracerProvider *sdktrace.TracerProvider + meterProvider *sdkmetric.MeterProvider + grpcServer *grpc.Server + listener net.Listener + closeOnce sync.Once +} + +type conformanceEnvOption func(*conformanceEnvConfig) + +type conformanceEnvConfig struct { + config sigil.Config +} + +func newConformanceEnv(t *testing.T, opts ...conformanceEnvOption) *conformanceEnv { + t.Helper() + + ingest := &fakeIngestServer{} + grpcServer := grpc.NewServer() + sigilv1.RegisterGenerationIngestServiceServer(grpcServer, ingest) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen for fake ingest server: %v", err) + } + + go func() { + _ = grpcServer.Serve(listener) + }() + + spanRecorder := tracetest.NewSpanRecorder() + tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + metricReader := sdkmetric.NewManualReader() + meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(metricReader)) + ratingServer := newFakeRatingServer() + + cfg := conformanceEnvConfig{ + config: sigil.Config{ + Tracer: tracerProvider.Tracer("sigil-conformance-test"), + Meter: meterProvider.Meter("sigil-conformance-test"), + GenerationExport: sigil.GenerationExportConfig{ + Protocol: sigil.GenerationExportProtocolGRPC, + Endpoint: listener.Addr().String(), + Insecure: true, + BatchSize: 1, + FlushInterval: time.Hour, + QueueSize: 8, + MaxRetries: 1, + InitialBackoff: time.Millisecond, + MaxBackoff: 5 * time.Millisecond, + PayloadMaxBytes: 4 << 20, + }, + API: sigil.APIConfig{ + Endpoint: ratingServer.URL(), + }, + }, + } + for _, opt := range opts { + opt(&cfg) + } + + env := &conformanceEnv{ + Client: sigil.NewClient(cfg.config), + Ingest: ingest, + Spans: spanRecorder, + Metrics: metricReader, + Rating: ratingServer, + tracerProvider: tracerProvider, + meterProvider: meterProvider, + grpcServer: grpcServer, + listener: listener, + } + t.Cleanup(func() { + _ = env.close() + }) + return env +} + +func withConformanceConfig(mutator func(*sigil.Config)) conformanceEnvOption { + return func(cfg *conformanceEnvConfig) { + if mutator != nil { + mutator(&cfg.config) + } + } +} + +func (e *conformanceEnv) Shutdown(t *testing.T) { + t.Helper() + + if e == nil || e.Client == nil { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := e.Client.Shutdown(ctx); err != nil { + t.Fatalf("shutdown conformance client: %v", err) + } +} + +func (e *conformanceEnv) close() error { + if e == nil { + return nil + } + + var closeErr error + e.closeOnce.Do(func() { + if e.Client != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := e.Client.Shutdown(ctx); err != nil { + closeErr = err + } + } + if e.meterProvider != nil { + if err := e.meterProvider.Shutdown(context.Background()); err != nil && closeErr == nil { + closeErr = err + } + } + if e.tracerProvider != nil { + if err := e.tracerProvider.Shutdown(context.Background()); err != nil && closeErr == nil { + closeErr = err + } + } + if e.grpcServer != nil { + e.grpcServer.Stop() + } + if e.listener != nil { + _ = e.listener.Close() + } + if e.Rating != nil { + e.Rating.Close() + } + }) + return closeErr +} + +func (e *conformanceEnv) CollectMetrics(t *testing.T) metricdata.ResourceMetrics { + t.Helper() + + var collected metricdata.ResourceMetrics + if err := e.Metrics.Collect(context.Background(), &collected); err != nil { + t.Fatalf("collect metrics: %v", err) + } + return collected +} + +type fakeIngestServer struct { + sigilv1.UnimplementedGenerationIngestServiceServer + + mu sync.Mutex + requests []*sigilv1.ExportGenerationsRequest +} + +func (s *fakeIngestServer) ExportGenerations(_ context.Context, req *sigilv1.ExportGenerationsRequest) (*sigilv1.ExportGenerationsResponse, error) { + s.capture(req) + return acceptanceResponse(req), nil +} + +func (s *fakeIngestServer) capture(req *sigilv1.ExportGenerationsRequest) { + if req == nil { + return + } + + clone := proto.Clone(req) + typed, ok := clone.(*sigilv1.ExportGenerationsRequest) + if !ok { + return + } + + s.mu.Lock() + s.requests = append(s.requests, typed) + s.mu.Unlock() +} + +func (s *fakeIngestServer) SingleGeneration(t *testing.T) *sigilv1.Generation { + t.Helper() + + s.mu.Lock() + defer s.mu.Unlock() + + if len(s.requests) != 1 { + t.Fatalf("expected exactly one export request, got %d", len(s.requests)) + } + if len(s.requests[0].Generations) != 1 { + t.Fatalf("expected exactly one generation in request, got %d", len(s.requests[0].Generations)) + } + return s.requests[0].Generations[0] +} + +func (s *fakeIngestServer) RequestCount() int { + if s == nil { + return 0 + } + + s.mu.Lock() + defer s.mu.Unlock() + return len(s.requests) +} + +func (s *fakeIngestServer) Requests() []*sigilv1.ExportGenerationsRequest { + if s == nil { + return nil + } + + s.mu.Lock() + defer s.mu.Unlock() + + out := make([]*sigilv1.ExportGenerationsRequest, len(s.requests)) + copy(out, s.requests) + return out +} + +func (s *fakeIngestServer) GenerationCount() int { + if s == nil { + return 0 + } + + s.mu.Lock() + defer s.mu.Unlock() + + total := 0 + for _, req := range s.requests { + total += len(req.GetGenerations()) + } + return total +} + +func acceptanceResponse(req *sigilv1.ExportGenerationsRequest) *sigilv1.ExportGenerationsResponse { + response := &sigilv1.ExportGenerationsResponse{Results: make([]*sigilv1.ExportGenerationResult, len(req.GetGenerations()))} + for i := range req.GetGenerations() { + response.Results[i] = &sigilv1.ExportGenerationResult{ + GenerationId: req.Generations[i].GetId(), + Accepted: true, + } + } + return response +} + +type fakeRatingServer struct { + server *httptest.Server + + mu sync.Mutex + requests []capturedRatingRequest +} + +type capturedRatingRequest struct { + Method string + Path string + Headers http.Header + Body []byte +} + +func newFakeRatingServer() *fakeRatingServer { + s := &fakeRatingServer{} + s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + body, err := io.ReadAll(req.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + s.mu.Lock() + s.requests = append(s.requests, capturedRatingRequest{ + Method: req.Method, + Path: req.URL.Path, + Headers: req.Header.Clone(), + Body: append([]byte(nil), body...), + }) + s.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"rating":{"rating_id":"rat-1","conversation_id":"conv-1","rating":"CONVERSATION_RATING_VALUE_GOOD","created_at":"2026-03-12T11:00:00Z"},"summary":{"total_count":1,"good_count":1,"bad_count":0,"latest_rating":"CONVERSATION_RATING_VALUE_GOOD","latest_rated_at":"2026-03-12T11:00:00Z","has_bad_rating":false}}`)) + })) + return s +} + +func (s *fakeRatingServer) URL() string { + if s == nil || s.server == nil { + return "" + } + return s.server.URL +} + +func (s *fakeRatingServer) Close() { + if s != nil && s.server != nil { + s.server.Close() + } +} + +func (s *fakeRatingServer) SingleRequest(t *testing.T) capturedRatingRequest { + t.Helper() + + requests := s.Requests() + if len(requests) != 1 { + t.Fatalf("expected exactly one rating request, got %d", len(requests)) + } + + return requests[0] +} + +func (s *fakeRatingServer) Requests() []capturedRatingRequest { + if s == nil { + return nil + } + + s.mu.Lock() + defer s.mu.Unlock() + + out := make([]capturedRatingRequest, len(s.requests)) + for i := range s.requests { + out[i] = capturedRatingRequest{ + Method: s.requests[i].Method, + Path: s.requests[i].Path, + Headers: s.requests[i].Headers.Clone(), + Body: append([]byte(nil), s.requests[i].Body...), + } + } + return out +} + +func findSpan(t *testing.T, spans []sdktrace.ReadOnlySpan, operationName string) sdktrace.ReadOnlySpan { + t.Helper() + + var matched sdktrace.ReadOnlySpan + for _, span := range spans { + attrs := spanAttrs(span) + if got, ok := attrs[spanAttrOperationName]; ok && got.AsString() == operationName { + if matched != nil { + t.Fatalf("expected exactly one span with %s=%q", spanAttrOperationName, operationName) + } + matched = span + } + } + if matched == nil { + t.Fatalf("expected span with %s=%q", spanAttrOperationName, operationName) + } + return matched +} + +func spanAttrs(span sdktrace.ReadOnlySpan) map[string]attribute.Value { + attrs := make(map[string]attribute.Value, len(span.Attributes())) + for _, attr := range span.Attributes() { + attrs[string(attr.Key)] = attr.Value + } + return attrs +} + +func requireSpanAttr(t *testing.T, attrs map[string]attribute.Value, key, want string) { + t.Helper() + + got, ok := attrs[key] + if !ok { + t.Fatalf("expected span attribute %q=%q, attribute missing", key, want) + } + if got.AsString() != want { + t.Fatalf("unexpected span attribute %q: got %q want %q", key, got.AsString(), want) + } +} + +func requireSpanAttrAbsent(t *testing.T, attrs map[string]attribute.Value, key string) { + t.Helper() + + if _, ok := attrs[key]; ok { + t.Fatalf("did not expect span attribute %q to be present", key) + } +} + +func requireSpanBoolAttr(t *testing.T, attrs map[string]attribute.Value, key string, want bool) { + t.Helper() + + got, ok := attrs[key] + if !ok { + t.Fatalf("expected span attribute %q=%t, attribute missing", key, want) + } + if got.AsBool() != want { + t.Fatalf("unexpected span attribute %q: got %t want %t", key, got.AsBool(), want) + } +} + +func requireSpanInt64Attr(t *testing.T, attrs map[string]attribute.Value, key string, want int64) { + t.Helper() + + got, ok := attrs[key] + if !ok { + t.Fatalf("expected span attribute %q=%d, attribute missing", key, want) + } + if got.AsInt64() != want { + t.Fatalf("unexpected span attribute %q: got %d want %d", key, got.AsInt64(), want) + } +} + +func requireSpanFloat64Attr(t *testing.T, attrs map[string]attribute.Value, key string, want float64) { + t.Helper() + + got, ok := attrs[key] + if !ok { + t.Fatalf("expected span attribute %q=%v, attribute missing", key, want) + } + if math.Abs(got.AsFloat64()-want) > 1e-9 { + t.Fatalf("unexpected span attribute %q: got %v want %v", key, got.AsFloat64(), want) + } +} + +func requireSpanStringSliceAttr(t *testing.T, attrs map[string]attribute.Value, key string, want []string) { + t.Helper() + + got, ok := attrs[key] + if !ok { + t.Fatalf("expected span attribute %q=%v, attribute missing", key, want) + } + gotSlice := got.AsStringSlice() + if len(gotSlice) != len(want) { + t.Fatalf("unexpected span attribute %q length: got %d want %d", key, len(gotSlice), len(want)) + } + for i := range want { + if gotSlice[i] != want[i] { + t.Fatalf("unexpected span attribute %q[%d]: got %q want %q", key, i, gotSlice[i], want[i]) + } + } +} + +func requireSpanAttrPresent(t *testing.T, attrs map[string]attribute.Value, key string) { + t.Helper() + + if _, ok := attrs[key]; !ok { + t.Fatalf("expected span attribute %q to be present", key) + } +} + +func findHistogram[N int64 | float64](t *testing.T, collected metricdata.ResourceMetrics, name string) metricdata.Histogram[N] { + t.Helper() + + for _, scopeMetrics := range collected.ScopeMetrics { + for _, metric := range scopeMetrics.Metrics { + if metric.Name != name { + continue + } + histogram, ok := metric.Data.(metricdata.Histogram[N]) + if !ok { + t.Fatalf("metric %q is not the expected histogram type", name) + } + return histogram + } + } + + t.Fatalf("expected histogram %q", name) + return metricdata.Histogram[N]{} +} + +func requireNoHistogram(t *testing.T, collected metricdata.ResourceMetrics, name string) { + t.Helper() + + for _, scopeMetrics := range collected.ScopeMetrics { + for _, metric := range scopeMetrics.Metrics { + if metric.Name == name { + t.Fatalf("did not expect histogram %q to be present", name) + } + } + } +} + +func requireHistogramPointWithAttrs[N int64 | float64](t *testing.T, histogram metricdata.Histogram[N], want map[string]string) metricdata.HistogramDataPoint[N] { + t.Helper() + + return findHistogramPoint(t, histogram, want) +} + +func findHistogramPoint[N int64 | float64](t *testing.T, histogram metricdata.Histogram[N], want map[string]string) metricdata.HistogramDataPoint[N] { + t.Helper() + + for _, point := range histogram.DataPoints { + if histogramPointMatches(point.Attributes, want) { + return point + } + } + + t.Fatalf("expected histogram point with attrs %v", want) + return metricdata.HistogramDataPoint[N]{} +} + +func histogramPointMatches(attrs attribute.Set, want map[string]string) bool { + for key, expected := range want { + value, ok := (&attrs).Value(attribute.Key(key)) + if !ok || value.AsString() != expected { + return false + } + } + return true +} + +func requireProtoMetadata(t *testing.T, generation *sigilv1.Generation, key, want string) { + t.Helper() + + got, ok := protoMetadataString(generation, key) + if !ok { + t.Fatalf("expected generation metadata %q=%q, key missing", key, want) + } + if got != want { + t.Fatalf("unexpected generation metadata %q: got %q want %q", key, got, want) + } +} + +func requireProtoMetadataAbsent(t *testing.T, generation *sigilv1.Generation, key string) { + t.Helper() + + if _, ok := protoMetadataString(generation, key); ok { + t.Fatalf("did not expect generation metadata %q to be present", key) + } +} + +func protoMetadataString(generation *sigilv1.Generation, key string) (string, bool) { + if generation == nil || generation.GetMetadata() == nil { + return "", false + } + + value, ok := generation.GetMetadata().AsMap()[key] + if !ok { + return "", false + } + asString, ok := value.(string) + if !ok { + return "", false + } + return asString, true +} diff --git a/go/sigil/conformance_test.go b/go/sigil/conformance_test.go new file mode 100644 index 0000000..9f8e64d --- /dev/null +++ b/go/sigil/conformance_test.go @@ -0,0 +1,1143 @@ +package sigil_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "testing" + "time" + + sigil "github.com/grafana/sigil-sdk/go/sigil" + sigilv1 "github.com/grafana/sigil-sdk/go/sigil/internal/gen/sigil/v1" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +func TestConformance_FullGenerationRoundtrip(t *testing.T) { + startedAt := time.Date(2026, time.March, 12, 11, 0, 0, 0, time.UTC) + completedAt := startedAt.Add(3 * time.Second) + + requestArtifact, err := sigil.NewJSONArtifact(sigil.ArtifactKindRequest, "request", map[string]any{ + "messages": 1, + "tools": 1, + }) + if err != nil { + t.Fatalf("build request artifact: %v", err) + } + requestArtifact.RecordID = "rec-request-1" + requestArtifact.URI = "sigil://artifact/request-1" + + responseArtifact, err := sigil.NewJSONArtifact(sigil.ArtifactKindResponse, "response", map[string]any{ + "response_id": "msg_1", + "status": "ok", + }) + if err != nil { + t.Fatalf("build response artifact: %v", err) + } + responseArtifact.RecordID = "rec-response-1" + responseArtifact.URI = "sigil://artifact/response-1" + + env := newConformanceEnv(t, withConformanceConfig(func(cfg *sigil.Config) { + cfg.Now = func() time.Time { return completedAt } + })) + + _, recorder := env.Client.StartGeneration(context.Background(), sigil.GenerationStart{ + ID: "gen-full-roundtrip", + ConversationID: "conv-full-roundtrip", + ConversationTitle: "Weather follow-up", + UserID: "user-42", + AgentName: "assistant-anthropic", + AgentVersion: "1.0.0", + Model: sigil.ModelRef{ + Provider: "anthropic", + Name: "claude-sonnet-4-5", + }, + SystemPrompt: "Answer with a brief explanation and cite the tool result.", + Tools: []sigil.ToolDefinition{ + { + Name: "weather.lookup", + Description: "Look up historical weather by city and date", + Type: "function", + InputSchema: json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"},"date":{"type":"string"}},"required":["city","date"]}`), + Deferred: true, + }, + }, + MaxTokens: int64Ptr(1024), + Temperature: float64Ptr(0.7), + TopP: float64Ptr(0.9), + ToolChoice: stringPtr("required"), + ThinkingEnabled: boolPtr(true), + Tags: map[string]string{ + "env": "prod", + "seed_only": "seed", + "shared": "seed", + }, + Metadata: map[string]any{ + spanAttrRequestThinkingBudget: int64(2048), + "request_only": "seed-value", + "shared": "seed", + "nested": map[string]any{"phase": "seed"}, + }, + StartedAt: startedAt, + }) + recorder.SetResult(sigil.Generation{ + ResponseID: "msg_1", + ResponseModel: "claude-sonnet-4-5-20260312", + Input: []sigil.Message{ + { + Role: sigil.RoleUser, + Name: "customer", + Parts: []sigil.Part{ + sigil.TextPart("Summarize yesterday's Paris weather and explain the spikes."), + }, + }, + }, + Output: []sigil.Message{ + { + Role: sigil.RoleAssistant, + Name: "assistant", + Parts: []sigil.Part{ + { + Kind: sigil.PartKindThinking, + Thinking: "Need the weather tool output before the final answer.", + Metadata: sigil.PartMetadata{ProviderType: "thinking"}, + }, + { + Kind: sigil.PartKindToolCall, + ToolCall: &sigil.ToolCall{ + ID: "call-weather-1", + Name: "weather.lookup", + InputJSON: json.RawMessage(`{"city":"Paris","date":"2026-03-11"}`), + }, + Metadata: sigil.PartMetadata{ProviderType: "tool_use"}, + }, + }, + }, + { + Role: sigil.RoleTool, + Name: "weather.lookup", + Parts: []sigil.Part{ + { + Kind: sigil.PartKindToolResult, + ToolResult: &sigil.ToolResult{ + ToolCallID: "call-weather-1", + Name: "weather.lookup", + Content: "22C with a late-afternoon drop", + ContentJSON: json.RawMessage(`{"high_c":22,"trend":"late drop"}`), + }, + }, + }, + }, + { + Role: sigil.RoleAssistant, + Name: "assistant", + Parts: []sigil.Part{ + sigil.TextPart("Paris peaked at 22C before a late drop as cloud cover moved in."), + }, + }, + }, + Usage: sigil.TokenUsage{ + InputTokens: 120, + OutputTokens: 42, + TotalTokens: 162, + CacheReadInputTokens: 30, + CacheWriteInputTokens: 4, + CacheCreationInputTokens: 6, + ReasoningTokens: 9, + }, + StopReason: "end_turn", + Tags: map[string]string{ + "shared": "result", + "result_only": "assistant", + }, + Metadata: map[string]any{ + "shared": "result", + "result_only": "assistant", + "nested": map[string]any{"phase": "result"}, + "quality": true, + }, + Artifacts: []sigil.Artifact{requestArtifact, responseArtifact}, + }, nil) + recorder.End() + if err := recorder.Err(); err != nil { + t.Fatalf("record full generation roundtrip: %v", err) + } + + metrics := env.CollectMetrics(t) + env.Shutdown(t) + + span := findSpan(t, env.Spans.Ended(), conformanceOperationName) + if got := span.Name(); got != "generateText claude-sonnet-4-5" { + t.Fatalf("unexpected span name: got %q want %q", got, "generateText claude-sonnet-4-5") + } + if got := span.SpanKind(); got != trace.SpanKindClient { + t.Fatalf("unexpected span kind: got %v want %v", got, trace.SpanKindClient) + } + if got := span.Status().Code; got != codes.Ok { + t.Fatalf("unexpected span status: got %v want %v", got, codes.Ok) + } + + attrs := spanAttrs(span) + requireSpanAttr(t, attrs, spanAttrGenerationID, "gen-full-roundtrip") + requireSpanAttr(t, attrs, spanAttrConversationID, "conv-full-roundtrip") + requireSpanAttr(t, attrs, spanAttrConversationTitle, "Weather follow-up") + requireSpanAttr(t, attrs, spanAttrUserID, "user-42") + requireSpanAttr(t, attrs, spanAttrAgentName, "assistant-anthropic") + requireSpanAttr(t, attrs, spanAttrAgentVersion, "1.0.0") + requireSpanAttr(t, attrs, spanAttrProviderName, "anthropic") + requireSpanAttr(t, attrs, spanAttrRequestModel, "claude-sonnet-4-5") + requireSpanAttr(t, attrs, spanAttrResponseID, "msg_1") + requireSpanAttr(t, attrs, spanAttrResponseModel, "claude-sonnet-4-5-20260312") + requireSpanAttr(t, attrs, sdkMetadataKeyName, "sdk-go") + requireSpanInt64Attr(t, attrs, spanAttrRequestMaxTokens, 1024) + requireSpanFloat64Attr(t, attrs, spanAttrRequestTemperature, 0.7) + requireSpanFloat64Attr(t, attrs, spanAttrRequestTopP, 0.9) + requireSpanAttr(t, attrs, spanAttrRequestToolChoice, "required") + requireSpanBoolAttr(t, attrs, spanAttrRequestThinkingEnabled, true) + requireSpanInt64Attr(t, attrs, spanAttrRequestThinkingBudget, 2048) + requireSpanStringSliceAttr(t, attrs, spanAttrFinishReasons, []string{"end_turn"}) + requireSpanInt64Attr(t, attrs, spanAttrInputTokens, 120) + requireSpanInt64Attr(t, attrs, spanAttrOutputTokens, 42) + requireSpanInt64Attr(t, attrs, spanAttrCacheReadTokens, 30) + requireSpanInt64Attr(t, attrs, spanAttrCacheWriteTokens, 4) + requireSpanInt64Attr(t, attrs, spanAttrCacheCreationTokens, 6) + requireSpanInt64Attr(t, attrs, spanAttrReasoningTokens, 9) + + duration := findHistogram[float64](t, metrics, metricOperationDuration) + durationPoint := findHistogramPoint(t, duration, map[string]string{ + spanAttrOperationName: conformanceOperationName, + spanAttrProviderName: "anthropic", + spanAttrRequestModel: "claude-sonnet-4-5", + spanAttrAgentName: "assistant-anthropic", + spanAttrErrorType: "", + spanAttrErrorCategory: "", + }) + if durationPoint.Count != 1 { + t.Fatalf("unexpected %s count: got %d want %d", metricOperationDuration, durationPoint.Count, 1) + } + if durationPoint.Sum != 3 { + t.Fatalf("unexpected %s sum: got %v want %v", metricOperationDuration, durationPoint.Sum, 3.0) + } + + tokenUsage := findHistogram[int64](t, metrics, metricTokenUsage) + for tokenType, want := range map[string]int64{ + metricTokenTypeInput: 120, + metricTokenTypeOutput: 42, + metricTokenTypeCacheRead: 30, + metricTokenTypeCacheWrite: 4, + metricTokenTypeCacheCreation: 6, + metricTokenTypeReasoning: 9, + } { + point := findHistogramPoint(t, tokenUsage, map[string]string{ + spanAttrOperationName: conformanceOperationName, + spanAttrProviderName: "anthropic", + spanAttrRequestModel: "claude-sonnet-4-5", + spanAttrAgentName: "assistant-anthropic", + metricAttrTokenType: tokenType, + }) + if point.Count != 1 { + t.Fatalf("unexpected %s count for token type %q: got %d want %d", metricTokenUsage, tokenType, point.Count, 1) + } + if point.Sum != want { + t.Fatalf("unexpected %s sum for token type %q: got %d want %d", metricTokenUsage, tokenType, point.Sum, want) + } + } + + toolCalls := findHistogram[int64](t, metrics, metricToolCallsPerOperation) + toolPoint := findHistogramPoint(t, toolCalls, map[string]string{ + spanAttrProviderName: "anthropic", + spanAttrRequestModel: "claude-sonnet-4-5", + spanAttrAgentName: "assistant-anthropic", + }) + if toolPoint.Count != 1 { + t.Fatalf("unexpected %s count: got %d want %d", metricToolCallsPerOperation, toolPoint.Count, 1) + } + if toolPoint.Sum != 1 { + t.Fatalf("unexpected %s sum: got %d want %d", metricToolCallsPerOperation, toolPoint.Sum, 1) + } + requireNoHistogram(t, metrics, metricTimeToFirstToken) + + generation := env.Ingest.SingleGeneration(t) + if got := generation.GetId(); got != "gen-full-roundtrip" { + t.Fatalf("unexpected proto generation id: got %q want %q", got, "gen-full-roundtrip") + } + if got := generation.GetConversationId(); got != "conv-full-roundtrip" { + t.Fatalf("unexpected proto conversation id: got %q want %q", got, "conv-full-roundtrip") + } + if got := generation.GetOperationName(); got != conformanceOperationName { + t.Fatalf("unexpected proto operation name: got %q want %q", got, conformanceOperationName) + } + if got := generation.GetMode(); got != sigilv1.GenerationMode_GENERATION_MODE_SYNC { + t.Fatalf("unexpected proto mode: got %s want %s", got, sigilv1.GenerationMode_GENERATION_MODE_SYNC) + } + if got := generation.GetTraceId(); got != span.SpanContext().TraceID().String() { + t.Fatalf("unexpected proto trace_id: got %q want %q", got, span.SpanContext().TraceID().String()) + } + if got := generation.GetSpanId(); got != span.SpanContext().SpanID().String() { + t.Fatalf("unexpected proto span_id: got %q want %q", got, span.SpanContext().SpanID().String()) + } + if got := generation.GetAgentName(); got != "assistant-anthropic" { + t.Fatalf("unexpected proto agent_name: got %q want %q", got, "assistant-anthropic") + } + if got := generation.GetAgentVersion(); got != "1.0.0" { + t.Fatalf("unexpected proto agent_version: got %q want %q", got, "1.0.0") + } + if got := generation.GetModel().GetProvider(); got != "anthropic" { + t.Fatalf("unexpected proto model provider: got %q want %q", got, "anthropic") + } + if got := generation.GetModel().GetName(); got != "claude-sonnet-4-5" { + t.Fatalf("unexpected proto model name: got %q want %q", got, "claude-sonnet-4-5") + } + if got := generation.GetResponseId(); got != "msg_1" { + t.Fatalf("unexpected proto response_id: got %q want %q", got, "msg_1") + } + if got := generation.GetResponseModel(); got != "claude-sonnet-4-5-20260312" { + t.Fatalf("unexpected proto response_model: got %q want %q", got, "claude-sonnet-4-5-20260312") + } + if got := generation.GetSystemPrompt(); got != "Answer with a brief explanation and cite the tool result." { + t.Fatalf("unexpected proto system_prompt: got %q", got) + } + if got := generation.GetStopReason(); got != "end_turn" { + t.Fatalf("unexpected proto stop_reason: got %q want %q", got, "end_turn") + } + if got := generation.GetMaxTokens(); got != 1024 { + t.Fatalf("unexpected proto max_tokens: got %d want %d", got, 1024) + } + if got := generation.GetTemperature(); got != 0.7 { + t.Fatalf("unexpected proto temperature: got %v want %v", got, 0.7) + } + if got := generation.GetTopP(); got != 0.9 { + t.Fatalf("unexpected proto top_p: got %v want %v", got, 0.9) + } + if got := generation.GetToolChoice(); got != "required" { + t.Fatalf("unexpected proto tool_choice: got %q want %q", got, "required") + } + if got := generation.GetThinkingEnabled(); !got { + t.Fatalf("unexpected proto thinking_enabled: got %t want %t", got, true) + } + if got := generation.GetCallError(); got != "" { + t.Fatalf("expected empty proto call_error, got %q", got) + } + + if got := generation.GetStartedAt().AsTime(); !got.Equal(startedAt) { + t.Fatalf("unexpected proto started_at: got %s want %s", got, startedAt) + } + if got := generation.GetCompletedAt().AsTime(); !got.Equal(completedAt) { + t.Fatalf("unexpected proto completed_at: got %s want %s", got, completedAt) + } + + if len(generation.GetInput()) != 1 { + t.Fatalf("expected 1 proto input message, got %d", len(generation.GetInput())) + } + if input := generation.GetInput()[0]; input.GetRole() != sigilv1.MessageRole_MESSAGE_ROLE_USER || input.GetName() != "customer" || len(input.GetParts()) != 1 || input.GetParts()[0].GetText() != "Summarize yesterday's Paris weather and explain the spikes." { + t.Fatalf("unexpected proto input message: %#v", input) + } + + if len(generation.GetOutput()) != 3 { + t.Fatalf("expected 3 proto output messages, got %d", len(generation.GetOutput())) + } + firstOutput := generation.GetOutput()[0] + if firstOutput.GetRole() != sigilv1.MessageRole_MESSAGE_ROLE_ASSISTANT || firstOutput.GetName() != "assistant" || len(firstOutput.GetParts()) != 2 { + t.Fatalf("unexpected first proto output message: %#v", firstOutput) + } + if got := firstOutput.GetParts()[0].GetThinking(); got != "Need the weather tool output before the final answer." { + t.Fatalf("unexpected proto thinking part: got %q", got) + } + if got := firstOutput.GetParts()[0].GetMetadata().GetProviderType(); got != "thinking" { + t.Fatalf("unexpected proto thinking provider_type: got %q want %q", got, "thinking") + } + if got := firstOutput.GetParts()[1].GetToolCall().GetId(); got != "call-weather-1" { + t.Fatalf("unexpected proto tool call id: got %q want %q", got, "call-weather-1") + } + if got := firstOutput.GetParts()[1].GetToolCall().GetName(); got != "weather.lookup" { + t.Fatalf("unexpected proto tool call name: got %q want %q", got, "weather.lookup") + } + if !bytes.Equal(firstOutput.GetParts()[1].GetToolCall().GetInputJson(), []byte(`{"city":"Paris","date":"2026-03-11"}`)) { + t.Fatalf("unexpected proto tool call input json: %s", firstOutput.GetParts()[1].GetToolCall().GetInputJson()) + } + if got := firstOutput.GetParts()[1].GetMetadata().GetProviderType(); got != "tool_use" { + t.Fatalf("unexpected proto tool call provider_type: got %q want %q", got, "tool_use") + } + + secondOutput := generation.GetOutput()[1] + if secondOutput.GetRole() != sigilv1.MessageRole_MESSAGE_ROLE_TOOL || secondOutput.GetName() != "weather.lookup" || len(secondOutput.GetParts()) != 1 { + t.Fatalf("unexpected second proto output message: %#v", secondOutput) + } + if got := secondOutput.GetParts()[0].GetToolResult().GetToolCallId(); got != "call-weather-1" { + t.Fatalf("unexpected proto tool result tool_call_id: got %q want %q", got, "call-weather-1") + } + if got := secondOutput.GetParts()[0].GetToolResult().GetName(); got != "weather.lookup" { + t.Fatalf("unexpected proto tool result name: got %q want %q", got, "weather.lookup") + } + if got := secondOutput.GetParts()[0].GetToolResult().GetContent(); got != "22C with a late-afternoon drop" { + t.Fatalf("unexpected proto tool result content: got %q", got) + } + if !bytes.Equal(secondOutput.GetParts()[0].GetToolResult().GetContentJson(), []byte(`{"high_c":22,"trend":"late drop"}`)) { + t.Fatalf("unexpected proto tool result content json: %s", secondOutput.GetParts()[0].GetToolResult().GetContentJson()) + } + if secondOutput.GetParts()[0].GetToolResult().GetIsError() { + t.Fatalf("expected successful proto tool result") + } + + thirdOutput := generation.GetOutput()[2] + if thirdOutput.GetRole() != sigilv1.MessageRole_MESSAGE_ROLE_ASSISTANT || thirdOutput.GetName() != "assistant" || len(thirdOutput.GetParts()) != 1 { + t.Fatalf("unexpected third proto output message: %#v", thirdOutput) + } + if got := thirdOutput.GetParts()[0].GetText(); got != "Paris peaked at 22C before a late drop as cloud cover moved in." { + t.Fatalf("unexpected proto output text: got %q", got) + } + + if len(generation.GetTools()) != 1 { + t.Fatalf("expected 1 proto tool definition, got %d", len(generation.GetTools())) + } + tool := generation.GetTools()[0] + if tool.GetName() != "weather.lookup" || tool.GetDescription() != "Look up historical weather by city and date" || tool.GetType() != "function" || !tool.GetDeferred() { + t.Fatalf("unexpected proto tool definition: %#v", tool) + } + if !bytes.Equal(tool.GetInputSchemaJson(), []byte(`{"type":"object","properties":{"city":{"type":"string"},"date":{"type":"string"}},"required":["city","date"]}`)) { + t.Fatalf("unexpected proto tool input schema: %s", tool.GetInputSchemaJson()) + } + + usage := generation.GetUsage() + if usage.GetInputTokens() != 120 || usage.GetOutputTokens() != 42 || usage.GetTotalTokens() != 162 || usage.GetCacheReadInputTokens() != 30 || usage.GetCacheWriteInputTokens() != 4 || usage.GetReasoningTokens() != 9 || usage.GetCacheCreationInputTokens() != 6 { + t.Fatalf("unexpected proto usage: %#v", usage) + } + + if len(generation.GetTags()) != 4 { + t.Fatalf("expected 4 proto tags, got %d", len(generation.GetTags())) + } + if got := generation.GetTags()["env"]; got != "prod" { + t.Fatalf("unexpected proto tag env: got %q want %q", got, "prod") + } + if got := generation.GetTags()["seed_only"]; got != "seed" { + t.Fatalf("unexpected proto tag seed_only: got %q want %q", got, "seed") + } + if got := generation.GetTags()["shared"]; got != "result" { + t.Fatalf("unexpected proto tag shared: got %q want %q", got, "result") + } + if got := generation.GetTags()["result_only"]; got != "assistant" { + t.Fatalf("unexpected proto tag result_only: got %q want %q", got, "assistant") + } + + metadata := generation.GetMetadata().AsMap() + if got := metadata[sdkMetadataKeyName]; got != "sdk-go" { + t.Fatalf("unexpected proto metadata %q: got %#v want %#v", sdkMetadataKeyName, got, "sdk-go") + } + if got := metadata[metadataKeyConversation]; got != "Weather follow-up" { + t.Fatalf("unexpected proto metadata %q: got %#v want %#v", metadataKeyConversation, got, "Weather follow-up") + } + if got := metadata[metadataKeyCanonicalUserID]; got != "user-42" { + t.Fatalf("unexpected proto metadata %q: got %#v want %#v", metadataKeyCanonicalUserID, got, "user-42") + } + if got := metadata[spanAttrRequestThinkingBudget]; got != float64(2048) { + t.Fatalf("unexpected proto metadata %q: got %#v want %#v", spanAttrRequestThinkingBudget, got, float64(2048)) + } + if got := metadata["request_only"]; got != "seed-value" { + t.Fatalf("unexpected proto metadata request_only: got %#v want %#v", got, "seed-value") + } + if got := metadata["shared"]; got != "result" { + t.Fatalf("unexpected proto metadata shared: got %#v want %#v", got, "result") + } + if got := metadata["result_only"]; got != "assistant" { + t.Fatalf("unexpected proto metadata result_only: got %#v want %#v", got, "assistant") + } + if got := metadata["quality"]; got != true { + t.Fatalf("unexpected proto metadata quality: got %#v want %#v", got, true) + } + nested, ok := metadata["nested"].(map[string]any) + if !ok { + t.Fatalf("expected nested proto metadata map, got %#v", metadata["nested"]) + } + if got := nested["phase"]; got != "result" { + t.Fatalf("unexpected proto nested metadata phase: got %#v want %#v", got, "result") + } + + if len(generation.GetRawArtifacts()) != 2 { + t.Fatalf("expected 2 proto artifacts, got %d", len(generation.GetRawArtifacts())) + } + if artifact := generation.GetRawArtifacts()[0]; artifact.GetKind() != sigilv1.ArtifactKind_ARTIFACT_KIND_REQUEST || artifact.GetName() != "request" || artifact.GetContentType() != "application/json" || artifact.GetRecordId() != "rec-request-1" || artifact.GetUri() != "sigil://artifact/request-1" || !bytes.Equal(artifact.GetPayload(), requestArtifact.Payload) { + t.Fatalf("unexpected request artifact: %#v", artifact) + } + if artifact := generation.GetRawArtifacts()[1]; artifact.GetKind() != sigilv1.ArtifactKind_ARTIFACT_KIND_RESPONSE || artifact.GetName() != "response" || artifact.GetContentType() != "application/json" || artifact.GetRecordId() != "rec-response-1" || artifact.GetUri() != "sigil://artifact/response-1" || !bytes.Equal(artifact.GetPayload(), responseArtifact.Payload) { + t.Fatalf("unexpected response artifact: %#v", artifact) + } +} + +func TestConformance_ConversationTitleSemantics(t *testing.T) { + testCases := []struct { + name string + startTitle string + contextTitle string + metadataTitle string + wantTitle string + }{ + { + name: "explicit wins", + startTitle: "Explicit", + contextTitle: "Context", + metadataTitle: "Meta", + wantTitle: "Explicit", + }, + { + name: "context fallback", + contextTitle: "Context", + wantTitle: "Context", + }, + { + name: "metadata fallback", + metadataTitle: "Meta", + wantTitle: "Meta", + }, + { + name: "whitespace omitted", + startTitle: " ", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + env := newConformanceEnv(t) + + ctx := context.Background() + if tc.contextTitle != "" { + ctx = sigil.WithConversationTitle(ctx, tc.contextTitle) + } + + start := sigil.GenerationStart{ + Model: conformanceModel, + ConversationTitle: tc.startTitle, + } + if tc.metadataTitle != "" { + start.Metadata = map[string]any{ + metadataKeyConversation: tc.metadataTitle, + } + } + + recordGeneration(t, env, ctx, start, sigil.Generation{}) + + span := findSpan(t, env.Spans.Ended(), conformanceOperationName) + attrs := spanAttrs(span) + if tc.wantTitle == "" { + requireSpanAttrAbsent(t, attrs, spanAttrConversationTitle) + } else { + requireSpanAttr(t, attrs, spanAttrConversationTitle, tc.wantTitle) + } + + requireSyncGenerationMetrics(t, env) + env.Shutdown(t) + + generation := env.Ingest.SingleGeneration(t) + if tc.wantTitle == "" { + requireProtoMetadataAbsent(t, generation, metadataKeyConversation) + } else { + requireProtoMetadata(t, generation, metadataKeyConversation, tc.wantTitle) + } + }) + } +} + +func TestConformance_UserIDSemantics(t *testing.T) { + testCases := []struct { + name string + startUserID string + contextUserID string + canonicalUser string + legacyUser string + wantResolvedID string + }{ + { + name: "explicit wins", + startUserID: "explicit", + contextUserID: "ctx", + canonicalUser: "meta-canonical", + legacyUser: "meta-legacy", + wantResolvedID: "explicit", + }, + { + name: "context fallback", + contextUserID: "ctx", + wantResolvedID: "ctx", + }, + { + name: "canonical metadata", + canonicalUser: "canonical", + wantResolvedID: "canonical", + }, + { + name: "legacy metadata", + legacyUser: "legacy", + wantResolvedID: "legacy", + }, + { + name: "canonical beats legacy", + canonicalUser: "canonical", + legacyUser: "legacy", + wantResolvedID: "canonical", + }, + { + name: "whitespace trimmed", + startUserID: " padded ", + wantResolvedID: "padded", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + env := newConformanceEnv(t) + + ctx := context.Background() + if tc.contextUserID != "" { + ctx = sigil.WithUserID(ctx, tc.contextUserID) + } + + start := sigil.GenerationStart{ + Model: conformanceModel, + UserID: tc.startUserID, + } + if tc.canonicalUser != "" || tc.legacyUser != "" { + start.Metadata = map[string]any{} + if tc.canonicalUser != "" { + start.Metadata[metadataKeyCanonicalUserID] = tc.canonicalUser + } + if tc.legacyUser != "" { + start.Metadata[metadataKeyLegacyUserID] = tc.legacyUser + } + } + + recordGeneration(t, env, ctx, start, sigil.Generation{}) + + span := findSpan(t, env.Spans.Ended(), conformanceOperationName) + attrs := spanAttrs(span) + requireSpanAttr(t, attrs, spanAttrUserID, tc.wantResolvedID) + + requireSyncGenerationMetrics(t, env) + env.Shutdown(t) + + generation := env.Ingest.SingleGeneration(t) + requireProtoMetadata(t, generation, metadataKeyCanonicalUserID, tc.wantResolvedID) + }) + } +} + +func TestConformance_AgentIdentitySemantics(t *testing.T) { + testCases := []struct { + name string + startAgentName string + startVersion string + contextAgentName string + contextVersion string + resultAgentName string + resultVersion string + wantAgentName string + wantVersion string + }{ + { + name: "explicit fields", + startAgentName: "agent-explicit", + startVersion: "v1.2.3", + wantAgentName: "agent-explicit", + wantVersion: "v1.2.3", + }, + { + name: "context fallback", + contextAgentName: "agent-context", + contextVersion: "v-context", + wantAgentName: "agent-context", + wantVersion: "v-context", + }, + { + name: "result-time override", + startAgentName: "agent-seed", + startVersion: "v-seed", + resultAgentName: "agent-result", + resultVersion: "v-result", + wantAgentName: "agent-result", + wantVersion: "v-result", + }, + { + name: "empty field omission", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + env := newConformanceEnv(t) + + ctx := context.Background() + if tc.contextAgentName != "" { + ctx = sigil.WithAgentName(ctx, tc.contextAgentName) + } + if tc.contextVersion != "" { + ctx = sigil.WithAgentVersion(ctx, tc.contextVersion) + } + + start := sigil.GenerationStart{ + Model: conformanceModel, + AgentName: tc.startAgentName, + AgentVersion: tc.startVersion, + } + result := sigil.Generation{ + AgentName: tc.resultAgentName, + AgentVersion: tc.resultVersion, + } + + recordGeneration(t, env, ctx, start, result) + + span := findSpan(t, env.Spans.Ended(), conformanceOperationName) + attrs := spanAttrs(span) + if tc.wantAgentName == "" { + requireSpanAttrAbsent(t, attrs, spanAttrAgentName) + } else { + requireSpanAttr(t, attrs, spanAttrAgentName, tc.wantAgentName) + } + if tc.wantVersion == "" { + requireSpanAttrAbsent(t, attrs, spanAttrAgentVersion) + } else { + requireSpanAttr(t, attrs, spanAttrAgentVersion, tc.wantVersion) + } + + requireSyncGenerationMetrics(t, env) + env.Shutdown(t) + + generation := env.Ingest.SingleGeneration(t) + if tc.wantAgentName == "" { + if got := generation.GetAgentName(); got != "" { + t.Fatalf("expected empty proto agent_name, got %q", got) + } + } else if got := generation.GetAgentName(); got != tc.wantAgentName { + t.Fatalf("unexpected proto agent_name: got %q want %q", got, tc.wantAgentName) + } + + if tc.wantVersion == "" { + if got := generation.GetAgentVersion(); got != "" { + t.Fatalf("expected empty proto agent_version, got %q", got) + } + } else if got := generation.GetAgentVersion(); got != tc.wantVersion { + t.Fatalf("unexpected proto agent_version: got %q want %q", got, tc.wantVersion) + } + }) + } +} + +func TestConformance_StreamingMode(t *testing.T) { + env := newConformanceEnv(t) + + recordGeneration(t, env, context.Background(), sigil.GenerationStart{ + ConversationID: "conv-sync", + Model: conformanceModel, + StartedAt: time.Date(2026, 3, 12, 14, 0, 0, 0, time.UTC), + }, sigil.Generation{ + Input: []sigil.Message{sigil.UserTextMessage("hello")}, + Output: []sigil.Message{sigil.AssistantTextMessage("hi")}, + CompletedAt: time.Date(2026, 3, 12, 14, 0, 1, 0, time.UTC), + }) + + streamStartedAt := time.Date(2026, 3, 12, 14, 1, 0, 0, time.UTC) + _, recorder := env.Client.StartStreamingGeneration(context.Background(), sigil.GenerationStart{ + ConversationID: "conv-stream", + AgentName: "agent-stream", + Model: conformanceModel, + StartedAt: streamStartedAt, + }) + recorder.SetFirstTokenAt(streamStartedAt.Add(250 * time.Millisecond)) + recorder.SetResult(sigil.Generation{ + Input: []sigil.Message{sigil.UserTextMessage("say hello")}, + Output: []sigil.Message{sigil.AssistantTextMessage("Hello world")}, + CompletedAt: streamStartedAt.Add(1500 * time.Millisecond), + }, nil) + recorder.End() + if err := recorder.Err(); err != nil { + t.Fatalf("record streaming generation: %v", err) + } + + metrics := env.CollectMetrics(t) + ttft := findHistogram[float64](t, metrics, metricTimeToFirstToken) + if len(ttft.DataPoints) != 1 { + t.Fatalf("expected exactly 1 %s datapoint, got %d", metricTimeToFirstToken, len(ttft.DataPoints)) + } + requireHistogramPointWithAttrs(t, ttft, map[string]string{ + spanAttrProviderName: conformanceModel.Provider, + spanAttrRequestModel: conformanceModel.Name, + spanAttrAgentName: "agent-stream", + }) + + env.Shutdown(t) + + streamGeneration := findGenerationByConversationID(t, env.Ingest.Requests(), "conv-stream") + if got := streamGeneration.GetMode(); got != sigilv1.GenerationMode_GENERATION_MODE_STREAM { + t.Fatalf("unexpected proto mode: got %v want %v", got, sigilv1.GenerationMode_GENERATION_MODE_STREAM) + } + if got := streamGeneration.GetOperationName(); got != conformanceStreamOperation { + t.Fatalf("unexpected proto operation: got %q want %q", got, conformanceStreamOperation) + } + if len(streamGeneration.GetOutput()) != 1 || len(streamGeneration.GetOutput()[0].GetParts()) != 1 { + t.Fatalf("expected a single streamed assistant output, got %#v", streamGeneration.GetOutput()) + } + if got := streamGeneration.GetOutput()[0].GetParts()[0].GetText(); got != "Hello world" { + t.Fatalf("unexpected streamed assistant text: got %q want %q", got, "Hello world") + } + + span := findSpan(t, env.Spans.Ended(), conformanceStreamOperation) + if got := span.Name(); got != conformanceStreamOperation+" "+conformanceModel.Name { + t.Fatalf("unexpected streaming span name: %q", got) + } + attrs := spanAttrs(span) + requireSpanAttr(t, attrs, spanAttrOperationName, conformanceStreamOperation) +} + +func TestConformance_ToolExecution(t *testing.T) { + env := newConformanceEnv(t) + + ctx := sigil.WithConversationID(context.Background(), "conv-tool") + ctx = sigil.WithConversationTitle(ctx, "Weather lookup") + ctx = sigil.WithAgentName(ctx, "agent-tools") + ctx = sigil.WithAgentVersion(ctx, "2026.03.12") + + generationStartedAt := time.Date(2026, 3, 12, 14, 2, 0, 0, time.UTC) + callCtx, generationRecorder := env.Client.StartGeneration(ctx, sigil.GenerationStart{ + Model: conformanceModel, + StartedAt: generationStartedAt, + }) + _, toolRecorder := env.Client.StartToolExecution(callCtx, sigil.ToolExecutionStart{ + ToolName: "weather", + ToolCallID: "call-weather", + ToolType: "function", + ToolDescription: "Get weather", + RequestModel: conformanceModel.Name, + RequestProvider: conformanceModel.Provider, + IncludeContent: true, + StartedAt: generationStartedAt.Add(100 * time.Millisecond), + }) + toolRecorder.SetResult(sigil.ToolExecutionEnd{ + Arguments: map[string]any{"city": "Paris"}, + Result: map[string]any{"temp_c": 18}, + CompletedAt: generationStartedAt.Add(600 * time.Millisecond), + }) + toolRecorder.End() + if err := toolRecorder.Err(); err != nil { + t.Fatalf("record tool execution: %v", err) + } + + generationRecorder.SetResult(sigil.Generation{ + Input: []sigil.Message{sigil.UserTextMessage("weather in Paris")}, + Output: []sigil.Message{sigil.AssistantTextMessage("Paris is 18C")}, + CompletedAt: generationStartedAt.Add(time.Second), + }, nil) + generationRecorder.End() + if err := generationRecorder.Err(); err != nil { + t.Fatalf("record parent generation: %v", err) + } + + metrics := env.CollectMetrics(t) + duration := findHistogram[float64](t, metrics, metricOperationDuration) + requireHistogramPointWithAttrs(t, duration, map[string]string{ + spanAttrOperationName: conformanceToolOperation, + spanAttrProviderName: conformanceModel.Provider, + spanAttrRequestModel: conformanceModel.Name, + spanAttrToolName: "weather", + spanAttrAgentName: "agent-tools", + }) + + env.Shutdown(t) + + span := findSpan(t, env.Spans.Ended(), conformanceToolOperation) + if got := span.SpanKind(); got != trace.SpanKindInternal { + t.Fatalf("unexpected tool span kind: got %v want %v", got, trace.SpanKindInternal) + } + + attrs := spanAttrs(span) + requireSpanAttr(t, attrs, spanAttrOperationName, conformanceToolOperation) + requireSpanAttr(t, attrs, spanAttrToolName, "weather") + requireSpanAttr(t, attrs, spanAttrToolCallID, "call-weather") + requireSpanAttr(t, attrs, spanAttrToolType, "function") + requireSpanAttr(t, attrs, spanAttrToolDescription, "Get weather") + requireSpanAttr(t, attrs, spanAttrConversationID, "conv-tool") + requireSpanAttr(t, attrs, spanAttrConversationTitle, "Weather lookup") + requireSpanAttr(t, attrs, spanAttrAgentName, "agent-tools") + requireSpanAttr(t, attrs, spanAttrAgentVersion, "2026.03.12") + requireSpanAttr(t, attrs, spanAttrProviderName, conformanceModel.Provider) + requireSpanAttr(t, attrs, spanAttrRequestModel, conformanceModel.Name) + requireSpanAttr(t, attrs, metadataKeySDKName, sdkNameGo) + requireSpanAttrPresent(t, attrs, spanAttrToolCallArguments) + requireSpanAttrPresent(t, attrs, spanAttrToolCallResult) +} + +func TestConformance_Embedding(t *testing.T) { + env := newConformanceEnv(t) + + _, recorder := env.Client.StartEmbedding(context.Background(), sigil.EmbeddingStart{ + Model: sigil.ModelRef{Provider: "openai", Name: "text-embedding-3-small"}, + AgentName: "agent-embed", + Dimensions: int64Ptr(256), + EncodingFormat: "float", + StartedAt: time.Date(2026, 3, 12, 14, 3, 0, 0, time.UTC), + }) + recorder.SetResult(sigil.EmbeddingResult{ + InputCount: 2, + InputTokens: 120, + ResponseModel: "text-embedding-3-small", + Dimensions: int64Ptr(256), + }) + recorder.End() + if err := recorder.Err(); err != nil { + t.Fatalf("record embedding: %v", err) + } + + metrics := env.CollectMetrics(t) + duration := findHistogram[float64](t, metrics, metricOperationDuration) + requireHistogramPointWithAttrs(t, duration, map[string]string{ + spanAttrOperationName: conformanceEmbeddingOperation, + spanAttrProviderName: "openai", + spanAttrRequestModel: "text-embedding-3-small", + spanAttrAgentName: "agent-embed", + }) + tokenUsage := findHistogram[int64](t, metrics, metricTokenUsage) + requireHistogramPointWithAttrs(t, tokenUsage, map[string]string{ + spanAttrOperationName: conformanceEmbeddingOperation, + spanAttrProviderName: "openai", + spanAttrRequestModel: "text-embedding-3-small", + spanAttrAgentName: "agent-embed", + metricAttrTokenType: metricTokenTypeInput, + }) + requireNoHistogram(t, metrics, metricTimeToFirstToken) + requireNoHistogram(t, metrics, metricToolCallsPerOperation) + + env.Shutdown(t) + + if got := env.Ingest.GenerationCount(); got != 0 { + t.Fatalf("expected no generation exports for embeddings, got %d", got) + } + + span := findSpan(t, env.Spans.Ended(), conformanceEmbeddingOperation) + if got := span.SpanKind(); got != trace.SpanKindClient { + t.Fatalf("unexpected embedding span kind: got %v want %v", got, trace.SpanKindClient) + } + + attrs := spanAttrs(span) + requireSpanAttr(t, attrs, spanAttrOperationName, conformanceEmbeddingOperation) + requireSpanAttr(t, attrs, spanAttrProviderName, "openai") + requireSpanAttr(t, attrs, spanAttrRequestModel, "text-embedding-3-small") + requireSpanAttr(t, attrs, metadataKeySDKName, sdkNameGo) + if got := attrs[spanAttrEmbeddingInputCount].AsInt64(); got != 2 { + t.Fatalf("unexpected embedding input count: got %d want 2", got) + } + if got := attrs[spanAttrEmbeddingDimCount].AsInt64(); got != 256 { + t.Fatalf("unexpected embedding dimension count: got %d want 256", got) + } +} + +func TestConformance_ValidationAndErrorSemantics(t *testing.T) { + t.Run("invalid generation", func(t *testing.T) { + env := newConformanceEnv(t) + + _, recorder := env.Client.StartGeneration(context.Background(), sigil.GenerationStart{ + ConversationID: "conv-invalid", + StartedAt: time.Date(2026, 3, 12, 14, 4, 0, 0, time.UTC), + }) + recorder.SetResult(sigil.Generation{ + Input: []sigil.Message{sigil.UserTextMessage("hello")}, + Output: []sigil.Message{sigil.AssistantTextMessage("hi")}, + CompletedAt: time.Date(2026, 3, 12, 14, 4, 1, 0, time.UTC), + }, nil) + recorder.End() + + if err := recorder.Err(); !errors.Is(err, sigil.ErrValidationFailed) { + t.Fatalf("expected ErrValidationFailed, got %v", err) + } + if got := env.Ingest.GenerationCount(); got != 0 { + t.Fatalf("expected no exports for invalid generation, got %d", got) + } + + span := findSpan(t, env.Spans.Ended(), conformanceOperationName) + if got := span.Status().Code; got != codes.Error { + t.Fatalf("expected error span status, got %v", got) + } + attrs := spanAttrs(span) + requireSpanAttr(t, attrs, spanAttrErrorType, "validation_error") + }) + + t.Run("provider call error", func(t *testing.T) { + env := newConformanceEnv(t) + + _, recorder := env.Client.StartGeneration(context.Background(), sigil.GenerationStart{ + ConversationID: "conv-rate-limit", + AgentName: "agent-error", + Model: conformanceModel, + StartedAt: time.Date(2026, 3, 12, 14, 5, 0, 0, time.UTC), + }) + recorder.SetCallError(errors.New("provider returned HTTP 429 rate limit")) + recorder.SetResult(sigil.Generation{ + Input: []sigil.Message{sigil.UserTextMessage("retry later")}, + Output: []sigil.Message{sigil.AssistantTextMessage("rate limited")}, + CompletedAt: time.Date(2026, 3, 12, 14, 5, 1, 0, time.UTC), + }, nil) + recorder.End() + if err := recorder.Err(); err != nil { + t.Fatalf("expected no local error for provider call failure, got %v", err) + } + + metrics := env.CollectMetrics(t) + duration := findHistogram[float64](t, metrics, metricOperationDuration) + requireHistogramPointWithAttrs(t, duration, map[string]string{ + spanAttrOperationName: conformanceOperationName, + spanAttrProviderName: conformanceModel.Provider, + spanAttrRequestModel: conformanceModel.Name, + spanAttrAgentName: "agent-error", + spanAttrErrorType: "provider_call_error", + spanAttrErrorCategory: "rate_limit", + }) + + env.Shutdown(t) + + span := findSpan(t, env.Spans.Ended(), conformanceOperationName) + if got := span.Status().Code; got != codes.Error { + t.Fatalf("expected error span status, got %v", got) + } + attrs := spanAttrs(span) + requireSpanAttr(t, attrs, spanAttrErrorType, "provider_call_error") + requireSpanAttr(t, attrs, spanAttrErrorCategory, "rate_limit") + + generation := env.Ingest.SingleGeneration(t) + if got := generation.GetCallError(); got != "provider returned HTTP 429 rate limit" { + t.Fatalf("unexpected proto call error: got %q", got) + } + requireProtoMetadata(t, generation, "call_error", "provider returned HTTP 429 rate limit") + }) +} + +func TestConformance_RatingHelper(t *testing.T) { + env := newConformanceEnv(t, withConformanceConfig(func(cfg *sigil.Config) { + cfg.GenerationExport.Headers = map[string]string{"X-Custom": "test"} + })) + + response, err := env.Client.SubmitConversationRating(context.Background(), "conv-rated", sigil.ConversationRatingInput{ + RatingID: "rat-1", + Rating: sigil.ConversationRatingValueGood, + Comment: "looks good", + Metadata: map[string]any{"channel": "assistant"}, + }) + if err != nil { + t.Fatalf("submit conversation rating: %v", err) + } + + requests := env.Rating.Requests() + if len(requests) != 1 { + t.Fatalf("expected exactly 1 rating request, got %d", len(requests)) + } + + request := requests[0] + if request.Method != http.MethodPost { + t.Fatalf("unexpected request method: got %s want %s", request.Method, http.MethodPost) + } + if request.Path != "/api/v1/conversations/conv-rated/ratings" { + t.Fatalf("unexpected rating request path: %s", request.Path) + } + if got := request.Headers.Get("X-Custom"); got != "test" { + t.Fatalf("expected X-Custom header, got %q", got) + } + + var payload sigil.ConversationRatingInput + if err := json.Unmarshal(request.Body, &payload); err != nil { + t.Fatalf("decode rating request body: %v", err) + } + if payload.RatingID != "rat-1" { + t.Fatalf("unexpected rating id: %q", payload.RatingID) + } + if payload.Rating != sigil.ConversationRatingValueGood { + t.Fatalf("unexpected rating value: %q", payload.Rating) + } + if payload.Comment != "looks good" { + t.Fatalf("unexpected comment: %q", payload.Comment) + } + if got := payload.Metadata["channel"]; got != "assistant" { + t.Fatalf("unexpected metadata: %#v", payload.Metadata) + } + if response == nil || response.Rating.RatingID != "rat-1" { + t.Fatalf("unexpected rating response: %#v", response) + } +} + +func TestConformance_ShutdownFlushesPendingGeneration(t *testing.T) { + env := newConformanceEnv(t, withConformanceConfig(func(cfg *sigil.Config) { + cfg.GenerationExport.BatchSize = 10 + })) + + recordGeneration(t, env, context.Background(), sigil.GenerationStart{ + ConversationID: "conv-shutdown", + Model: conformanceModel, + StartedAt: time.Date(2026, 3, 12, 14, 6, 0, 0, time.UTC), + }, sigil.Generation{ + Input: []sigil.Message{sigil.UserTextMessage("hello")}, + Output: []sigil.Message{sigil.AssistantTextMessage("hi")}, + CompletedAt: time.Date(2026, 3, 12, 14, 6, 1, 0, time.UTC), + }) + + if got := env.Ingest.GenerationCount(); got != 0 { + t.Fatalf("expected no exports before shutdown flush, got %d", got) + } + + env.Shutdown(t) + + if got := env.Ingest.GenerationCount(); got != 1 { + t.Fatalf("expected exactly 1 exported generation after shutdown, got %d", got) + } + generation := env.Ingest.SingleGeneration(t) + if got := generation.GetConversationId(); got != "conv-shutdown" { + t.Fatalf("unexpected shutdown-flushed conversation id: %q", got) + } +} + +func recordGeneration(t *testing.T, env *conformanceEnv, ctx context.Context, start sigil.GenerationStart, result sigil.Generation) { + t.Helper() + + _, recorder := env.Client.StartGeneration(ctx, start) + recorder.SetResult(result, nil) + recorder.End() + if err := recorder.Err(); err != nil { + t.Fatalf("record generation: %v", err) + } +} + +func requireSyncGenerationMetrics(t *testing.T, env *conformanceEnv) { + t.Helper() + + metrics := env.CollectMetrics(t) + duration := findHistogram[float64](t, metrics, metricOperationDuration) + if len(duration.DataPoints) == 0 { + t.Fatalf("expected %s datapoints for conformance generation", metricOperationDuration) + } + requireNoHistogram(t, metrics, metricTimeToFirstToken) +} + +func findGenerationByConversationID(t *testing.T, requests []*sigilv1.ExportGenerationsRequest, conversationID string) *sigilv1.Generation { + t.Helper() + + for _, req := range requests { + for _, generation := range req.GetGenerations() { + if generation.GetConversationId() == conversationID { + return generation + } + } + } + + t.Fatalf("expected generation for conversation %q", conversationID) + return nil +} + +func int64Ptr(value int64) *int64 { + return &value +} + +func float64Ptr(value float64) *float64 { + return &value +} + +func stringPtr(value string) *string { + return &value +} + +func boolPtr(value bool) *bool { + return &value +} diff --git a/go/sigil/context.go b/go/sigil/context.go index 0dec3e9..ae21a74 100644 --- a/go/sigil/context.go +++ b/go/sigil/context.go @@ -3,6 +3,8 @@ package sigil import "context" type conversationIDContextKey struct{} +type conversationTitleContextKey struct{} +type userIDContextKey struct{} type agentNameContextKey struct{} type agentVersionContextKey struct{} @@ -19,6 +21,32 @@ func ConversationIDFromContext(ctx context.Context) (string, bool) { return id, ok && id != "" } +// WithConversationTitle stores a conversation title in the context. +// StartGeneration, StartStreamingGeneration, and StartToolExecution read it when +// the explicit field is empty. +func WithConversationTitle(ctx context.Context, title string) context.Context { + return context.WithValue(ctx, conversationTitleContextKey{}, title) +} + +// ConversationTitleFromContext retrieves the conversation title stored by WithConversationTitle. +func ConversationTitleFromContext(ctx context.Context) (string, bool) { + title, ok := ctx.Value(conversationTitleContextKey{}).(string) + return title, ok && title != "" +} + +// WithUserID stores a user ID in the context. +// StartGeneration and StartStreamingGeneration read it when the explicit field +// is empty. +func WithUserID(ctx context.Context, userID string) context.Context { + return context.WithValue(ctx, userIDContextKey{}, userID) +} + +// UserIDFromContext retrieves the user ID stored by WithUserID. +func UserIDFromContext(ctx context.Context) (string, bool) { + userID, ok := ctx.Value(userIDContextKey{}).(string) + return userID, ok && userID != "" +} + // WithAgentName stores an agent name in the context. // StartGeneration, StartStreamingGeneration, and StartToolExecution read it when // the explicit field is empty. diff --git a/go/sigil/example_test.go b/go/sigil/example_test.go index 790682b..0613e67 100644 --- a/go/sigil/example_test.go +++ b/go/sigil/example_test.go @@ -4,7 +4,7 @@ import ( "context" "strings" - "github.com/grafana/sigil/sdks/go/sigil" + "github.com/grafana/sigil-sdk/go/sigil" ) func ExampleClient_StartGeneration() { diff --git a/go/sigil/exporter.go b/go/sigil/exporter.go index 477bc7a..56bea0c 100644 --- a/go/sigil/exporter.go +++ b/go/sigil/exporter.go @@ -3,6 +3,7 @@ package sigil import ( "context" "crypto/tls" + "encoding/base64" "errors" "fmt" "io" @@ -11,7 +12,7 @@ import ( "strings" "time" - sigilv1 "github.com/grafana/sigil/sdks/go/sigil/internal/gen/sigil/v1" + sigilv1 "github.com/grafana/sigil-sdk/go/sigil/internal/gen/sigil/v1" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" @@ -76,13 +77,31 @@ func newGRPCGenerationExporter(cfg GenerationExportConfig) (generationExporter, if err != nil { return nil, err } + maxSendMessageBytes := cfg.GRPCMaxSendMessageBytes + if maxSendMessageBytes <= 0 { + maxSendMessageBytes = defaultGRPCMaxSendMessageBytes + } + maxReceiveMessageBytes := cfg.GRPCMaxReceiveMessageBytes + if maxReceiveMessageBytes <= 0 { + maxReceiveMessageBytes = defaultGRPCMaxReceiveMessageBytes + } - transportCreds := credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12}) + transportCreds := credentials.NewTLS(&tls.Config{ + MinVersion: tls.VersionTLS12, + NextProtos: []string{"h2"}, + }) if cfg.Insecure || insecureEndpoint { transportCreds = insecure.NewCredentials() } - conn, err := grpc.NewClient(endpoint, grpc.WithTransportCredentials(transportCreds)) + conn, err := grpc.NewClient( + endpoint, + grpc.WithTransportCredentials(transportCreds), + grpc.WithDefaultCallOptions( + grpc.MaxCallSendMsgSize(maxSendMessageBytes), + grpc.MaxCallRecvMsgSize(maxReceiveMessageBytes), + ), + ) if err != nil { return nil, fmt.Errorf("dial generation ingest grpc endpoint %q: %w", endpoint, err) } @@ -115,7 +134,7 @@ type httpGenerationExporter struct { } func newHTTPGenerationExporter(cfg GenerationExportConfig) (generationExporter, error) { - endpoint, path, _, err := splitEndpoint(cfg.Endpoint) + endpoint, path, insecureEndpoint, err := splitEndpoint(cfg.Endpoint) if err != nil { return nil, err } @@ -123,7 +142,7 @@ func newHTTPGenerationExporter(cfg GenerationExportConfig) (generationExporter, urlString := endpoint if !strings.HasPrefix(urlString, "http://") && !strings.HasPrefix(urlString, "https://") { scheme := "https://" - if cfg.Insecure { + if cfg.Insecure || insecureEndpoint { scheme = "http://" } urlString = scheme + endpoint @@ -209,8 +228,12 @@ func mergeGenerationExportConfig(base, override GenerationExportConfig) Generati out.Headers = cloneTags(override.Headers) } out.Auth = mergeAuthConfig(out.Auth, override.Auth) - if override.Insecure { - out.Insecure = true + out.Insecure = override.Insecure + if override.GRPCMaxSendMessageBytes > 0 { + out.GRPCMaxSendMessageBytes = override.GRPCMaxSendMessageBytes + } + if override.GRPCMaxReceiveMessageBytes > 0 { + out.GRPCMaxReceiveMessageBytes = override.GRPCMaxReceiveMessageBytes } if override.BatchSize > 0 { out.BatchSize = override.BatchSize @@ -267,6 +290,12 @@ func mergeAuthConfig(base, override AuthConfig) AuthConfig { if override.BearerToken != "" { out.BearerToken = override.BearerToken } + if override.BasicUser != "" { + out.BasicUser = override.BasicUser + } + if override.BasicPassword != "" { + out.BasicPassword = override.BasicPassword + } return out } @@ -281,8 +310,10 @@ func resolveHeadersWithAuth(headers map[string]string, auth AuthConfig) (map[str switch mode { case ExportAuthModeNone: - if tenantID != "" || bearerToken != "" { - return nil, errors.New("auth mode none does not allow tenant_id or bearer_token") + basicUser := strings.TrimSpace(auth.BasicUser) + basicPassword := strings.TrimSpace(auth.BasicPassword) + if tenantID != "" || bearerToken != "" || basicUser != "" || basicPassword != "" { + return nil, errors.New("auth mode none does not allow credentials") } return cloneTags(headers), nil case ExportAuthModeTenant: @@ -317,6 +348,29 @@ func resolveHeadersWithAuth(headers map[string]string, auth AuthConfig) (map[str } out[authorizationHeaderName] = formatBearerTokenValue(bearerToken) return out, nil + case ExportAuthModeBasic: + password := strings.TrimSpace(auth.BasicPassword) + if password == "" { + return nil, errors.New("auth mode basic requires basic_password") + } + user := strings.TrimSpace(auth.BasicUser) + if user == "" { + user = tenantID + } + if user == "" { + return nil, errors.New("auth mode basic requires basic_user or tenant_id") + } + out := cloneTags(headers) + if out == nil { + out = make(map[string]string, 2) + } + if !hasHeaderKey(out, authorizationHeaderName) { + out[authorizationHeaderName] = "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+password)) + } + if tenantID != "" && !hasHeaderKey(out, tenantHeaderName) { + out[tenantHeaderName] = tenantID + } + return out, nil default: return nil, fmt.Errorf("unsupported auth mode %q", mode) } diff --git a/go/sigil/exporter_auth_test.go b/go/sigil/exporter_auth_test.go index 529fe0a..0c1575f 100644 --- a/go/sigil/exporter_auth_test.go +++ b/go/sigil/exporter_auth_test.go @@ -2,6 +2,7 @@ package sigil import ( "context" + "encoding/base64" "strings" "testing" "time" @@ -52,15 +53,80 @@ func TestResolveHeadersWithAuthExplicitHeaderWins(t *testing.T) { } } +func TestResolveHeadersWithAuthBasicMode(t *testing.T) { + headers, err := resolveHeadersWithAuth(nil, AuthConfig{ + Mode: ExportAuthModeBasic, + TenantID: "42", + BasicPassword: "secret", + }) + if err != nil { + t.Fatalf("resolve headers: %v", err) + } + wantAuth := "Basic " + base64Encode("42:secret") + if headers[authorizationHeaderName] != wantAuth { + t.Fatalf("expected %q, got %q", wantAuth, headers[authorizationHeaderName]) + } + if headers[tenantHeaderName] != "42" { + t.Fatalf("expected tenant header 42, got %q", headers[tenantHeaderName]) + } +} + +func TestResolveHeadersWithAuthBasicModeExplicitUser(t *testing.T) { + headers, err := resolveHeadersWithAuth(nil, AuthConfig{ + Mode: ExportAuthModeBasic, + TenantID: "42", + BasicUser: "probe-user", + BasicPassword: "secret", + }) + if err != nil { + t.Fatalf("resolve headers: %v", err) + } + wantAuth := "Basic " + base64Encode("probe-user:secret") + if headers[authorizationHeaderName] != wantAuth { + t.Fatalf("expected %q, got %q", wantAuth, headers[authorizationHeaderName]) + } + if headers[tenantHeaderName] != "42" { + t.Fatalf("expected tenant header 42, got %q", headers[tenantHeaderName]) + } +} + +func TestResolveHeadersWithAuthBasicModeExplicitHeaderWins(t *testing.T) { + headers, err := resolveHeadersWithAuth(map[string]string{ + "Authorization": "Basic override", + "X-Scope-OrgID": "override-tenant", + }, AuthConfig{ + Mode: ExportAuthModeBasic, + TenantID: "42", + BasicPassword: "secret", + }) + if err != nil { + t.Fatalf("resolve headers: %v", err) + } + if headers["Authorization"] != "Basic override" { + t.Fatalf("expected explicit header to win, got %q", headers["Authorization"]) + } + if headers["X-Scope-OrgID"] != "override-tenant" { + t.Fatalf("expected explicit tenant header to win, got %q", headers["X-Scope-OrgID"]) + } +} + +func base64Encode(s string) string { + return base64.StdEncoding.EncodeToString([]byte(s)) +} + func TestResolveHeadersWithAuthRejectsInvalidConfig(t *testing.T) { testCases := []AuthConfig{ {Mode: ExportAuthModeTenant}, {Mode: ExportAuthModeBearer}, {Mode: ExportAuthModeNone, TenantID: "tenant-a"}, {Mode: ExportAuthModeNone, BearerToken: "token"}, + {Mode: ExportAuthModeNone, BasicUser: "user"}, + {Mode: ExportAuthModeNone, BasicPassword: "secret"}, {Mode: ExportAuthModeTenant, TenantID: "tenant-a", BearerToken: "token"}, {Mode: ExportAuthModeBearer, TenantID: "tenant-a", BearerToken: "token"}, {Mode: ExportAuthMode("unknown"), TenantID: "tenant-a"}, + {Mode: ExportAuthModeBasic}, + {Mode: ExportAuthModeBasic, BasicPassword: "secret"}, } for _, testCase := range testCases { @@ -71,6 +137,50 @@ func TestResolveHeadersWithAuthRejectsInvalidConfig(t *testing.T) { } } +func TestMergeAuthConfigBasicFields(t *testing.T) { + base := AuthConfig{ + Mode: ExportAuthModeBearer, + TenantID: "base-tenant", + } + override := AuthConfig{ + Mode: ExportAuthModeBasic, + TenantID: "override-tenant", + BasicUser: "probe-user", + BasicPassword: "secret", + } + got := mergeAuthConfig(base, override) + + if got.Mode != ExportAuthModeBasic { + t.Fatalf("Mode=%q, want %q", got.Mode, ExportAuthModeBasic) + } + if got.TenantID != "override-tenant" { + t.Fatalf("TenantID=%q, want %q", got.TenantID, "override-tenant") + } + if got.BasicUser != "probe-user" { + t.Fatalf("BasicUser=%q, want %q", got.BasicUser, "probe-user") + } + if got.BasicPassword != "secret" { + t.Fatalf("BasicPassword=%q, want %q", got.BasicPassword, "secret") + } +} + +func TestMergeAuthConfigPreservesBaseBasicFields(t *testing.T) { + base := AuthConfig{ + Mode: ExportAuthModeBasic, + BasicUser: "base-user", + BasicPassword: "base-secret", + } + override := AuthConfig{} + got := mergeAuthConfig(base, override) + + if got.BasicUser != "base-user" { + t.Fatalf("BasicUser=%q, want %q", got.BasicUser, "base-user") + } + if got.BasicPassword != "base-secret" { + t.Fatalf("BasicPassword=%q, want %q", got.BasicPassword, "base-secret") + } +} + func TestNewClientPanicsOnInvalidAuthConfig(t *testing.T) { defer func() { recovered := recover() diff --git a/go/sigil/exporter_test.go b/go/sigil/exporter_test.go index b71710d..b3d6b06 100644 --- a/go/sigil/exporter_test.go +++ b/go/sigil/exporter_test.go @@ -167,6 +167,111 @@ func TestShutdownFlushesPendingGenerations(t *testing.T) { } } +func TestMergeGenerationExportConfigInsecure(t *testing.T) { + testCases := []struct { + name string + baseInsecure bool + overrideInsecure bool + wantInsecure bool + }{ + { + name: "override false replaces base true", + baseInsecure: true, + overrideInsecure: false, + wantInsecure: false, + }, + { + name: "override true replaces base false", + baseInsecure: false, + overrideInsecure: true, + wantInsecure: true, + }, + { + name: "both true remains true", + baseInsecure: true, + overrideInsecure: true, + wantInsecure: true, + }, + { + name: "both false remains false", + baseInsecure: false, + overrideInsecure: false, + wantInsecure: false, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + base := GenerationExportConfig{Insecure: testCase.baseInsecure} + override := GenerationExportConfig{Insecure: testCase.overrideInsecure} + got := mergeGenerationExportConfig(base, override) + if got.Insecure != testCase.wantInsecure { + t.Fatalf("insecure=%v, want %v", got.Insecure, testCase.wantInsecure) + } + }) + } +} + +func TestMergeGenerationExportConfigGRPCMessageLimits(t *testing.T) { + base := GenerationExportConfig{ + GRPCMaxSendMessageBytes: 2 << 20, + GRPCMaxReceiveMessageBytes: 3 << 20, + } + override := GenerationExportConfig{ + GRPCMaxSendMessageBytes: 8 << 20, + GRPCMaxReceiveMessageBytes: 9 << 20, + } + got := mergeGenerationExportConfig(base, override) + + if got.GRPCMaxSendMessageBytes != 8<<20 { + t.Fatalf("expected grpc max send 8MiB, got %d", got.GRPCMaxSendMessageBytes) + } + if got.GRPCMaxReceiveMessageBytes != 9<<20 { + t.Fatalf("expected grpc max receive 9MiB, got %d", got.GRPCMaxReceiveMessageBytes) + } +} + +func TestNewHTTPGenerationExporterUsesEndpointScheme(t *testing.T) { + testCases := []struct { + name string + endpoint string + insecure bool + wantURL string + }{ + { + name: "explicit http endpoint remains http", + endpoint: "http://localhost:8080/api/v1/generations:export", + insecure: false, + wantURL: "http://localhost:8080/api/v1/generations:export", + }, + { + name: "host endpoint uses insecure flag when no scheme", + endpoint: "localhost:8080/api/v1/generations:export", + insecure: true, + wantURL: "http://localhost:8080/api/v1/generations:export", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + exporter, err := newHTTPGenerationExporter(GenerationExportConfig{ + Endpoint: testCase.endpoint, + Insecure: testCase.insecure, + }) + if err != nil { + t.Fatalf("newHTTPGenerationExporter failed: %v", err) + } + httpExporter, ok := exporter.(*httpGenerationExporter) + if !ok { + t.Fatalf("unexpected exporter type %T", exporter) + } + if httpExporter.endpoint != testCase.wantURL { + t.Fatalf("endpoint=%q, want %q", httpExporter.endpoint, testCase.wantURL) + } + }) + } +} + func waitForCondition(timeout time.Duration, condition func() bool) error { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { diff --git a/go/sigil/exporter_transport_test.go b/go/sigil/exporter_transport_test.go index 1da0a24..6ba7b5d 100644 --- a/go/sigil/exporter_transport_test.go +++ b/go/sigil/exporter_transport_test.go @@ -8,11 +8,12 @@ import ( "net" "net/http" "net/http/httptest" + "strings" "sync" "testing" "time" - sigilv1 "github.com/grafana/sigil/sdks/go/sigil/internal/gen/sigil/v1" + sigilv1 "github.com/grafana/sigil-sdk/go/sigil/internal/gen/sigil/v1" "go.opentelemetry.io/otel/trace/noop" "google.golang.org/grpc" "google.golang.org/protobuf/encoding/protojson" @@ -44,6 +45,76 @@ func TestSDKExportsGenerationOverGRPC_AllPropertiesRoundTrip(t *testing.T) { } } +func TestSDKExportsGenerationOverGRPCAboveDefaultMessageLimit(t *testing.T) { + ingest := &capturingIngestServer{} + + grpcServer := grpc.NewServer( + grpc.MaxRecvMsgSize(defaultGRPCMaxReceiveMessageBytes), + grpc.MaxSendMsgSize(defaultGRPCMaxSendMessageBytes), + ) + sigilv1.RegisterGenerationIngestServiceServer(grpcServer, ingest) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen grpc: %v", err) + } + go func() { + _ = grpcServer.Serve(listener) + }() + t.Cleanup(func() { + grpcServer.Stop() + _ = listener.Close() + }) + + client := NewClient(Config{ + Tracer: noop.NewTracerProvider().Tracer("test"), + GenerationExport: GenerationExportConfig{ + Protocol: GenerationExportProtocolGRPC, + Endpoint: listener.Addr().String(), + Insecure: true, + GRPCMaxSendMessageBytes: defaultGRPCMaxSendMessageBytes, + GRPCMaxReceiveMessageBytes: defaultGRPCMaxReceiveMessageBytes, + PayloadMaxBytes: 8 << 20, + BatchSize: 1, + QueueSize: 10, + FlushInterval: time.Hour, + MaxRetries: 1, + InitialBackoff: time.Millisecond, + MaxBackoff: 10 * time.Millisecond, + }, + }) + + largeText := strings.Repeat("x", 5<<20) + _, rec := client.StartGeneration(context.Background(), GenerationStart{ + Model: ModelRef{ + Provider: "openai", + Name: "gpt-5", + }, + }) + rec.SetResult(Generation{ + Input: []Message{UserTextMessage(largeText)}, + Output: []Message{AssistantTextMessage("ok")}, + }, nil) + rec.End() + if err := rec.Err(); err != nil { + t.Fatalf("expected large grpc payload export to succeed, got %v", err) + } + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() + if err := client.Shutdown(shutdownCtx); err != nil { + t.Fatalf("shutdown client: %v", err) + } + + request := ingest.singleRequest(t) + if len(request.Generations) != 1 { + t.Fatalf("expected one generation in captured request, got %d", len(request.Generations)) + } + if got := request.Generations[0].GetInput()[0].GetParts()[0].GetText(); got != largeText { + t.Fatalf("unexpected large input text size=%d", len(got)) + } +} + func TestSDKExportRoundTripProperties(t *testing.T) { for seed := uint64(1); seed <= 20; seed++ { t.Run(fmt.Sprintf("seed-%d", seed), func(t *testing.T) { @@ -242,7 +313,7 @@ func payloadFromSeed(seed uint64) (GenerationStart, Generation) { }, SystemPrompt: "system-" + randomASCII(rnd, 10), Tools: []ToolDefinition{ - {Name: "tool-" + randomASCII(rnd, 5), Description: "desc-" + randomASCII(rnd, 6), Type: "function", InputSchema: []byte(`{"type":"object"}`)}, + {Name: "tool-" + randomASCII(rnd, 5), Description: "desc-" + randomASCII(rnd, 6), Type: "function", InputSchema: []byte(`{"type":"object"}`), Deferred: seed%2 == 0}, }, MaxTokens: int64Ptr(int64(rnd.Intn(1024) + 1)), Temperature: float64Ptr(float64(rnd.Intn(100)) / 100), @@ -288,12 +359,13 @@ func payloadFromSeed(seed uint64) (GenerationStart, Generation) { ToolChoice: stringPtr(*start.ToolChoice), ThinkingEnabled: boolPtr(*start.ThinkingEnabled), Usage: TokenUsage{ - InputTokens: int64(rnd.Intn(1000)), - OutputTokens: int64(rnd.Intn(1000)), - TotalTokens: int64(rnd.Intn(2000) + 1), - CacheReadInputTokens: int64(rnd.Intn(100)), - CacheWriteInputTokens: int64(rnd.Intn(100)), - ReasoningTokens: int64(rnd.Intn(100)), + InputTokens: int64(rnd.Intn(1000)), + OutputTokens: int64(rnd.Intn(1000)), + TotalTokens: int64(rnd.Intn(2000) + 1), + CacheReadInputTokens: int64(rnd.Intn(100)), + CacheWriteInputTokens: int64(rnd.Intn(100)), + CacheCreationInputTokens: int64(rnd.Intn(100)), + ReasoningTokens: int64(rnd.Intn(100)), }, StopReason: "stop-" + randomASCII(rnd, 4), StartedAt: startedAt, diff --git a/go/sigil/generation.go b/go/sigil/generation.go index 48f68c4..ed76c50 100644 --- a/go/sigil/generation.go +++ b/go/sigil/generation.go @@ -29,17 +29,20 @@ type ToolDefinition struct { Description string `json:"description,omitempty"` Type string `json:"type,omitempty"` InputSchema json.RawMessage `json:"input_schema,omitempty"` + Deferred bool `json:"deferred,omitempty"` } // Generation is the normalized, provider-agnostic generation payload. // It can represent both request/response and streaming outcomes. type Generation struct { // ID is the Sigil generation identifier. If empty, End assigns one. - ID string `json:"id,omitempty"` - ConversationID string `json:"conversation_id,omitempty"` - AgentName string `json:"agent_name,omitempty"` - AgentVersion string `json:"agent_version,omitempty"` - Mode GenerationMode `json:"mode,omitempty"` + ID string `json:"id,omitempty"` + ConversationID string `json:"conversation_id,omitempty"` + ConversationTitle string `json:"conversation_title,omitempty"` + UserID string `json:"user_id,omitempty"` + AgentName string `json:"agent_name,omitempty"` + AgentVersion string `json:"agent_version,omitempty"` + Mode GenerationMode `json:"mode,omitempty"` // OperationName maps to gen_ai.operation.name. // Defaults are mode-aware: // - SYNC -> "generateText" @@ -75,23 +78,25 @@ type Generation struct { // GenerationStart seeds generation fields before the provider call executes. // Any zero-valued fields can be filled later by End. type GenerationStart struct { - ID string - ConversationID string - AgentName string - AgentVersion string - Mode GenerationMode - OperationName string - Model ModelRef - SystemPrompt string - Tools []ToolDefinition - MaxTokens *int64 - Temperature *float64 - TopP *float64 - ToolChoice *string - ThinkingEnabled *bool - Tags map[string]string - Metadata map[string]any - StartedAt time.Time + ID string + ConversationID string + ConversationTitle string + UserID string + AgentName string + AgentVersion string + Mode GenerationMode + OperationName string + Model ModelRef + SystemPrompt string + Tools []ToolDefinition + MaxTokens *int64 + Temperature *float64 + TopP *float64 + ToolChoice *string + ThinkingEnabled *bool + Tags map[string]string + Metadata map[string]any + StartedAt time.Time } func (g Generation) Validate() error { @@ -107,56 +112,60 @@ func defaultOperationNameForMode(mode GenerationMode) string { func cloneGeneration(in Generation) Generation { return Generation{ - ID: in.ID, - ConversationID: in.ConversationID, - AgentName: in.AgentName, - AgentVersion: in.AgentVersion, - Mode: in.Mode, - OperationName: in.OperationName, - TraceID: in.TraceID, - SpanID: in.SpanID, - Model: in.Model, - ResponseID: in.ResponseID, - ResponseModel: in.ResponseModel, - SystemPrompt: in.SystemPrompt, - Input: cloneMessages(in.Input), - Output: cloneMessages(in.Output), - Tools: cloneTools(in.Tools), - MaxTokens: cloneInt64Ptr(in.MaxTokens), - Temperature: cloneFloat64Ptr(in.Temperature), - TopP: cloneFloat64Ptr(in.TopP), - ToolChoice: cloneStringPtr(in.ToolChoice), - ThinkingEnabled: cloneBoolPtr(in.ThinkingEnabled), - Usage: in.Usage, - StopReason: in.StopReason, - StartedAt: in.StartedAt, - CompletedAt: in.CompletedAt, - Tags: cloneTags(in.Tags), - Metadata: cloneMetadata(in.Metadata), - Artifacts: cloneArtifacts(in.Artifacts), - CallError: in.CallError, + ID: in.ID, + ConversationID: in.ConversationID, + ConversationTitle: in.ConversationTitle, + UserID: in.UserID, + AgentName: in.AgentName, + AgentVersion: in.AgentVersion, + Mode: in.Mode, + OperationName: in.OperationName, + TraceID: in.TraceID, + SpanID: in.SpanID, + Model: in.Model, + ResponseID: in.ResponseID, + ResponseModel: in.ResponseModel, + SystemPrompt: in.SystemPrompt, + Input: cloneMessages(in.Input), + Output: cloneMessages(in.Output), + Tools: cloneTools(in.Tools), + MaxTokens: cloneInt64Ptr(in.MaxTokens), + Temperature: cloneFloat64Ptr(in.Temperature), + TopP: cloneFloat64Ptr(in.TopP), + ToolChoice: cloneStringPtr(in.ToolChoice), + ThinkingEnabled: cloneBoolPtr(in.ThinkingEnabled), + Usage: in.Usage, + StopReason: in.StopReason, + StartedAt: in.StartedAt, + CompletedAt: in.CompletedAt, + Tags: cloneTags(in.Tags), + Metadata: cloneMetadata(in.Metadata), + Artifacts: cloneArtifacts(in.Artifacts), + CallError: in.CallError, } } func cloneGenerationStart(in GenerationStart) GenerationStart { return GenerationStart{ - ID: in.ID, - ConversationID: in.ConversationID, - AgentName: in.AgentName, - AgentVersion: in.AgentVersion, - Mode: in.Mode, - OperationName: in.OperationName, - Model: in.Model, - SystemPrompt: in.SystemPrompt, - Tools: cloneTools(in.Tools), - MaxTokens: cloneInt64Ptr(in.MaxTokens), - Temperature: cloneFloat64Ptr(in.Temperature), - TopP: cloneFloat64Ptr(in.TopP), - ToolChoice: cloneStringPtr(in.ToolChoice), - ThinkingEnabled: cloneBoolPtr(in.ThinkingEnabled), - Tags: cloneTags(in.Tags), - Metadata: cloneMetadata(in.Metadata), - StartedAt: in.StartedAt, + ID: in.ID, + ConversationID: in.ConversationID, + ConversationTitle: in.ConversationTitle, + UserID: in.UserID, + AgentName: in.AgentName, + AgentVersion: in.AgentVersion, + Mode: in.Mode, + OperationName: in.OperationName, + Model: in.Model, + SystemPrompt: in.SystemPrompt, + Tools: cloneTools(in.Tools), + MaxTokens: cloneInt64Ptr(in.MaxTokens), + Temperature: cloneFloat64Ptr(in.Temperature), + TopP: cloneFloat64Ptr(in.TopP), + ToolChoice: cloneStringPtr(in.ToolChoice), + ThinkingEnabled: cloneBoolPtr(in.ThinkingEnabled), + Tags: cloneTags(in.Tags), + Metadata: cloneMetadata(in.Metadata), + StartedAt: in.StartedAt, } } diff --git a/go/sigil/internal/gen/sigil/v1/generation_ingest.pb.go b/go/sigil/internal/gen/sigil/v1/generation_ingest.pb.go index 1e036a3..ea0401f 100644 --- a/go/sigil/internal/gen/sigil/v1/generation_ingest.pb.go +++ b/go/sigil/internal/gen/sigil/v1/generation_ingest.pb.go @@ -747,6 +747,7 @@ type ToolDefinition struct { Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` Type string `protobuf:"bytes,3,opt,name=type,proto3" json:"type,omitempty"` InputSchemaJson []byte `protobuf:"bytes,4,opt,name=input_schema_json,json=inputSchemaJson,proto3" json:"input_schema_json,omitempty"` + Deferred bool `protobuf:"varint,5,opt,name=deferred,proto3" json:"deferred,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -809,16 +810,24 @@ func (x *ToolDefinition) GetInputSchemaJson() []byte { return nil } +func (x *ToolDefinition) GetDeferred() bool { + if x != nil { + return x.Deferred + } + return false +} + type TokenUsage struct { - state protoimpl.MessageState `protogen:"open.v1"` - InputTokens int64 `protobuf:"varint,1,opt,name=input_tokens,json=inputTokens,proto3" json:"input_tokens,omitempty"` - OutputTokens int64 `protobuf:"varint,2,opt,name=output_tokens,json=outputTokens,proto3" json:"output_tokens,omitempty"` - TotalTokens int64 `protobuf:"varint,3,opt,name=total_tokens,json=totalTokens,proto3" json:"total_tokens,omitempty"` - CacheReadInputTokens int64 `protobuf:"varint,4,opt,name=cache_read_input_tokens,json=cacheReadInputTokens,proto3" json:"cache_read_input_tokens,omitempty"` - CacheWriteInputTokens int64 `protobuf:"varint,5,opt,name=cache_write_input_tokens,json=cacheWriteInputTokens,proto3" json:"cache_write_input_tokens,omitempty"` - ReasoningTokens int64 `protobuf:"varint,6,opt,name=reasoning_tokens,json=reasoningTokens,proto3" json:"reasoning_tokens,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + InputTokens int64 `protobuf:"varint,1,opt,name=input_tokens,json=inputTokens,proto3" json:"input_tokens,omitempty"` + OutputTokens int64 `protobuf:"varint,2,opt,name=output_tokens,json=outputTokens,proto3" json:"output_tokens,omitempty"` + TotalTokens int64 `protobuf:"varint,3,opt,name=total_tokens,json=totalTokens,proto3" json:"total_tokens,omitempty"` + CacheReadInputTokens int64 `protobuf:"varint,4,opt,name=cache_read_input_tokens,json=cacheReadInputTokens,proto3" json:"cache_read_input_tokens,omitempty"` + CacheWriteInputTokens int64 `protobuf:"varint,5,opt,name=cache_write_input_tokens,json=cacheWriteInputTokens,proto3" json:"cache_write_input_tokens,omitempty"` + ReasoningTokens int64 `protobuf:"varint,6,opt,name=reasoning_tokens,json=reasoningTokens,proto3" json:"reasoning_tokens,omitempty"` + CacheCreationInputTokens int64 `protobuf:"varint,7,opt,name=cache_creation_input_tokens,json=cacheCreationInputTokens,proto3" json:"cache_creation_input_tokens,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *TokenUsage) Reset() { @@ -893,6 +902,13 @@ func (x *TokenUsage) GetReasoningTokens() int64 { return 0 } +func (x *TokenUsage) GetCacheCreationInputTokens() int64 { + if x != nil { + return x.CacheCreationInputTokens + } + return 0 +} + type Artifact struct { state protoimpl.MessageState `protogen:"open.v1"` Kind ArtifactKind `protobuf:"varint,1,opt,name=kind,proto3,enum=sigil.v1.ArtifactKind" json:"kind,omitempty"` @@ -1279,12 +1295,13 @@ const file_sigil_v1_generation_ingest_proto_rawDesc = "" + "\aMessage\x12)\n" + "\x04role\x18\x01 \x01(\x0e2\x15.sigil.v1.MessageRoleR\x04role\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12$\n" + - "\x05parts\x18\x03 \x03(\v2\x0e.sigil.v1.PartR\x05parts\"\x86\x01\n" + + "\x05parts\x18\x03 \x03(\v2\x0e.sigil.v1.PartR\x05parts\"\xa2\x01\n" + "\x0eToolDefinition\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + "\vdescription\x18\x02 \x01(\tR\vdescription\x12\x12\n" + "\x04type\x18\x03 \x01(\tR\x04type\x12*\n" + - "\x11input_schema_json\x18\x04 \x01(\fR\x0finputSchemaJson\"\x92\x02\n" + + "\x11input_schema_json\x18\x04 \x01(\fR\x0finputSchemaJson\x12\x1a\n" + + "\bdeferred\x18\x05 \x01(\bR\bdeferred\"\xd1\x02\n" + "\n" + "TokenUsage\x12!\n" + "\finput_tokens\x18\x01 \x01(\x03R\vinputTokens\x12#\n" + @@ -1292,7 +1309,8 @@ const file_sigil_v1_generation_ingest_proto_rawDesc = "" + "\ftotal_tokens\x18\x03 \x01(\x03R\vtotalTokens\x125\n" + "\x17cache_read_input_tokens\x18\x04 \x01(\x03R\x14cacheReadInputTokens\x127\n" + "\x18cache_write_input_tokens\x18\x05 \x01(\x03R\x15cacheWriteInputTokens\x12)\n" + - "\x10reasoning_tokens\x18\x06 \x01(\x03R\x0freasoningTokens\"\xb6\x01\n" + + "\x10reasoning_tokens\x18\x06 \x01(\x03R\x0freasoningTokens\x12=\n" + + "\x1bcache_creation_input_tokens\x18\a \x01(\x03R\x18cacheCreationInputTokens\"\xb6\x01\n" + "\bArtifact\x12*\n" + "\x04kind\x18\x01 \x01(\x0e2\x16.sigil.v1.ArtifactKindR\x04kind\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12!\n" + diff --git a/go/sigil/proto_mapping.go b/go/sigil/proto_mapping.go index 87ada5a..51a5ede 100644 --- a/go/sigil/proto_mapping.go +++ b/go/sigil/proto_mapping.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - sigilv1 "github.com/grafana/sigil/sdks/go/sigil/internal/gen/sigil/v1" + sigilv1 "github.com/grafana/sigil-sdk/go/sigil/internal/gen/sigil/v1" "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -172,6 +172,7 @@ func mapToolsToProto(tools []ToolDefinition) []*sigilv1.ToolDefinition { Description: tools[i].Description, Type: tools[i].Type, InputSchemaJson: append([]byte(nil), tools[i].InputSchema...), + Deferred: tools[i].Deferred, }) } return out @@ -179,12 +180,13 @@ func mapToolsToProto(tools []ToolDefinition) []*sigilv1.ToolDefinition { func mapUsageToProto(usage TokenUsage) *sigilv1.TokenUsage { return &sigilv1.TokenUsage{ - InputTokens: usage.InputTokens, - OutputTokens: usage.OutputTokens, - TotalTokens: usage.TotalTokens, - CacheReadInputTokens: usage.CacheReadInputTokens, - CacheWriteInputTokens: usage.CacheWriteInputTokens, - ReasoningTokens: usage.ReasoningTokens, + InputTokens: usage.InputTokens, + OutputTokens: usage.OutputTokens, + TotalTokens: usage.TotalTokens, + CacheReadInputTokens: usage.CacheReadInputTokens, + CacheWriteInputTokens: usage.CacheWriteInputTokens, + ReasoningTokens: usage.ReasoningTokens, + CacheCreationInputTokens: usage.CacheCreationInputTokens, } } diff --git a/go/sigil/sigiltest/env.go b/go/sigil/sigiltest/env.go new file mode 100644 index 0000000..66f5aa0 --- /dev/null +++ b/go/sigil/sigiltest/env.go @@ -0,0 +1,304 @@ +package sigiltest + +import ( + "context" + "encoding/json" + "fmt" + "net" + "sync" + "testing" + "time" + + sigil "github.com/grafana/sigil-sdk/go/sigil" + sigilv1 "github.com/grafana/sigil-sdk/go/sigil/internal/gen/sigil/v1" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "google.golang.org/grpc" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +type Env struct { + Client *sigil.Client + Spans *tracetest.SpanRecorder + Metrics *sdkmetric.ManualReader + + ingest *capturingIngestServer + + tracerProvider *sdktrace.TracerProvider + meterProvider *sdkmetric.MeterProvider + grpcServer *grpc.Server + listener net.Listener + closeOnce sync.Once +} + +func NewEnv(t testing.TB) *Env { + t.Helper() + + ingest := &capturingIngestServer{} + grpcServer := grpc.NewServer() + sigilv1.RegisterGenerationIngestServiceServer(grpcServer, ingest) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen for fake ingest server: %v", err) + } + + go func() { + _ = grpcServer.Serve(listener) + }() + + spanRecorder := tracetest.NewSpanRecorder() + tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + metricReader := sdkmetric.NewManualReader() + meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(metricReader)) + + cfg := sigil.DefaultConfig() + cfg.Tracer = tracerProvider.Tracer("sigil-provider-conformance") + cfg.Meter = meterProvider.Meter("sigil-provider-conformance") + cfg.GenerationExport = sigil.GenerationExportConfig{ + Protocol: sigil.GenerationExportProtocolGRPC, + Endpoint: listener.Addr().String(), + Insecure: true, + BatchSize: 1, + FlushInterval: time.Hour, + QueueSize: 8, + MaxRetries: 1, + InitialBackoff: time.Millisecond, + MaxBackoff: 5 * time.Millisecond, + PayloadMaxBytes: 4 << 20, + } + + env := &Env{ + Client: sigil.NewClient(cfg), + Spans: spanRecorder, + Metrics: metricReader, + ingest: ingest, + tracerProvider: tracerProvider, + meterProvider: meterProvider, + grpcServer: grpcServer, + listener: listener, + } + t.Cleanup(func() { + if err := env.close(); err != nil { + t.Errorf("close sigil test env: %v", err) + } + }) + return env +} + +func (e *Env) Shutdown(t testing.TB) { + t.Helper() + + if err := e.close(); err != nil { + t.Fatalf("shutdown sigil client: %v", err) + } +} + +func (e *Env) RequestCount() int { + if e == nil || e.ingest == nil { + return 0 + } + return e.ingest.requestCount() +} + +func (e *Env) SingleGenerationJSON(t testing.TB) map[string]any { + t.Helper() + + req := e.singleRequest(t) + if len(req.GetGenerations()) != 1 { + t.Fatalf("expected exactly one generation in request, got %d", len(req.GetGenerations())) + } + + generationJSON, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(req.GetGenerations()[0]) + if err != nil { + t.Fatalf("marshal generation json: %v", err) + } + + var generation map[string]any + if err := json.Unmarshal(generationJSON, &generation); err != nil { + t.Fatalf("decode generation json: %v", err) + } + return generation +} + +func (e *Env) close() error { + if e == nil { + return nil + } + + var closeErr error + e.closeOnce.Do(func() { + if e.Client != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := e.Client.Shutdown(ctx); err != nil { + closeErr = err + } + } + if e.meterProvider != nil { + if err := e.meterProvider.Shutdown(context.Background()); err != nil && closeErr == nil { + closeErr = err + } + } + if e.tracerProvider != nil { + if err := e.tracerProvider.Shutdown(context.Background()); err != nil && closeErr == nil { + closeErr = err + } + } + if e.grpcServer != nil { + e.grpcServer.Stop() + } + if e.listener != nil { + _ = e.listener.Close() + } + }) + return closeErr +} + +func (e *Env) singleRequest(t testing.TB) *sigilv1.ExportGenerationsRequest { + t.Helper() + + if e == nil || e.ingest == nil { + t.Fatalf("sigil test env has no ingest server") + } + return e.ingest.singleRequest(t) +} + +type capturingIngestServer struct { + sigilv1.UnimplementedGenerationIngestServiceServer + + mu sync.Mutex + requests []*sigilv1.ExportGenerationsRequest +} + +func (s *capturingIngestServer) ExportGenerations(_ context.Context, req *sigilv1.ExportGenerationsRequest) (*sigilv1.ExportGenerationsResponse, error) { + s.capture(req) + return acceptedResponse(req), nil +} + +func (s *capturingIngestServer) capture(req *sigilv1.ExportGenerationsRequest) { + if req == nil { + return + } + + clone := proto.Clone(req) + typed, ok := clone.(*sigilv1.ExportGenerationsRequest) + if !ok { + return + } + + s.mu.Lock() + s.requests = append(s.requests, typed) + s.mu.Unlock() +} + +func (s *capturingIngestServer) requestCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.requests) +} + +func (s *capturingIngestServer) singleRequest(t testing.TB) *sigilv1.ExportGenerationsRequest { + t.Helper() + + s.mu.Lock() + defer s.mu.Unlock() + + if len(s.requests) != 1 { + t.Fatalf("expected exactly one export request, got %d", len(s.requests)) + } + return s.requests[0] +} + +func acceptedResponse(req *sigilv1.ExportGenerationsRequest) *sigilv1.ExportGenerationsResponse { + response := &sigilv1.ExportGenerationsResponse{Results: make([]*sigilv1.ExportGenerationResult, len(req.GetGenerations()))} + for i := range req.GetGenerations() { + response.Results[i] = &sigilv1.ExportGenerationResult{ + Accepted: true, + } + } + return response +} + +func JSONPath(t testing.TB, value any, path ...any) any { + t.Helper() + + current := value + for _, step := range path { + switch typed := step.(type) { + case string: + node, ok := current.(map[string]any) + if !ok { + t.Fatalf("path step %q expected object, got %T", typed, current) + } + next, ok := node[typed] + if !ok { + t.Fatalf("path step %q missing in object keys %v", typed, mapsKeys(node)) + } + current = next + case int: + node, ok := current.([]any) + if !ok { + t.Fatalf("path step %d expected array, got %T", typed, current) + } + if typed < 0 || typed >= len(node) { + t.Fatalf("path index %d out of range for len %d", typed, len(node)) + } + current = node[typed] + default: + t.Fatalf("unsupported path step type %T", step) + } + } + return current +} + +func mapsKeys(values map[string]any) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + return keys +} + +func StringValue(t testing.TB, value any, path ...any) string { + t.Helper() + + resolved := JSONPath(t, value, path...) + text, ok := resolved.(string) + if !ok { + t.Fatalf("path %v expected string, got %T (%v)", path, resolved, resolved) + } + return text +} + +func FloatValue(t testing.TB, value any, path ...any) float64 { + t.Helper() + + resolved := JSONPath(t, value, path...) + number, ok := resolved.(float64) + if !ok { + t.Fatalf("path %v expected number, got %T (%v)", path, resolved, resolved) + } + return number +} + +func RequireRequestCount(t testing.TB, env *Env, want int) { + t.Helper() + + if env == nil { + t.Fatalf("sigil test env is nil") + } + if got := env.RequestCount(); got != want { + t.Fatalf("unexpected export request count: got %d want %d", got, want) + } +} + +func DebugJSON(value any) string { + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + return fmt.Sprintf("%v", value) + } + return string(encoded) +} diff --git a/go/sigil/sigiltest/record.go b/go/sigil/sigiltest/record.go new file mode 100644 index 0000000..e8de10f --- /dev/null +++ b/go/sigil/sigiltest/record.go @@ -0,0 +1,72 @@ +package sigiltest + +import ( + "context" + "testing" + "time" + + sigil "github.com/grafana/sigil-sdk/go/sigil" +) + +func RecordGeneration(t testing.TB, env *Env, start sigil.GenerationStart, generation sigil.Generation, mapErr error) { + t.Helper() + + if env == nil || env.Client == nil { + t.Fatalf("sigil test env is not initialized") + } + + _, recorder := env.Client.StartGeneration(context.Background(), start) + recorder.SetResult(generation, mapErr) + recorder.End() + if err := recorder.Err(); err != nil { + t.Fatalf("record generation: %v", err) + } +} + +func RecordStreamingGeneration(t testing.TB, env *Env, start sigil.GenerationStart, firstTokenAt time.Time, generation sigil.Generation, mapErr error) { + t.Helper() + + if env == nil || env.Client == nil { + t.Fatalf("sigil test env is not initialized") + } + + _, recorder := env.Client.StartStreamingGeneration(context.Background(), start) + if !firstTokenAt.IsZero() { + recorder.SetFirstTokenAt(firstTokenAt) + } + recorder.SetResult(generation, mapErr) + recorder.End() + if err := recorder.Err(); err != nil { + t.Fatalf("record streaming generation: %v", err) + } +} + +func RecordCallError(t testing.TB, env *Env, start sigil.GenerationStart, callErr error) { + t.Helper() + + if env == nil || env.Client == nil { + t.Fatalf("sigil test env is not initialized") + } + + _, recorder := env.Client.StartGeneration(context.Background(), start) + recorder.SetCallError(callErr) + recorder.End() + if err := recorder.Err(); err != nil { + t.Fatalf("record call error: %v", err) + } +} + +func RecordEmbedding(t testing.TB, env *Env, start sigil.EmbeddingStart, result sigil.EmbeddingResult) { + t.Helper() + + if env == nil || env.Client == nil { + t.Fatalf("sigil test env is not initialized") + } + + _, recorder := env.Client.StartEmbedding(context.Background(), start) + recorder.SetResult(result) + recorder.End() + if err := recorder.Err(); err != nil { + t.Fatalf("record embedding: %v", err) + } +} diff --git a/go/sigil/sigiltest/spans.go b/go/sigil/sigiltest/spans.go new file mode 100644 index 0000000..c699c1d --- /dev/null +++ b/go/sigil/sigiltest/spans.go @@ -0,0 +1,32 @@ +package sigiltest + +import ( + "testing" + + "go.opentelemetry.io/otel/attribute" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +func FindSpan(t testing.TB, spans []sdktrace.ReadOnlySpan, name string) sdktrace.ReadOnlySpan { + t.Helper() + + for i := range spans { + if spans[i].Name() == name { + return spans[i] + } + } + t.Fatalf("span %q not found", name) + return nil +} + +func SpanAttributes(span sdktrace.ReadOnlySpan) map[string]attribute.Value { + if span == nil { + return nil + } + + attrs := make(map[string]attribute.Value, len(span.Attributes())) + for _, attr := range span.Attributes() { + attrs[string(attr.Key)] = attr.Value + } + return attrs +} diff --git a/go/sigil/tool.go b/go/sigil/tool.go index 0fa04fa..09c7784 100644 --- a/go/sigil/tool.go +++ b/go/sigil/tool.go @@ -4,13 +4,18 @@ import "time" // ToolExecutionStart seeds a tool execution span before the tool call runs. type ToolExecutionStart struct { - ToolName string - ToolCallID string - ToolType string - ToolDescription string - ConversationID string - AgentName string - AgentVersion string + ToolName string + ToolCallID string + ToolType string + ToolDescription string + ConversationID string + ConversationTitle string + AgentName string + AgentVersion string + // RequestModel is the model that requested the tool call (e.g. "gpt-5"). + RequestModel string + // RequestProvider is the provider that served the model (e.g. "openai"). + RequestProvider string StartedAt time.Time // IncludeContent enables gen_ai.tool.call.arguments and gen_ai.tool.call.result attributes. IncludeContent bool diff --git a/go/sigil/validation.go b/go/sigil/validation.go index 8b25124..210a8e4 100644 --- a/go/sigil/validation.go +++ b/go/sigil/validation.go @@ -103,10 +103,10 @@ func validatePart(path string, messageIndex, partIndex int, role Role, part Part } fieldCount := 0 - if strings.TrimSpace(part.Text) != "" { + if part.Text != "" { fieldCount++ } - if strings.TrimSpace(part.Thinking) != "" { + if part.Thinking != "" { fieldCount++ } if part.ToolCall != nil { @@ -122,14 +122,14 @@ func validatePart(path string, messageIndex, partIndex int, role Role, part Part switch part.Kind { case PartKindText: - if strings.TrimSpace(part.Text) == "" { + if part.Text == "" { return fmt.Errorf("%s[%d].parts[%d].text is required", path, messageIndex, partIndex) } case PartKindThinking: if role != RoleAssistant { return fmt.Errorf("%s[%d].parts[%d].thinking only allowed for assistant role", path, messageIndex, partIndex) } - if strings.TrimSpace(part.Thinking) == "" { + if part.Thinking == "" { return fmt.Errorf("%s[%d].parts[%d].thinking is required", path, messageIndex, partIndex) } case PartKindToolCall: @@ -146,6 +146,9 @@ func validatePart(path string, messageIndex, partIndex int, role Role, part Part if part.ToolResult == nil { return fmt.Errorf("%s[%d].parts[%d].tool_result is required", path, messageIndex, partIndex) } + if strings.TrimSpace(part.ToolResult.ToolCallID) == "" && strings.TrimSpace(part.ToolResult.Name) == "" { + return fmt.Errorf("%s[%d].parts[%d].tool_result.tool_call_id or name is required", path, messageIndex, partIndex) + } } return nil diff --git a/go/sigil/validation_test.go b/go/sigil/validation_test.go index 6ab1124..3e81cf4 100644 --- a/go/sigil/validation_test.go +++ b/go/sigil/validation_test.go @@ -47,6 +47,38 @@ func TestValidateGenerationRolePartCompatibility(t *testing.T) { } }) + t.Run("tool result requires correlation key", func(t *testing.T) { + g := cloneGeneration(base) + g.Input = append(g.Input, Message{ + Role: RoleTool, + Parts: []Part{ + ToolResultPart(ToolResult{Content: "sunny"}), + }, + }) + + err := ValidateGeneration(g) + if err == nil { + t.Fatalf("expected validation error") + } + if !strings.Contains(err.Error(), "tool_result.tool_call_id or name is required") { + t.Fatalf("expected correlation validation error, got %q", err.Error()) + } + }) + + t.Run("tool result allows name fallback without tool call id", func(t *testing.T) { + g := cloneGeneration(base) + g.Input = append(g.Input, Message{ + Role: RoleTool, + Parts: []Part{ + ToolResultPart(ToolResult{Name: "weather", Content: "sunny"}), + }, + }) + + if err := ValidateGeneration(g); err != nil { + t.Fatalf("expected valid generation, got %v", err) + } + }) + t.Run("thinking only assistant", func(t *testing.T) { g := cloneGeneration(base) g.Input = append(g.Input, Message{ @@ -107,3 +139,28 @@ func TestValidateGenerationAllowsConversationAndResponseFields(t *testing.T) { t.Fatalf("expected valid generation, got %v", err) } } + +func TestValidateGenerationAllowsWhitespaceOnlyTextAndThinking(t *testing.T) { + g := Generation{ + Model: ModelRef{ + Provider: "anthropic", + Name: "claude-sonnet-4-5", + }, + Input: []Message{ + { + Role: RoleUser, + Parts: []Part{TextPart(" ")}, + }, + }, + Output: []Message{ + { + Role: RoleAssistant, + Parts: []Part{ThinkingPart(" \n\t ")}, + }, + }, + } + + if err := ValidateGeneration(g); err != nil { + t.Fatalf("expected valid generation, got %v", err) + } +} diff --git a/java/README.md b/java/README.md index e2481c3..9ff2351 100644 --- a/java/README.md +++ b/java/README.md @@ -136,9 +136,31 @@ Auth is configured for generation export: - `NONE` - `TENANT` (injects `X-Scope-OrgID`) - `BEARER` (injects `Authorization: Bearer `) +- `BASIC` (requires `basicPassword` + `basicUser` or `tenantId`, injects `Authorization: Basic `; also injects `X-Scope-OrgID` when `tenantId` is set — for self-hosted multi-tenancy only, not needed for Grafana Cloud) Invalid combinations fail fast at client construction. If explicit headers already contain `Authorization` or `X-Scope-OrgID`, explicit headers win. +### Grafana Cloud auth (basic) + +For Grafana Cloud, use `BASIC` auth mode. The username is your Grafana Cloud instance/tenant ID and the password is your Grafana Cloud API key: + +```java +.setAuth(new AuthConfig() + .setMode(AuthMode.BASIC) + .setTenantId(System.getenv("GRAFANA_CLOUD_INSTANCE_ID")) + .setBasicPassword(System.getenv("GRAFANA_CLOUD_API_KEY"))) +``` + +If your deployment requires a distinct username, set `basicUser` explicitly: + +```java +.setAuth(new AuthConfig() + .setMode(AuthMode.BASIC) + .setTenantId(System.getenv("GRAFANA_CLOUD_INSTANCE_ID")) + .setBasicUser(System.getenv("GRAFANA_CLOUD_INSTANCE_ID")) + .setBasicPassword(System.getenv("GRAFANA_CLOUD_API_KEY"))) +``` + Generation export transport protocols: - `GenerationExportProtocol.HTTP` diff --git a/java/core/build.gradle.kts b/java/core/build.gradle.kts index ed0653d..0830200 100644 --- a/java/core/build.gradle.kts +++ b/java/core/build.gradle.kts @@ -8,7 +8,7 @@ java { withSourcesJar() } -val protoRoot = rootProject.projectDir.resolve("../../sigil/proto") +val protoRoot = rootProject.projectDir.resolve("../proto") dependencies { api(libs.otel.api) diff --git a/java/core/src/main/java/com/grafana/sigil/sdk/AuthConfig.java b/java/core/src/main/java/com/grafana/sigil/sdk/AuthConfig.java index d6d1cc4..b3d871d 100644 --- a/java/core/src/main/java/com/grafana/sigil/sdk/AuthConfig.java +++ b/java/core/src/main/java/com/grafana/sigil/sdk/AuthConfig.java @@ -5,6 +5,8 @@ public final class AuthConfig { private AuthMode mode = AuthMode.NONE; private String tenantId = ""; private String bearerToken = ""; + private String basicUser = ""; + private String basicPassword = ""; public AuthMode getMode() { return mode; @@ -33,7 +35,32 @@ public AuthConfig setBearerToken(String bearerToken) { return this; } + /** Username for basic auth. When empty, tenantId is used. */ + public String getBasicUser() { + return basicUser; + } + + public AuthConfig setBasicUser(String basicUser) { + this.basicUser = basicUser == null ? "" : basicUser; + return this; + } + + /** Password/token for basic auth. */ + public String getBasicPassword() { + return basicPassword; + } + + public AuthConfig setBasicPassword(String basicPassword) { + this.basicPassword = basicPassword == null ? "" : basicPassword; + return this; + } + public AuthConfig copy() { - return new AuthConfig().setMode(mode).setTenantId(tenantId).setBearerToken(bearerToken); + return new AuthConfig() + .setMode(mode) + .setTenantId(tenantId) + .setBearerToken(bearerToken) + .setBasicUser(basicUser) + .setBasicPassword(basicPassword); } } diff --git a/java/core/src/main/java/com/grafana/sigil/sdk/AuthHeaders.java b/java/core/src/main/java/com/grafana/sigil/sdk/AuthHeaders.java index 35498ad..be5ab11 100644 --- a/java/core/src/main/java/com/grafana/sigil/sdk/AuthHeaders.java +++ b/java/core/src/main/java/com/grafana/sigil/sdk/AuthHeaders.java @@ -1,5 +1,7 @@ package com.grafana.sigil.sdk; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.LinkedHashMap; import java.util.Map; @@ -21,8 +23,10 @@ static Map resolve(Map headers, AuthConfig auth, String bearer = auth == null ? "" : auth.getBearerToken().trim(); if (mode == AuthMode.NONE) { - if (!tenantId.isEmpty() || !bearer.isEmpty()) { - throw new IllegalArgumentException(label + " auth mode 'none' does not allow tenantId or bearerToken"); + String basicUser = auth == null ? "" : auth.getBasicUser().trim(); + String basicPassword = auth == null ? "" : auth.getBasicPassword().trim(); + if (!tenantId.isEmpty() || !bearer.isEmpty() || !basicUser.isEmpty() || !basicPassword.isEmpty()) { + throw new IllegalArgumentException(label + " auth mode 'none' does not allow credentials"); } return out; } @@ -53,6 +57,29 @@ static Map resolve(Map headers, AuthConfig auth, return out; } + if (mode == AuthMode.BASIC) { + String password = auth == null ? "" : auth.getBasicPassword().trim(); + if (password.isEmpty()) { + throw new IllegalArgumentException(label + " auth mode 'basic' requires basicPassword"); + } + String user = auth == null ? "" : auth.getBasicUser().trim(); + if (user.isEmpty()) { + user = tenantId; + } + if (user.isEmpty()) { + throw new IllegalArgumentException(label + " auth mode 'basic' requires basicUser or tenantId"); + } + if (!hasHeader(out, AUTHORIZATION_HEADER)) { + String encoded = Base64.getEncoder().encodeToString( + (user + ":" + password).getBytes(StandardCharsets.UTF_8)); + out.put(AUTHORIZATION_HEADER, "Basic " + encoded); + } + if (!tenantId.isEmpty() && !hasHeader(out, TENANT_HEADER)) { + out.put(TENANT_HEADER, tenantId); + } + return out; + } + throw new IllegalArgumentException("unsupported " + label + " auth mode " + mode); } diff --git a/java/core/src/main/java/com/grafana/sigil/sdk/AuthMode.java b/java/core/src/main/java/com/grafana/sigil/sdk/AuthMode.java index f6f1447..ba41a47 100644 --- a/java/core/src/main/java/com/grafana/sigil/sdk/AuthMode.java +++ b/java/core/src/main/java/com/grafana/sigil/sdk/AuthMode.java @@ -3,5 +3,6 @@ public enum AuthMode { NONE, TENANT, - BEARER + BEARER, + BASIC } diff --git a/java/core/src/main/java/com/grafana/sigil/sdk/Generation.java b/java/core/src/main/java/com/grafana/sigil/sdk/Generation.java index f74c79a..25f4703 100644 --- a/java/core/src/main/java/com/grafana/sigil/sdk/Generation.java +++ b/java/core/src/main/java/com/grafana/sigil/sdk/Generation.java @@ -37,6 +37,18 @@ public Generation setConversationId(String conversationId) { return this; } + @Override + public Generation setConversationTitle(String conversationTitle) { + super.setConversationTitle(conversationTitle); + return this; + } + + @Override + public Generation setUserId(String userId) { + super.setUserId(userId); + return this; + } + @Override public Generation setAgentName(String agentName) { super.setAgentName(agentName); @@ -101,6 +113,8 @@ public Generation copy() { Generation out = new Generation(); out.setId(getId()); out.setConversationId(getConversationId()); + out.setConversationTitle(getConversationTitle()); + out.setUserId(getUserId()); out.setAgentName(getAgentName()); out.setAgentVersion(getAgentVersion()); out.setMode(getMode()); diff --git a/java/core/src/main/java/com/grafana/sigil/sdk/GenerationRecorder.java b/java/core/src/main/java/com/grafana/sigil/sdk/GenerationRecorder.java index 8355cdd..6d1425a 100644 --- a/java/core/src/main/java/com/grafana/sigil/sdk/GenerationRecorder.java +++ b/java/core/src/main/java/com/grafana/sigil/sdk/GenerationRecorder.java @@ -168,6 +168,8 @@ private Generation normalize(GenerationResult result, Instant completedAt, Throw generation.setId(firstNonBlank(result.getId(), seed.getId(), SigilClient.newID("gen"))); generation.setConversationId(firstNonBlank(result.getConversationId(), seed.getConversationId())); + generation.setConversationTitle(firstNonBlank(result.getConversationTitle(), seed.getConversationTitle())); + generation.setUserId(firstNonBlank(result.getUserId(), seed.getUserId())); generation.setAgentName(firstNonBlank(result.getAgentName(), seed.getAgentName())); generation.setAgentVersion(firstNonBlank(result.getAgentVersion(), seed.getAgentVersion())); @@ -224,6 +226,23 @@ private Generation normalize(GenerationResult result, Instant completedAt, Throw metadata.putAll(result.getMetadata()); generation.setMetadata(metadata); + generation.setConversationTitle(firstNonBlank( + generation.getConversationTitle(), + SigilClient.metadataString(generation.getMetadata(), SigilClient.SPAN_ATTR_CONVERSATION_TITLE))); + generation.setConversationTitle(normalizeResolvedString(generation.getConversationTitle())); + if (!generation.getConversationTitle().isBlank()) { + generation.getMetadata().put(SigilClient.SPAN_ATTR_CONVERSATION_TITLE, generation.getConversationTitle()); + } + + generation.setUserId(firstNonBlank( + generation.getUserId(), + SigilClient.metadataString(generation.getMetadata(), SigilClient.METADATA_USER_ID_KEY), + SigilClient.metadataString(generation.getMetadata(), SigilClient.METADATA_LEGACY_USER_ID_KEY))); + generation.setUserId(normalizeResolvedString(generation.getUserId())); + if (!generation.getUserId().isBlank()) { + generation.getMetadata().put(SigilClient.METADATA_USER_ID_KEY, generation.getUserId()); + } + for (Artifact artifact : result.getArtifacts()) { generation.getArtifacts().add(artifact == null ? new Artifact() : artifact.copy()); } @@ -247,4 +266,8 @@ private static String firstNonBlank(String... values) { } return ""; } + + private static String normalizeResolvedString(String value) { + return value == null ? "" : value.trim(); + } } diff --git a/java/core/src/main/java/com/grafana/sigil/sdk/GenerationResult.java b/java/core/src/main/java/com/grafana/sigil/sdk/GenerationResult.java index 7d18c5d..d4a4b65 100644 --- a/java/core/src/main/java/com/grafana/sigil/sdk/GenerationResult.java +++ b/java/core/src/main/java/com/grafana/sigil/sdk/GenerationResult.java @@ -10,6 +10,8 @@ public class GenerationResult { private String id = ""; private String conversationId = ""; + private String conversationTitle = ""; + private String userId = ""; private String agentName = ""; private String agentVersion = ""; private GenerationMode mode; @@ -53,6 +55,24 @@ public GenerationResult setConversationId(String conversationId) { return this; } + public String getConversationTitle() { + return conversationTitle; + } + + public GenerationResult setConversationTitle(String conversationTitle) { + this.conversationTitle = conversationTitle == null ? "" : conversationTitle; + return this; + } + + public String getUserId() { + return userId; + } + + public GenerationResult setUserId(String userId) { + this.userId = userId == null ? "" : userId; + return this; + } + public String getAgentName() { return agentName; } @@ -291,6 +311,8 @@ public GenerationResult copy() { GenerationResult out = new GenerationResult() .setId(id) .setConversationId(conversationId) + .setConversationTitle(conversationTitle) + .setUserId(userId) .setAgentName(agentName) .setAgentVersion(agentVersion) .setMode(mode) diff --git a/java/core/src/main/java/com/grafana/sigil/sdk/GenerationStart.java b/java/core/src/main/java/com/grafana/sigil/sdk/GenerationStart.java index 2672961..b3ac3bc 100644 --- a/java/core/src/main/java/com/grafana/sigil/sdk/GenerationStart.java +++ b/java/core/src/main/java/com/grafana/sigil/sdk/GenerationStart.java @@ -10,6 +10,8 @@ public final class GenerationStart { private String id = ""; private String conversationId = ""; + private String conversationTitle = ""; + private String userId = ""; private String agentName = ""; private String agentVersion = ""; private GenerationMode mode; @@ -44,6 +46,24 @@ public GenerationStart setConversationId(String conversationId) { return this; } + public String getConversationTitle() { + return conversationTitle; + } + + public GenerationStart setConversationTitle(String conversationTitle) { + this.conversationTitle = conversationTitle == null ? "" : conversationTitle; + return this; + } + + public String getUserId() { + return userId; + } + + public GenerationStart setUserId(String userId) { + this.userId = userId == null ? "" : userId; + return this; + } + public String getAgentName() { return agentName; } @@ -192,6 +212,8 @@ public GenerationStart copy() { GenerationStart out = new GenerationStart() .setId(id) .setConversationId(conversationId) + .setConversationTitle(conversationTitle) + .setUserId(userId) .setAgentName(agentName) .setAgentVersion(agentVersion) .setMode(mode) diff --git a/java/core/src/main/java/com/grafana/sigil/sdk/SigilClient.java b/java/core/src/main/java/com/grafana/sigil/sdk/SigilClient.java index 9126153..251a831 100644 --- a/java/core/src/main/java/com/grafana/sigil/sdk/SigilClient.java +++ b/java/core/src/main/java/com/grafana/sigil/sdk/SigilClient.java @@ -42,6 +42,8 @@ public final class SigilClient implements AutoCloseable { static final String SPAN_ATTR_GENERATION_ID = "sigil.generation.id"; static final String SPAN_ATTR_SDK_NAME = "sigil.sdk.name"; static final String SPAN_ATTR_CONVERSATION_ID = "gen_ai.conversation.id"; + static final String SPAN_ATTR_CONVERSATION_TITLE = "sigil.conversation.title"; + static final String SPAN_ATTR_USER_ID = "user.id"; static final String SPAN_ATTR_AGENT_NAME = "gen_ai.agent.name"; static final String SPAN_ATTR_AGENT_VERSION = "gen_ai.agent.version"; static final String SPAN_ATTR_ERROR_TYPE = "error.type"; @@ -98,6 +100,8 @@ public final class SigilClient implements AutoCloseable { private static final String INSTRUMENTATION_NAME = "github.com/grafana/sigil/sdks/java"; static final String DEFAULT_EMBEDDING_OPERATION_NAME = "embeddings"; static final String SDK_NAME = "sdk-java"; + static final String METADATA_USER_ID_KEY = "sigil.user.id"; + static final String METADATA_LEGACY_USER_ID_KEY = "user.id"; private final SigilClientConfig config; private final GenerationExporter generationExporter; @@ -301,6 +305,9 @@ public ToolExecutionRecorder startToolExecution(ToolExecutionStart start) { if (seed.getConversationId().isBlank()) { seed.setConversationId(SigilContext.conversationIdFromContext()); } + if (seed.getConversationTitle().isBlank()) { + seed.setConversationTitle(SigilContext.conversationTitleFromContext()); + } if (seed.getAgentName().isBlank()) { seed.setAgentName(SigilContext.agentNameFromContext()); } @@ -535,6 +542,12 @@ private GenerationRecorder startGenerationInternal(GenerationStart start, Genera if (seed.getConversationId().isBlank()) { seed.setConversationId(SigilContext.conversationIdFromContext()); } + if (seed.getConversationTitle().isBlank()) { + seed.setConversationTitle(SigilContext.conversationTitleFromContext()); + } + if (seed.getUserId().isBlank()) { + seed.setUserId(SigilContext.userIdFromContext()); + } if (seed.getAgentName().isBlank()) { seed.setAgentName(SigilContext.agentNameFromContext()); } @@ -553,6 +566,8 @@ private GenerationRecorder startGenerationInternal(GenerationStart start, Genera Generation initial = new Generation(); initial.setId(seed.getId()); initial.setConversationId(seed.getConversationId()); + initial.setConversationTitle(seed.getConversationTitle()); + initial.setUserId(seed.getUserId()); initial.setAgentName(seed.getAgentName()); initial.setAgentVersion(seed.getAgentVersion()); initial.setMode(seed.getMode()); @@ -955,6 +970,12 @@ static void setGenerationSpanAttributes(Span span, Generation generation) { if (!generation.getConversationId().isBlank()) { span.setAttribute(SPAN_ATTR_CONVERSATION_ID, generation.getConversationId()); } + if (!generation.getConversationTitle().isBlank()) { + span.setAttribute(SPAN_ATTR_CONVERSATION_TITLE, generation.getConversationTitle()); + } + if (!generation.getUserId().isBlank()) { + span.setAttribute(SPAN_ATTR_USER_ID, generation.getUserId()); + } if (!generation.getAgentName().isBlank()) { span.setAttribute(SPAN_ATTR_AGENT_NAME, generation.getAgentName()); } @@ -1014,12 +1035,21 @@ static void setToolSpanAttributes(Span span, ToolExecutionStart seed) { if (!seed.getConversationId().isBlank()) { span.setAttribute(SPAN_ATTR_CONVERSATION_ID, seed.getConversationId()); } + if (!seed.getConversationTitle().isBlank()) { + span.setAttribute(SPAN_ATTR_CONVERSATION_TITLE, seed.getConversationTitle()); + } if (!seed.getAgentName().isBlank()) { span.setAttribute(SPAN_ATTR_AGENT_NAME, seed.getAgentName()); } if (!seed.getAgentVersion().isBlank()) { span.setAttribute(SPAN_ATTR_AGENT_VERSION, seed.getAgentVersion()); } + if (!seed.getRequestProvider().isBlank()) { + span.setAttribute(SPAN_ATTR_PROVIDER_NAME, seed.getRequestProvider()); + } + if (!seed.getRequestModel().isBlank()) { + span.setAttribute(SPAN_ATTR_REQUEST_MODEL, seed.getRequestModel()); + } if (!seed.getToolName().isBlank()) { span.setAttribute(SPAN_ATTR_TOOL_NAME, seed.getToolName()); } @@ -1160,8 +1190,9 @@ void recordToolExecutionMetrics(ToolExecutionStart seed, Instant startedAt, Inst durationSeconds, Attributes.builder() .put(SPAN_ATTR_OPERATION_NAME, "execute_tool") - .put(SPAN_ATTR_PROVIDER_NAME, "") - .put(SPAN_ATTR_REQUEST_MODEL, seed.getToolName()) + .put(SPAN_ATTR_PROVIDER_NAME, seed.getRequestProvider().trim()) + .put(SPAN_ATTR_REQUEST_MODEL, seed.getRequestModel().trim()) + .put(SPAN_ATTR_TOOL_NAME, seed.getToolName().trim()) .put(SPAN_ATTR_AGENT_NAME, seed.getAgentName()) .put(SPAN_ATTR_ERROR_TYPE, errorType) .put(SPAN_ATTR_ERROR_CATEGORY, errorCategory) @@ -1330,6 +1361,17 @@ private static Long thinkingBudgetFromMetadata(Map metadata) { return null; } + static String metadataString(Map metadata, String key) { + if (metadata == null) { + return ""; + } + Object value = metadata.get(key); + if (value == null) { + return ""; + } + return String.valueOf(value).trim(); + } + private static EmbeddingCaptureConfig normalizeEmbeddingCaptureConfig(EmbeddingCaptureConfig input) { EmbeddingCaptureConfig config = input == null ? new EmbeddingCaptureConfig() : input.copy(); if (config.getMaxInputItems() <= 0) { diff --git a/java/core/src/main/java/com/grafana/sigil/sdk/SigilContext.java b/java/core/src/main/java/com/grafana/sigil/sdk/SigilContext.java index 2b84adf..f8cefaf 100644 --- a/java/core/src/main/java/com/grafana/sigil/sdk/SigilContext.java +++ b/java/core/src/main/java/com/grafana/sigil/sdk/SigilContext.java @@ -7,6 +7,8 @@ /** Context helpers for conversation and agent defaults. */ public final class SigilContext { private static final ContextKey CONVERSATION_ID = ContextKey.named("sigil.conversation.id"); + private static final ContextKey CONVERSATION_TITLE = ContextKey.named("sigil.conversation.title"); + private static final ContextKey USER_ID = ContextKey.named("sigil.user.id"); private static final ContextKey AGENT_NAME = ContextKey.named("sigil.agent.name"); private static final ContextKey AGENT_VERSION = ContextKey.named("sigil.agent.version"); @@ -22,6 +24,24 @@ public static Scope withConversationId(String conversationId) { return Context.current().with(CONVERSATION_ID, emptyToBlank(conversationId)).makeCurrent(); } + /** + * Sets the conversation title in the current OTel context. + * + *

Use the returned {@link Scope} in try-with-resources to restore context automatically.

+ */ + public static Scope withConversationTitle(String conversationTitle) { + return Context.current().with(CONVERSATION_TITLE, emptyToBlank(conversationTitle)).makeCurrent(); + } + + /** + * Sets the user id in the current OTel context. + * + *

Use the returned {@link Scope} in try-with-resources to restore context automatically.

+ */ + public static Scope withUserId(String userId) { + return Context.current().with(USER_ID, emptyToBlank(userId)).makeCurrent(); + } + /** * Sets the agent name in the current OTel context. * @@ -45,6 +65,16 @@ static String conversationIdFromContext() { return value == null ? "" : value; } + static String conversationTitleFromContext() { + String value = Context.current().get(CONVERSATION_TITLE); + return value == null ? "" : value; + } + + static String userIdFromContext() { + String value = Context.current().get(USER_ID); + return value == null ? "" : value; + } + static String agentNameFromContext() { String value = Context.current().get(AGENT_NAME); return value == null ? "" : value; diff --git a/java/core/src/main/java/com/grafana/sigil/sdk/ToolExecutionStart.java b/java/core/src/main/java/com/grafana/sigil/sdk/ToolExecutionStart.java index a1769a2..9442f24 100644 --- a/java/core/src/main/java/com/grafana/sigil/sdk/ToolExecutionStart.java +++ b/java/core/src/main/java/com/grafana/sigil/sdk/ToolExecutionStart.java @@ -9,8 +9,11 @@ public final class ToolExecutionStart { private String toolType = ""; private String toolDescription = ""; private String conversationId = ""; + private String conversationTitle = ""; private String agentName = ""; private String agentVersion = ""; + private String requestModel = ""; + private String requestProvider = ""; private boolean includeContent; private Instant startedAt; @@ -59,6 +62,15 @@ public ToolExecutionStart setConversationId(String conversationId) { return this; } + public String getConversationTitle() { + return conversationTitle; + } + + public ToolExecutionStart setConversationTitle(String conversationTitle) { + this.conversationTitle = conversationTitle == null ? "" : conversationTitle; + return this; + } + public String getAgentName() { return agentName; } @@ -77,6 +89,28 @@ public ToolExecutionStart setAgentVersion(String agentVersion) { return this; } + /** Returns the model that requested the tool call. */ + public String getRequestModel() { + return requestModel; + } + + /** Sets the model that requested the tool call (e.g. "gpt-5"). */ + public ToolExecutionStart setRequestModel(String requestModel) { + this.requestModel = requestModel == null ? "" : requestModel; + return this; + } + + /** Returns the provider that served the model. */ + public String getRequestProvider() { + return requestProvider; + } + + /** Sets the provider that served the model (e.g. "openai"). */ + public ToolExecutionStart setRequestProvider(String requestProvider) { + this.requestProvider = requestProvider == null ? "" : requestProvider; + return this; + } + public boolean isIncludeContent() { return includeContent; } @@ -102,8 +136,11 @@ public ToolExecutionStart copy() { .setToolType(toolType) .setToolDescription(toolDescription) .setConversationId(conversationId) + .setConversationTitle(conversationTitle) .setAgentName(agentName) .setAgentVersion(agentVersion) + .setRequestModel(requestModel) + .setRequestProvider(requestProvider) .setIncludeContent(includeContent) .setStartedAt(startedAt); } diff --git a/java/core/src/test/java/com/grafana/sigil/sdk/ConformanceTest.java b/java/core/src/test/java/com/grafana/sigil/sdk/ConformanceTest.java new file mode 100644 index 0000000..eb4161c --- /dev/null +++ b/java/core/src/test/java/com/grafana/sigil/sdk/ConformanceTest.java @@ -0,0 +1,602 @@ +package com.grafana.sigil.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.sun.net.httpserver.HttpServer; +import io.grpc.Server; +import io.grpc.ServerBuilder; +import io.grpc.stub.StreamObserver; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.context.Scope; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import sigil.v1.GenerationIngest; +import sigil.v1.GenerationIngestServiceGrpc; + +class ConformanceTest { + @Test + void syncRoundtripSemantics() throws Exception { + try (ConformanceEnv env = new ConformanceEnv(1)) { + GenerationStart start = new GenerationStart() + .setId("gen-roundtrip") + .setConversationId("conv-roundtrip") + .setConversationTitle("Roundtrip conversation") + .setUserId("user-roundtrip") + .setAgentName("agent-roundtrip") + .setAgentVersion("v-roundtrip") + .setModel(new ModelRef().setProvider("openai").setName("gpt-5")) + .setMaxTokens(256L) + .setTemperature(0.2) + .setTopP(0.9) + .setToolChoice("required") + .setThinkingEnabled(false); + start.getTools().add(new ToolDefinition() + .setName("weather") + .setDescription("Get weather") + .setType("function")); + start.getTags().put("tenant", "dev"); + start.getMetadata().put("trace", "roundtrip"); + + GenerationRecorder recorder = env.client.startGeneration(start); + + GenerationResult result = new GenerationResult() + .setResponseId("resp-roundtrip") + .setResponseModel("gpt-5-2026") + .setUsage(new TokenUsage() + .setInputTokens(12) + .setOutputTokens(7) + .setTotalTokens(19) + .setCacheReadInputTokens(2) + .setCacheWriteInputTokens(1) + .setCacheCreationInputTokens(3) + .setReasoningTokens(4)) + .setStopReason("stop"); + result.getTags().put("region", "eu"); + result.getMetadata().put("result", "ok"); + result.getInput().add(new Message() + .setRole(MessageRole.USER) + .setParts(List.of(MessagePart.text("hello")))); + result.getOutput().add(new Message() + .setRole(MessageRole.ASSISTANT) + .setParts(List.of( + MessagePart.thinking("reasoning"), + MessagePart.toolCall(new ToolCall() + .setId("call-1") + .setName("weather") + .setInputJson("{\"city\":\"Paris\"}".getBytes(StandardCharsets.UTF_8)))))); + result.getOutput().add(new Message() + .setRole(MessageRole.TOOL) + .setParts(List.of( + MessagePart.toolResult(new ToolResultPart() + .setToolCallId("call-1") + .setName("weather") + .setContent("sunny") + .setContentJson("{\"temp_c\":18}".getBytes(StandardCharsets.UTF_8)))))); + result.getArtifacts().add(new Artifact() + .setKind(ArtifactKind.REQUEST) + .setName("request") + .setContentType("application/json") + .setPayload("{\"prompt\":\"hello\"}".getBytes(StandardCharsets.UTF_8))); + result.getArtifacts().add(new Artifact() + .setKind(ArtifactKind.RESPONSE) + .setName("response") + .setContentType("application/json") + .setPayload("{\"text\":\"sunny\"}".getBytes(StandardCharsets.UTF_8))); + + recorder.setResult(result); + recorder.end(); + env.client.shutdown(); + + GenerationIngest.Generation generation = env.singleGeneration(); + SpanData span = env.latestGenerationSpan(); + List metricNames = env.metricNames(); + + assertThat(generation.getMode()).isEqualTo(GenerationIngest.GenerationMode.GENERATION_MODE_SYNC); + assertThat(generation.getOperationName()).isEqualTo("generateText"); + assertThat(generation.getConversationId()).isEqualTo("conv-roundtrip"); + assertThat(generation.getAgentName()).isEqualTo("agent-roundtrip"); + assertThat(generation.getAgentVersion()).isEqualTo("v-roundtrip"); + assertThat(generation.getTraceId()).isEqualTo(span.getTraceId()); + assertThat(generation.getSpanId()).isEqualTo(span.getSpanId()); + assertThat(generation.getMetadata().getFieldsMap().get("sigil.conversation.title").getStringValue()) + .isEqualTo("Roundtrip conversation"); + assertThat(generation.getMetadata().getFieldsMap().get("sigil.user.id").getStringValue()) + .isEqualTo("user-roundtrip"); + assertThat(generation.getInput(0).getParts(0).getText()).isEqualTo("hello"); + assertThat(generation.getOutput(0).getParts(0).getThinking()).isEqualTo("reasoning"); + assertThat(generation.getOutput(0).getParts(1).getToolCall().getName()).isEqualTo("weather"); + assertThat(generation.getOutput(1).getParts(0).getToolResult().getContent()).isEqualTo("sunny"); + assertThat(generation.getMaxTokens()).isEqualTo(256L); + assertThat(generation.getTemperature()).isEqualTo(0.2d); + assertThat(generation.getTopP()).isEqualTo(0.9d); + assertThat(generation.getToolChoice()).isEqualTo("required"); + assertThat(generation.getThinkingEnabled()).isFalse(); + assertThat(generation.getUsage().getInputTokens()).isEqualTo(12L); + assertThat(generation.getUsage().getOutputTokens()).isEqualTo(7L); + assertThat(generation.getUsage().getTotalTokens()).isEqualTo(19L); + assertThat(generation.getUsage().getCacheReadInputTokens()).isEqualTo(2L); + assertThat(generation.getUsage().getCacheWriteInputTokens()).isEqualTo(1L); + assertThat(generation.getUsage().getReasoningTokens()).isEqualTo(4L); + assertThat(generation.getStopReason()).isEqualTo("stop"); + assertThat(generation.getTagsMap()).containsEntry("tenant", "dev").containsEntry("region", "eu"); + assertThat(generation.getRawArtifactsCount()).isEqualTo(2); + assertThat(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_OPERATION_NAME))).isEqualTo("generateText"); + assertThat(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_CONVERSATION_TITLE))) + .isEqualTo("Roundtrip conversation"); + assertThat(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_USER_ID))) + .isEqualTo("user-roundtrip"); + assertThat(metricNames).contains(SigilClient.METRIC_OPERATION_DURATION, SigilClient.METRIC_TOKEN_USAGE); + assertThat(metricNames).doesNotContain(SigilClient.METRIC_TTFT); + } + } + + @ParameterizedTest + @MethodSource("conversationTitleCases") + void conversationTitleSemantics(String startTitle, String contextTitle, String metadataTitle, String expected) throws Exception { + try (ConformanceEnv env = new ConformanceEnv(1); + Scope ignored = contextTitle.isEmpty() ? noopScope() : SigilContext.withConversationTitle(contextTitle)) { + GenerationStart start = new GenerationStart() + .setModel(new ModelRef().setProvider("openai").setName("gpt-5")) + .setConversationTitle(startTitle); + if (!metadataTitle.isEmpty()) { + start.getMetadata().put(SigilClient.SPAN_ATTR_CONVERSATION_TITLE, metadataTitle); + } + + GenerationRecorder recorder = env.client.startGeneration(start); + recorder.setResult(new GenerationResult()); + recorder.end(); + env.client.shutdown(); + + GenerationIngest.Generation generation = env.singleGeneration(); + SpanData span = env.latestGenerationSpan(); + if (expected.isEmpty()) { + assertThat(generation.getMetadata().getFieldsMap()).doesNotContainKey(SigilClient.SPAN_ATTR_CONVERSATION_TITLE); + assertThat(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_CONVERSATION_TITLE))).isNull(); + return; + } + + assertThat(generation.getMetadata().getFieldsMap().get(SigilClient.SPAN_ATTR_CONVERSATION_TITLE).getStringValue()) + .isEqualTo(expected); + assertThat(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_CONVERSATION_TITLE))) + .isEqualTo(expected); + } + } + + @ParameterizedTest + @MethodSource("userIdCases") + void userIdSemantics(String startUserId, String contextUserId, String canonicalUserId, String legacyUserId, String expected) + throws Exception { + try (ConformanceEnv env = new ConformanceEnv(1); + Scope ignored = contextUserId.isEmpty() ? noopScope() : SigilContext.withUserId(contextUserId)) { + GenerationStart start = new GenerationStart() + .setModel(new ModelRef().setProvider("openai").setName("gpt-5")) + .setUserId(startUserId); + if (!canonicalUserId.isEmpty()) { + start.getMetadata().put(SigilClient.METADATA_USER_ID_KEY, canonicalUserId); + } + if (!legacyUserId.isEmpty()) { + start.getMetadata().put(SigilClient.METADATA_LEGACY_USER_ID_KEY, legacyUserId); + } + + GenerationRecorder recorder = env.client.startGeneration(start); + recorder.setResult(new GenerationResult()); + recorder.end(); + env.client.shutdown(); + + GenerationIngest.Generation generation = env.singleGeneration(); + SpanData span = env.latestGenerationSpan(); + assertThat(generation.getMetadata().getFieldsMap().get(SigilClient.METADATA_USER_ID_KEY).getStringValue()) + .isEqualTo(expected); + assertThat(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_USER_ID))).isEqualTo(expected); + } + } + + @ParameterizedTest + @MethodSource("agentIdentityCases") + void agentIdentitySemantics( + String startName, + String startVersion, + String contextName, + String contextVersion, + String resultName, + String resultVersion, + String expectedName, + String expectedVersion) + throws Exception { + try (ConformanceEnv env = new ConformanceEnv(1); + Scope ignoredName = contextName.isEmpty() ? noopScope() : SigilContext.withAgentName(contextName); + Scope ignoredVersion = contextVersion.isEmpty() ? noopScope() : SigilContext.withAgentVersion(contextVersion)) { + GenerationRecorder recorder = env.client.startGeneration(new GenerationStart() + .setModel(new ModelRef().setProvider("openai").setName("gpt-5")) + .setAgentName(startName) + .setAgentVersion(startVersion)); + recorder.setResult(new GenerationResult() + .setAgentName(resultName) + .setAgentVersion(resultVersion)); + recorder.end(); + env.client.shutdown(); + + GenerationIngest.Generation generation = env.singleGeneration(); + SpanData span = env.latestGenerationSpan(); + assertThat(generation.getAgentName()).isEqualTo(expectedName); + assertThat(generation.getAgentVersion()).isEqualTo(expectedVersion); + assertThat(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_AGENT_NAME))) + .isEqualTo(expectedName.isEmpty() ? null : expectedName); + assertThat(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_AGENT_VERSION))) + .isEqualTo(expectedVersion.isEmpty() ? null : expectedVersion); + } + } + + @Test + void streamingTelemetrySemantics() throws Exception { + try (ConformanceEnv env = new ConformanceEnv(1)) { + Instant startedAt = Instant.parse("2026-03-12T09:00:00Z"); + GenerationRecorder recorder = env.client.startStreamingGeneration(new GenerationStart() + .setModel(new ModelRef().setProvider("openai").setName("gpt-5")) + .setStartedAt(startedAt)); + recorder.setFirstTokenAt(startedAt.plusMillis(250)); + recorder.setResult(new GenerationResult() + .setStartedAt(startedAt) + .setCompletedAt(startedAt.plusSeconds(1)) + .setUsage(new TokenUsage().setInputTokens(4).setOutputTokens(3).setTotalTokens(7))); + recorder.end(); + env.client.shutdown(); + + GenerationIngest.Generation generation = env.singleGeneration(); + SpanData span = env.latestGenerationSpan(); + List metricNames = env.metricNames(); + + assertThat(generation.getMode()).isEqualTo(GenerationIngest.GenerationMode.GENERATION_MODE_STREAM); + assertThat(generation.getOperationName()).isEqualTo("streamText"); + assertThat(span.getName()).isEqualTo("streamText gpt-5"); + assertThat(metricNames).contains(SigilClient.METRIC_OPERATION_DURATION, SigilClient.METRIC_TTFT); + } + } + + @Test + void toolExecutionSemantics() throws Exception { + try (ConformanceEnv env = new ConformanceEnv(1); + Scope ignoredTitle = SigilContext.withConversationTitle("Context title"); + Scope ignoredName = SigilContext.withAgentName("agent-context"); + Scope ignoredVersion = SigilContext.withAgentVersion("v-context")) { + ToolExecutionRecorder recorder = env.client.startToolExecution(new ToolExecutionStart() + .setToolName("weather") + .setToolCallId("call-weather-1") + .setToolType("function") + .setIncludeContent(true)); + recorder.setResult(new ToolExecutionResult() + .setArguments(Map.of("city", "Paris")) + .setResult(Map.of("forecast", "sunny"))); + recorder.end(); + env.client.shutdown(); + + SpanData span = env.latestSpanByNamePrefix("execute_tool "); + List metricNames = env.metricNames(); + + assertThat(env.requests).isEmpty(); + assertThat(span.getName()).isEqualTo("execute_tool weather"); + assertThat(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_TOOL_NAME))).isEqualTo("weather"); + assertThat(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_TOOL_CALL_ID))).isEqualTo("call-weather-1"); + assertThat(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_TOOL_TYPE))).isEqualTo("function"); + assertThat(String.valueOf(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_TOOL_CALL_ARGUMENTS)))) + .contains("Paris"); + assertThat(String.valueOf(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_TOOL_CALL_RESULT)))) + .contains("sunny"); + assertThat(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_CONVERSATION_TITLE))) + .isEqualTo("Context title"); + assertThat(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_AGENT_NAME))) + .isEqualTo("agent-context"); + assertThat(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_AGENT_VERSION))) + .isEqualTo("v-context"); + assertThat(metricNames).contains(SigilClient.METRIC_OPERATION_DURATION); + assertThat(metricNames).doesNotContain(SigilClient.METRIC_TTFT); + } + } + + @Test + void embeddingSemantics() throws Exception { + try (ConformanceEnv env = new ConformanceEnv(1); + Scope ignoredName = SigilContext.withAgentName("agent-context"); + Scope ignoredVersion = SigilContext.withAgentVersion("v-context")) { + EmbeddingRecorder recorder = env.client.startEmbedding(new EmbeddingStart() + .setModel(new ModelRef().setProvider("openai").setName("text-embedding-3-small")) + .setDimensions(512L)); + recorder.setResult(new EmbeddingResult() + .setInputCount(2) + .setInputTokens(8) + .setInputTexts(List.of("hello", "world")) + .setResponseModel("text-embedding-3-small") + .setDimensions(512L)); + recorder.end(); + env.client.shutdown(); + + SpanData span = env.latestSpan("embeddings"); + List metricNames = env.metricNames(); + + assertThat(env.requests).isEmpty(); + assertThat(span.getName()).isEqualTo("embeddings text-embedding-3-small"); + assertThat(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_OPERATION_NAME))).isEqualTo("embeddings"); + assertThat(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_AGENT_NAME))) + .isEqualTo("agent-context"); + assertThat(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_AGENT_VERSION))) + .isEqualTo("v-context"); + assertThat(span.getAttributes().get(AttributeKey.longKey("gen_ai.embeddings.input_count"))).isEqualTo(2L); + assertThat(span.getAttributes().get(AttributeKey.longKey("gen_ai.embeddings.dimension.count"))).isEqualTo(512L); + assertThat(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_RESPONSE_MODEL))) + .isEqualTo("text-embedding-3-small"); + assertThat(metricNames).contains(SigilClient.METRIC_OPERATION_DURATION, SigilClient.METRIC_TOKEN_USAGE); + assertThat(metricNames).doesNotContain(SigilClient.METRIC_TTFT, SigilClient.METRIC_TOOL_CALLS_PER_OPERATION); + } + } + + @Test + void validationAndCallErrorSemantics() throws Exception { + try (ConformanceEnv env = new ConformanceEnv(1)) { + GenerationRecorder invalid = env.client.startGeneration(new GenerationStart() + .setModel(new ModelRef().setProvider("anthropic").setName("claude-sonnet-4-5"))); + invalid.setResult(new GenerationResult().setInput(List.of(new Message() + .setRole(MessageRole.USER) + .setParts(List.of(MessagePart.toolCall(new ToolCall().setName("weather"))))))); + invalid.end(); + + assertThat(invalid.error()).isPresent(); + assertThat(env.requests).isEmpty(); + assertThat(env.latestGenerationSpan().getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_ERROR_TYPE))) + .isEqualTo("validation_error"); + + GenerationRecorder callError = env.client.startGeneration(new GenerationStart() + .setModel(new ModelRef().setProvider("openai").setName("gpt-5"))); + callError.setCallError(new IllegalStateException("provider unavailable")); + callError.setResult(new GenerationResult()); + callError.end(); + env.client.shutdown(); + + GenerationIngest.Generation generation = env.singleGeneration(); + SpanData span = env.latestGenerationSpan(); + assertThat(callError.error()).isEmpty(); + assertThat(generation.getCallError()).isEqualTo("provider unavailable"); + assertThat(generation.getMetadata().getFieldsMap().get("call_error").getStringValue()).isEqualTo("provider unavailable"); + assertThat(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_ERROR_TYPE))) + .isEqualTo("provider_call_error"); + } + } + + @Test + void ratingSubmissionSemantics() throws Exception { + try (ConformanceEnv env = new ConformanceEnv(1)) { + SubmitConversationRatingResponse response = env.client.submitConversationRating( + "conv-rating", + new SubmitConversationRatingRequest() + .setRatingId("rat-1") + .setRating(ConversationRatingValue.BAD) + .setComment("wrong answer") + .setMetadata(Map.of("channel", "assistant"))); + + assertThat(env.ratingPath.get()).isEqualTo("/api/v1/conversations/conv-rating/ratings"); + assertThat(response.getRating().getConversationId()).isEqualTo("conv-rating"); + assertThat(response.getSummary().getBadCount()).isEqualTo(1L); + + JsonNode body = env.ratingPayload.get(); + assertThat(body.get("rating_id").asText()).isEqualTo("rat-1"); + assertThat(body.get("rating").asText()).isEqualTo("CONVERSATION_RATING_VALUE_BAD"); + assertThat(body.get("comment").asText()).isEqualTo("wrong answer"); + } + } + + @Test + void shutdownFlushSemantics() throws Exception { + try (ConformanceEnv env = new ConformanceEnv(10)) { + GenerationRecorder recorder = env.client.startGeneration(new GenerationStart() + .setConversationId("conv-shutdown") + .setAgentName("agent-shutdown") + .setAgentVersion("v-shutdown") + .setModel(new ModelRef().setProvider("openai").setName("gpt-5"))); + recorder.setResult(new GenerationResult()); + recorder.end(); + + assertThat(env.requests).isEmpty(); + env.client.shutdown(); + + GenerationIngest.Generation generation = env.singleGeneration(); + assertThat(generation.getConversationId()).isEqualTo("conv-shutdown"); + assertThat(generation.getAgentName()).isEqualTo("agent-shutdown"); + assertThat(generation.getAgentVersion()).isEqualTo("v-shutdown"); + } + } + + private static Stream conversationTitleCases() { + return Stream.of( + Arguments.of("Explicit", "Context", "Meta", "Explicit"), + Arguments.of("", "Context", "", "Context"), + Arguments.of("", "", "Meta", "Meta"), + Arguments.of(" Padded ", "", "", "Padded"), + Arguments.of(" ", "", "", "")); + } + + private static Stream userIdCases() { + return Stream.of( + Arguments.of("explicit", "ctx", "canonical", "legacy", "explicit"), + Arguments.of("", "ctx", "", "", "ctx"), + Arguments.of("", "", "canonical", "", "canonical"), + Arguments.of("", "", "", "legacy", "legacy"), + Arguments.of("", "", "canonical", "legacy", "canonical"), + Arguments.of(" padded ", "", "", "", "padded")); + } + + private static Stream agentIdentityCases() { + return Stream.of( + Arguments.of("agent-explicit", "v1.2.3", "", "", "", "", "agent-explicit", "v1.2.3"), + Arguments.of("", "", "agent-context", "v-context", "", "", "agent-context", "v-context"), + Arguments.of("agent-seed", "v-seed", "", "", "agent-result", "v-result", "agent-result", "v-result"), + Arguments.of("", "", "", "", "", "", "", "")); + } + + private static Scope noopScope() { + return () -> { + }; + } + + private static final class ConformanceEnv implements AutoCloseable { + private final Server server; + private final HttpServer ratingServer; + private final InMemorySpanExporter spanExporter = InMemorySpanExporter.create(); + private final SdkTracerProvider tracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(spanExporter)) + .build(); + private final InMemoryMetricReader metricReader = InMemoryMetricReader.create(); + private final SdkMeterProvider meterProvider = SdkMeterProvider.builder() + .registerMetricReader(metricReader) + .build(); + private final AtomicReference ratingPath = new AtomicReference<>(); + private final AtomicReference ratingPayload = new AtomicReference<>(); + private final List requests = new CopyOnWriteArrayList<>(); + private boolean closed; + + private final SigilClient client; + + ConformanceEnv(int batchSize) throws Exception { + GenerationIngestServiceGrpc.GenerationIngestServiceImplBase service = + new GenerationIngestServiceGrpc.GenerationIngestServiceImplBase() { + @Override + public void exportGenerations( + GenerationIngest.ExportGenerationsRequest request, + StreamObserver responseObserver) { + requests.add(request); + List results = new ArrayList<>(); + for (GenerationIngest.Generation generation : request.getGenerationsList()) { + results.add(GenerationIngest.ExportGenerationResult.newBuilder() + .setGenerationId(generation.getId()) + .setAccepted(true) + .build()); + } + responseObserver.onNext(GenerationIngest.ExportGenerationsResponse.newBuilder() + .addAllResults(results) + .build()); + responseObserver.onCompleted(); + } + }; + server = ServerBuilder.forPort(0).addService(service).build().start(); + + ratingServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + ratingServer.createContext("/api/v1/conversations/conv-rating/ratings", exchange -> { + ratingPath.set(exchange.getRequestURI().getPath()); + ratingPayload.set(Json.MAPPER.readTree(exchange.getRequestBody().readAllBytes())); + + byte[] response = """ + { + "rating":{ + "rating_id":"rat-1", + "conversation_id":"conv-rating", + "rating":"CONVERSATION_RATING_VALUE_BAD", + "created_at":"2026-03-12T09:00:00Z" + }, + "summary":{ + "total_count":1, + "good_count":0, + "bad_count":1, + "latest_rating":"CONVERSATION_RATING_VALUE_BAD", + "latest_rated_at":"2026-03-12T09:00:00Z", + "has_bad_rating":true + } + } + """.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length); + try (OutputStream outputStream = exchange.getResponseBody()) { + outputStream.write(response); + } + }); + ratingServer.start(); + + client = new SigilClient(new SigilClientConfig() + .setTracer(tracerProvider.get("sigil-conformance-test")) + .setMeter(meterProvider.get("sigil-conformance-test")) + .setApi(new ApiConfig().setEndpoint("http://127.0.0.1:" + ratingServer.getAddress().getPort())) + .setGenerationExport(new GenerationExportConfig() + .setProtocol(GenerationExportProtocol.GRPC) + .setEndpoint("127.0.0.1:" + server.getPort()) + .setInsecure(true) + .setBatchSize(batchSize) + .setQueueSize(10) + .setFlushInterval(Duration.ofHours(1)) + .setMaxRetries(1) + .setInitialBackoff(Duration.ofMillis(1)) + .setMaxBackoff(Duration.ofMillis(2)))); + } + + GenerationIngest.Generation singleGeneration() { + assertThat(requests).hasSize(1); + assertThat(requests.get(0).getGenerationsCount()).isEqualTo(1); + return requests.get(0).getGenerations(0); + } + + SpanData latestGenerationSpan() { + List spans = spanExporter.getFinishedSpanItems().stream() + .filter(span -> { + String operation = span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_OPERATION_NAME)); + return "generateText".equals(operation) || "streamText".equals(operation); + }) + .toList(); + assertThat(spans).isNotEmpty(); + return spans.get(spans.size() - 1); + } + + SpanData latestSpan(String operationName) { + List spans = spanExporter.getFinishedSpanItems().stream() + .filter(span -> operationName.equals( + span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_OPERATION_NAME)))) + .toList(); + assertThat(spans).isNotEmpty(); + return spans.get(spans.size() - 1); + } + + SpanData latestSpanByNamePrefix(String prefix) { + List spans = spanExporter.getFinishedSpanItems().stream() + .filter(span -> span.getName().startsWith(prefix)) + .toList(); + assertThat(spans).isNotEmpty(); + return spans.get(spans.size() - 1); + } + + List metricNames() { + return metricReader.collectAllMetrics().stream() + .map(MetricData::getName) + .toList(); + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + client.shutdown(); + server.shutdownNow(); + ratingServer.stop(0); + tracerProvider.shutdown(); + meterProvider.shutdown(); + } + } +} diff --git a/java/core/src/test/java/com/grafana/sigil/sdk/SigilAuthConfigTest.java b/java/core/src/test/java/com/grafana/sigil/sdk/SigilAuthConfigTest.java index 39b0aac..6c26a18 100644 --- a/java/core/src/test/java/com/grafana/sigil/sdk/SigilAuthConfigTest.java +++ b/java/core/src/test/java/com/grafana/sigil/sdk/SigilAuthConfigTest.java @@ -3,6 +3,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.LinkedHashMap; import java.util.Map; import org.junit.jupiter.api.Test; @@ -13,6 +16,14 @@ void validatesAuthModeShape() { .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("mode 'none'"); + assertThatThrownBy(() -> AuthHeaders.resolve(Map.of(), new AuthConfig().setMode(AuthMode.NONE).setBasicUser("user"), "trace")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("mode 'none'"); + + assertThatThrownBy(() -> AuthHeaders.resolve(Map.of(), new AuthConfig().setMode(AuthMode.NONE).setBasicPassword("secret"), "trace")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("mode 'none'"); + assertThatThrownBy(() -> AuthHeaders.resolve(Map.of(), new AuthConfig().setMode(AuthMode.TENANT), "generation export")) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("requires tenantId"); @@ -36,4 +47,69 @@ void explicitHeadersOverrideInjectedAuthHeaders() { "generation export"); assertThat(generation.get("x-scope-orgid")).isEqualTo("tenant-override"); } + + @Test + void basicAuthWithTenantId() { + Map headers = AuthHeaders.resolve( + Map.of(), + new AuthConfig().setMode(AuthMode.BASIC).setTenantId("42").setBasicPassword("secret"), + "generation export"); + String expected = "Basic " + Base64.getEncoder() + .encodeToString("42:secret".getBytes(StandardCharsets.UTF_8)); + assertThat(headers.get("Authorization")).isEqualTo(expected); + assertThat(headers.get("X-Scope-OrgID")).isEqualTo("42"); + } + + @Test + void basicAuthWithExplicitUser() { + Map headers = AuthHeaders.resolve( + Map.of(), + new AuthConfig().setMode(AuthMode.BASIC).setTenantId("42") + .setBasicUser("probe-user").setBasicPassword("secret"), + "generation export"); + String expected = "Basic " + Base64.getEncoder() + .encodeToString("probe-user:secret".getBytes(StandardCharsets.UTF_8)); + assertThat(headers.get("Authorization")).isEqualTo(expected); + assertThat(headers.get("X-Scope-OrgID")).isEqualTo("42"); + } + + @Test + void basicAuthExplicitHeaderWins() { + Map input = new LinkedHashMap<>(); + input.put("Authorization", "Basic override"); + input.put("X-Scope-OrgID", "override-tenant"); + Map headers = AuthHeaders.resolve( + input, + new AuthConfig().setMode(AuthMode.BASIC).setTenantId("42").setBasicPassword("secret"), + "generation export"); + assertThat(headers.get("Authorization")).isEqualTo("Basic override"); + assertThat(headers.get("X-Scope-OrgID")).isEqualTo("override-tenant"); + } + + @Test + void basicAuthRejectsInvalidConfig() { + assertThatThrownBy(() -> AuthHeaders.resolve(Map.of(), + new AuthConfig().setMode(AuthMode.BASIC), "generation export")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("requires basicPassword"); + + assertThatThrownBy(() -> AuthHeaders.resolve(Map.of(), + new AuthConfig().setMode(AuthMode.BASIC).setBasicPassword("secret"), "generation export")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("requires basicUser or tenantId"); + } + + @Test + void basicAuthCopy() { + AuthConfig original = new AuthConfig() + .setMode(AuthMode.BASIC) + .setTenantId("42") + .setBasicUser("user") + .setBasicPassword("pass"); + AuthConfig copy = original.copy(); + assertThat(copy.getMode()).isEqualTo(AuthMode.BASIC); + assertThat(copy.getTenantId()).isEqualTo("42"); + assertThat(copy.getBasicUser()).isEqualTo("user"); + assertThat(copy.getBasicPassword()).isEqualTo("pass"); + } } diff --git a/java/core/src/test/java/com/grafana/sigil/sdk/SigilClientSpansTest.java b/java/core/src/test/java/com/grafana/sigil/sdk/SigilClientSpansTest.java index 2c16f96..e8bc6e7 100644 --- a/java/core/src/test/java/com/grafana/sigil/sdk/SigilClientSpansTest.java +++ b/java/core/src/test/java/com/grafana/sigil/sdk/SigilClientSpansTest.java @@ -85,7 +85,9 @@ void toolSpanNameAndAttributesMatchContract() { .setToolName("weather") .setToolCallId("call-1") .setToolType("function") - .setToolDescription("Get weather")); + .setToolDescription("Get weather") + .setRequestProvider("openai") + .setRequestModel("gpt-5")); recorder.setResult(new ToolExecutionResult().setArguments(java.util.Map.of("city", "Paris")).setResult("18C")); recorder.end(); } @@ -97,6 +99,8 @@ void toolSpanNameAndAttributesMatchContract() { assertThat(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_SDK_NAME))).isEqualTo("sdk-java"); assertThat(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_TOOL_NAME))).isEqualTo("weather"); assertThat(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_TOOL_CALL_ID))).isEqualTo("call-1"); + assertThat(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_PROVIDER_NAME))).isEqualTo("openai"); + assertThat(span.getAttributes().get(AttributeKey.stringKey(SigilClient.SPAN_ATTR_REQUEST_MODEL))).isEqualTo("gpt-5"); provider.shutdown(); } diff --git a/java/frameworks/google-adk/README.md b/java/frameworks/google-adk/README.md index 24fd310..1ac7741 100644 --- a/java/frameworks/google-adk/README.md +++ b/java/frameworks/google-adk/README.md @@ -8,6 +8,7 @@ This module maps Google ADK callback/interceptor lifecycles to Sigil generation - Optional lineage metadata (`run_id`, `thread_id`, `parent_run_id`, `event_id`) - SYNC and STREAM lifecycle support - Tool lifecycle support +- Explicit embeddings unsupported contract via `SigilGoogleAdkAdapter.checkEmbeddingsSupport()` ## Install diff --git a/java/frameworks/google-adk/build.gradle.kts b/java/frameworks/google-adk/build.gradle.kts index b465e77..4804368 100644 --- a/java/frameworks/google-adk/build.gradle.kts +++ b/java/frameworks/google-adk/build.gradle.kts @@ -9,4 +9,7 @@ dependencies { testImplementation(libs.junit.jupiter) testRuntimeOnly("org.junit.platform:junit-platform-launcher") testImplementation(libs.assertj.core) + testImplementation(libs.otel.sdk.trace) + testImplementation(libs.otel.sdk.metrics) + testImplementation(libs.otel.sdk.testing) } diff --git a/java/frameworks/google-adk/src/main/java/com/grafana/sigil/sdk/frameworks/googleadk/SigilGoogleAdkAdapter.java b/java/frameworks/google-adk/src/main/java/com/grafana/sigil/sdk/frameworks/googleadk/SigilGoogleAdkAdapter.java index a4516ed..8f934b8 100644 --- a/java/frameworks/google-adk/src/main/java/com/grafana/sigil/sdk/frameworks/googleadk/SigilGoogleAdkAdapter.java +++ b/java/frameworks/google-adk/src/main/java/com/grafana/sigil/sdk/frameworks/googleadk/SigilGoogleAdkAdapter.java @@ -29,6 +29,8 @@ public final class SigilGoogleAdkAdapter { private static final String FRAMEWORK_SOURCE = "handler"; private static final String FRAMEWORK_LANGUAGE = "java"; private static final int MAX_METADATA_DEPTH = 5; + private static final String EMBEDDINGS_UNSUPPORTED_MESSAGE = + "google-adk: embeddings are not supported because the Google ADK lifecycle surface does not expose a dedicated embeddings callback"; static final String META_RUN_ID = "sigil.framework.run_id"; static final String META_THREAD_ID = "sigil.framework.thread_id"; @@ -84,6 +86,15 @@ public static Callbacks createCallbacks(SigilClient client, Options options) { return new SigilGoogleAdkAdapter(client, options).callbacks(); } + /** + * Reports whether this adapter can observe a native Google ADK embeddings lifecycle. + * The current lifecycle surface only exposes run and tool callbacks, so embeddings + * remain unsupported until ADK exposes a dedicated embeddings callback. + */ + public static void checkEmbeddingsSupport() { + throw new UnsupportedOperationException(EMBEDDINGS_UNSUPPORTED_MESSAGE); + } + public void onRunStart(RunStartEvent event) { if (event == null || event.getRunId().isBlank()) { return; diff --git a/java/frameworks/google-adk/src/test/java/com/grafana/sigil/sdk/frameworks/googleadk/GoogleAdkConformanceTest.java b/java/frameworks/google-adk/src/test/java/com/grafana/sigil/sdk/frameworks/googleadk/GoogleAdkConformanceTest.java new file mode 100644 index 0000000..61438af --- /dev/null +++ b/java/frameworks/google-adk/src/test/java/com/grafana/sigil/sdk/frameworks/googleadk/GoogleAdkConformanceTest.java @@ -0,0 +1,220 @@ +package com.grafana.sigil.sdk.frameworks.googleadk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.grafana.sigil.sdk.ExportGenerationResult; +import com.grafana.sigil.sdk.ExportGenerationsRequest; +import com.grafana.sigil.sdk.ExportGenerationsResponse; +import com.grafana.sigil.sdk.Generation; +import com.grafana.sigil.sdk.GenerationExportConfig; +import com.grafana.sigil.sdk.GenerationExporter; +import com.grafana.sigil.sdk.MessagePart; +import com.grafana.sigil.sdk.MessageRole; +import com.grafana.sigil.sdk.SigilClient; +import com.grafana.sigil.sdk.SigilClientConfig; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import org.junit.jupiter.api.Test; + +class GoogleAdkConformanceTest { + @Test + void runLifecycleConformancePropagatesFrameworkMetadataAndParentSpan() throws Exception { + try (ConformanceEnv env = new ConformanceEnv()) { + Span parent = env.tracerProvider.get("google-adk-framework").spanBuilder("google-adk.parent").startSpan(); + try (var scope = parent.makeCurrent()) { + SigilGoogleAdkAdapter adapter = new SigilGoogleAdkAdapter(env.client, new SigilGoogleAdkAdapter.Options() + .setAgentName("planner") + .setAgentVersion("1.0.0") + .setCaptureInputs(true) + .setCaptureOutputs(true)); + + adapter.onRunStart(new SigilGoogleAdkAdapter.RunStartEvent() + .setRunId("run-sync") + .setConversationId("conversation-42") + .setThreadId("thread-42") + .setParentRunId("parent-run-42") + .setEventId("event-42") + .setComponentName("planner") + .setRunType("chat") + .setRetryAttempt(2) + .addTag("prod") + .addTag("framework") + .setModelName("gpt-5") + .addPrompt("hello") + .putMetadata("team", "infra")); + adapter.onRunEnd("run-sync", new SigilGoogleAdkAdapter.RunEndEvent() + .setResponseModel("gpt-5") + .setStopReason("stop") + .setUsage(new com.grafana.sigil.sdk.TokenUsage().setInputTokens(12L).setOutputTokens(4L).setTotalTokens(16L))); + } finally { + parent.end(); + } + + Generation generation = env.singleGeneration(); + SpanData span = env.latestGenerationSpan(); + List metricNames = env.metricNames(); + + assertThat(generation.getTags()) + .containsEntry("sigil.framework.name", "google-adk") + .containsEntry("sigil.framework.source", "handler") + .containsEntry("sigil.framework.language", "java"); + assertThat(generation.getConversationId()).isEqualTo("conversation-42"); + assertThat(generation.getMetadata()) + .containsEntry("sigil.framework.run_id", "run-sync") + .containsEntry("sigil.framework.run_type", "chat") + .containsEntry("sigil.framework.thread_id", "thread-42") + .containsEntry("sigil.framework.parent_run_id", "parent-run-42") + .containsEntry("sigil.framework.component_name", "planner") + .containsEntry("sigil.framework.retry_attempt", 2) + .containsEntry("sigil.framework.event_id", "event-42"); + assertThat(generation.getMetadata().get("sigil.framework.tags")).isEqualTo(List.of("prod", "framework")); + assertThat(generation.getMetadata()).containsEntry("team", "infra"); + assertThat(span.getParentSpanContext().getSpanId()).isEqualTo(parent.getSpanContext().getSpanId()); + assertThat(metricNames).contains("gen_ai.client.operation.duration"); + assertThat(metricNames).doesNotContain("gen_ai.client.time_to_first_token"); + } + } + + @Test + void streamingConformanceStitchesOutputAndRecordsFirstTokenMetric() throws Exception { + try (ConformanceEnv env = new ConformanceEnv()) { + SigilGoogleAdkAdapter adapter = new SigilGoogleAdkAdapter(env.client, new SigilGoogleAdkAdapter.Options() + .setCaptureInputs(true) + .setCaptureOutputs(true)); + + adapter.onRunStart(new SigilGoogleAdkAdapter.RunStartEvent() + .setRunId("run-stream") + .setThreadId("thread-stream-42") + .setRunType("chat") + .setStream(true) + .setModelName("claude-sonnet-4-5") + .addPrompt("stream this")); + adapter.onRunToken("run-stream", "hello"); + adapter.onRunToken("run-stream", " world"); + adapter.onRunEnd("run-stream", new SigilGoogleAdkAdapter.RunEndEvent().setResponseModel("claude-sonnet-4-5")); + + env.client.shutdown(); + + Generation generation = env.singleGeneration(); + SpanData span = env.latestGenerationSpan(); + List metricNames = env.metricNames(); + + assertThat(generation.getMode()).isEqualTo(com.grafana.sigil.sdk.GenerationMode.STREAM); + assertThat(generation.getOperationName()).isEqualTo("streamText"); + assertThat(generation.getOutput()).hasSize(1); + assertThat(generation.getOutput().get(0).getRole()).isEqualTo(MessageRole.ASSISTANT); + assertThat(generation.getOutput().get(0).getParts()).hasSize(1); + assertThat(generation.getOutput().get(0).getParts().get(0).getText()).isEqualTo("hello world"); + assertThat(span.getAttributes().get(AttributeKey.stringKey("gen_ai.operation.name"))).isEqualTo("streamText"); + assertThat(metricNames).contains("gen_ai.client.operation.duration", "gen_ai.client.time_to_first_token"); + } + } + + @Test + void embeddingsConformanceUsesUnsupportedCapabilityContract() { + assertThatThrownBy(SigilGoogleAdkAdapter::checkEmbeddingsSupport) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessage( + "google-adk: embeddings are not supported because the Google ADK lifecycle surface does not expose a dedicated embeddings callback"); + } + + private static final class ConformanceEnv implements AutoCloseable { + private final CapturingExporter exporter = new CapturingExporter(); + private final InMemorySpanExporter spanExporter = InMemorySpanExporter.create(); + private final InMemoryMetricReader metricReader = InMemoryMetricReader.create(); + private final SdkTracerProvider tracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(spanExporter)) + .build(); + private final SdkMeterProvider meterProvider = SdkMeterProvider.builder() + .registerMetricReader(metricReader) + .build(); + private final SigilClient client = new SigilClient(new SigilClientConfig() + .setTracer(tracerProvider.get("google-adk-conformance")) + .setMeter(meterProvider.get("google-adk-conformance")) + .setGenerationExporter(exporter) + .setGenerationExport(new GenerationExportConfig() + .setBatchSize(1) + .setQueueSize(10) + .setFlushInterval(Duration.ofHours(1)) + .setMaxRetries(0))); + + Generation singleGeneration() { + awaitRequests(); + assertThat(exporter.requests).hasSize(1); + assertThat(exporter.requests.get(0)).hasSize(1); + return exporter.requests.get(0).get(0); + } + + SpanData latestGenerationSpan() { + List spans = spanExporter.getFinishedSpanItems().stream() + .filter(span -> { + String operation = span.getAttributes().get(AttributeKey.stringKey("gen_ai.operation.name")); + return "generateText".equals(operation) || "streamText".equals(operation); + }) + .toList(); + assertThat(spans).isNotEmpty(); + return spans.get(spans.size() - 1); + } + + List metricNames() { + return metricReader.collectAllMetrics().stream() + .map(metric -> metric.getName()) + .distinct() + .sorted() + .toList(); + } + + private void awaitRequests() { + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while (System.nanoTime() < deadline) { + if (!exporter.requests.isEmpty()) { + return; + } + try { + Thread.sleep(10L); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError("interrupted while waiting for export", exception); + } + } + throw new AssertionError("timed out waiting for generation export"); + } + + @Override + public void close() throws Exception { + client.shutdown(); + meterProvider.close(); + tracerProvider.close(); + } + } + + private static final class CapturingExporter implements GenerationExporter { + private final List> requests = new CopyOnWriteArrayList<>(); + + @Override + public ExportGenerationsResponse exportGenerations(ExportGenerationsRequest request) { + List batch = new ArrayList<>(); + for (Generation generation : request.getGenerations()) { + batch.add(generation.copy()); + } + requests.add(batch); + + List results = new ArrayList<>(); + for (Generation generation : batch) { + results.add(new ExportGenerationResult().setGenerationId(generation.getId()).setAccepted(true)); + } + return new ExportGenerationsResponse().setResults(results); + } + } +} diff --git a/java/frameworks/google-adk/src/test/java/com/grafana/sigil/sdk/frameworks/googleadk/SigilGoogleAdkAdapterTest.java b/java/frameworks/google-adk/src/test/java/com/grafana/sigil/sdk/frameworks/googleadk/SigilGoogleAdkAdapterTest.java index 3a628ff..05e2107 100644 --- a/java/frameworks/google-adk/src/test/java/com/grafana/sigil/sdk/frameworks/googleadk/SigilGoogleAdkAdapterTest.java +++ b/java/frameworks/google-adk/src/test/java/com/grafana/sigil/sdk/frameworks/googleadk/SigilGoogleAdkAdapterTest.java @@ -2,6 +2,11 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.grafana.sigil.sdk.ExportGenerationResult; +import com.grafana.sigil.sdk.ExportGenerationsRequest; +import com.grafana.sigil.sdk.ExportGenerationsResponse; +import com.grafana.sigil.sdk.Generation; +import com.grafana.sigil.sdk.GenerationExporter; import com.grafana.sigil.sdk.GenerationMode; import com.grafana.sigil.sdk.GenerationRecorder; import com.grafana.sigil.sdk.GenerationExportConfig; @@ -13,7 +18,21 @@ import com.grafana.sigil.sdk.ModelRef; import com.grafana.sigil.sdk.SigilClient; import com.grafana.sigil.sdk.SigilClientConfig; +import com.grafana.sigil.sdk.TokenUsage; import com.grafana.sigil.sdk.ToolExecutionStart; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.context.Scope; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import java.lang.reflect.Method; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; @@ -157,6 +176,184 @@ void adapterUsesExplicitProviderWhenConfigured() { } } + @Test + void syncRunExportsFrameworkPayloadTagsAndMetrics() { + try (FrameworkConformanceEnv env = new FrameworkConformanceEnv()) { + SigilGoogleAdkAdapter adapter = new SigilGoogleAdkAdapter(env.client, new SigilGoogleAdkAdapter.Options() + .setAgentName("adk-agent") + .setAgentVersion("1.0.0") + .setCaptureInputs(true) + .setCaptureOutputs(true) + .putExtraTag("team", "infra") + .putExtraMetadata("workspace", "sigil")); + + var parentSpan = env.tracerProvider.get("sigil-framework-test") + .spanBuilder("framework.request") + .setAttribute(AttributeKey.stringKey("sigil.framework.name"), "google-adk") + .setAttribute(AttributeKey.stringKey("sigil.framework.source"), "handler") + .setAttribute(AttributeKey.stringKey("sigil.framework.language"), "java") + .startSpan(); + try (Scope ignored = parentSpan.makeCurrent()) { + adapter.onRunStart(new SigilGoogleAdkAdapter.RunStartEvent() + .setRunId("run-sync") + .setSessionId("session-42") + .setThreadId("thread-9") + .setParentRunId("framework-parent-run") + .setComponentName("planner") + .setRunType("chat") + .setRetryAttempt(2) + .setEventId("event-42") + .setModelName("gpt-5") + .addTag("prod") + .addTag("framework") + .addPrompt("hello") + .putMetadata("phase", "plan")); + adapter.onRunEnd("run-sync", new SigilGoogleAdkAdapter.RunEndEvent() + .setResponseModel("gpt-5") + .setStopReason("stop") + .setUsage(new TokenUsage().setInputTokens(3).setOutputTokens(2).setTotalTokens(5)) + .addOutputMessage(new Message() + .setRole(MessageRole.ASSISTANT) + .setParts(List.of(MessagePart.text("hi"))))); + } finally { + parentSpan.end(); + } + + env.client.flush(); + + Generation generation = env.exporter.singleGeneration(); + SpanData generationSpan = env.latestGenerationSpan(); + + assertThat(generation.getMode()).isEqualTo(GenerationMode.SYNC); + assertThat(generation.getOperationName()).isEqualTo("generateText"); + assertThat(generation.getConversationId()).isEqualTo("session-42"); + assertThat(generation.getResponseModel()).isEqualTo("gpt-5"); + assertThat(generation.getTraceId()).isEqualTo(generationSpan.getTraceId()); + assertThat(generation.getSpanId()).isEqualTo(generationSpan.getSpanId()); + assertThat(generation.getTags()) + .containsEntry("sigil.framework.name", "google-adk") + .containsEntry("sigil.framework.source", "handler") + .containsEntry("sigil.framework.language", "java") + .containsEntry("team", "infra"); + assertThat(generation.getMetadata()) + .containsEntry("workspace", "sigil") + .containsEntry("phase", "plan") + .containsEntry(SigilGoogleAdkAdapter.META_RUN_ID, "run-sync") + .containsEntry(SigilGoogleAdkAdapter.META_RUN_TYPE, "chat") + .containsEntry(SigilGoogleAdkAdapter.META_THREAD_ID, "thread-9") + .containsEntry(SigilGoogleAdkAdapter.META_PARENT_RUN_ID, "framework-parent-run") + .containsEntry(SigilGoogleAdkAdapter.META_COMPONENT_NAME, "planner") + .containsEntry(SigilGoogleAdkAdapter.META_RETRY_ATTEMPT, 2) + .containsEntry(SigilGoogleAdkAdapter.META_EVENT_ID, "event-42") + .containsEntry(SigilGoogleAdkAdapter.META_TAGS, List.of("prod", "framework")); + assertThat(generation.getOutput()).hasSize(1); + assertThat(generation.getOutput().get(0).getParts()).hasSize(1); + assertThat(generation.getOutput().get(0).getParts().get(0).getText()).isEqualTo("hi"); + assertThat(generationSpan.getParentSpanId()).isEqualTo(parentSpan.getSpanContext().getSpanId()); + assertThat(env.metricNames()) + .contains("gen_ai.client.operation.duration") + .doesNotContain("gen_ai.client.time_to_first_token"); + } + } + + @Test + void streamRunExportsStitchedOutputAndTtftMetric() { + try (FrameworkConformanceEnv env = new FrameworkConformanceEnv()) { + SigilGoogleAdkAdapter adapter = new SigilGoogleAdkAdapter(env.client, new SigilGoogleAdkAdapter.Options() + .setCaptureInputs(true) + .setCaptureOutputs(true)); + + adapter.onRunStart(new SigilGoogleAdkAdapter.RunStartEvent() + .setRunId("run-stream-export") + .setSessionId("session-stream") + .setModelName("claude-sonnet-4-5") + .setStream(true) + .addPrompt("stream me")); + adapter.onRunToken("run-stream-export", "hello"); + adapter.onRunToken("run-stream-export", " world"); + adapter.onRunEnd("run-stream-export", new SigilGoogleAdkAdapter.RunEndEvent() + .setResponseModel("claude-sonnet-4-5")); + + env.client.flush(); + + Generation generation = env.exporter.singleGeneration(); + assertThat(generation.getMode()).isEqualTo(GenerationMode.STREAM); + assertThat(generation.getOperationName()).isEqualTo("streamText"); + assertThat(generation.getResponseModel()).isEqualTo("claude-sonnet-4-5"); + assertThat(generation.getOutput()).hasSize(1); + assertThat(generation.getOutput().get(0).getParts()).hasSize(1); + assertThat(generation.getOutput().get(0).getParts().get(0).getText()).isEqualTo("hello world"); + assertThat(generation.getTags()) + .containsEntry("sigil.framework.name", "google-adk") + .containsEntry("sigil.framework.source", "handler") + .containsEntry("sigil.framework.language", "java"); + assertThat(env.metricNames()) + .contains("gen_ai.client.operation.duration", "gen_ai.client.time_to_first_token"); + } + } + + @Test + void generationSpanTracksActiveParentSpanAndPreservesExportLineage() { + InMemorySpanExporter spanExporter = InMemorySpanExporter.create(); + SdkTracerProvider tracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(spanExporter)) + .build(); + SigilClient client = new SigilClient( + new SigilClientConfig() + .setTracer(tracerProvider.get("sigil-framework-test")) + .setGenerationExport( + new GenerationExportConfig() + .setProtocol(GenerationExportProtocol.NONE))); + try { + SigilGoogleAdkAdapter adapter = new SigilGoogleAdkAdapter(client, new SigilGoogleAdkAdapter.Options() + .setCaptureInputs(true) + .setCaptureOutputs(true)); + + var parentSpan = tracerProvider.get("sigil-framework-test").spanBuilder("framework.request").startSpan(); + try (Scope ignored = parentSpan.makeCurrent()) { + adapter.onRunStart(new SigilGoogleAdkAdapter.RunStartEvent() + .setRunId("run-lineage") + .setSessionId("session-lineage-42") + .setParentRunId("framework-parent-run") + .setRunType("chat") + .setModelName("gpt-5") + .addPrompt("hello")); + adapter.onRunEnd("run-lineage", new SigilGoogleAdkAdapter.RunEndEvent() + .setResponseModel("gpt-5") + .setStopReason("stop")); + } finally { + parentSpan.end(); + } + + assertThat(client.debugSnapshot().getGenerations()).hasSize(1); + var generation = client.debugSnapshot().getGenerations().get(0); + var generationSpan = spanExporter.getFinishedSpanItems().stream() + .filter(span -> "generateText".equals(span.getAttributes().get(io.opentelemetry.api.common.AttributeKey.stringKey("gen_ai.operation.name")))) + .findFirst() + .orElseThrow(); + + assertThat(generationSpan.getParentSpanId()).isEqualTo(parentSpan.getSpanContext().getSpanId()); + assertThat(generationSpan.getTraceId()).isEqualTo(parentSpan.getSpanContext().getTraceId()); + assertThat(generation.getTraceId()).isEqualTo(generationSpan.getTraceId()); + assertThat(generation.getSpanId()).isEqualTo(generationSpan.getSpanId()); + } finally { + client.shutdown(); + tracerProvider.close(); + } + } + + @Test + void adapterExplicitlyHasNoEmbeddingLifecycle() { + List publicMethodNames = Arrays.stream(SigilGoogleAdkAdapter.class.getMethods()) + .map(Method::getName) + .toList(); + + assertThat(publicMethodNames) + .doesNotContain("onEmbeddingStart") + .doesNotContain("onEmbeddingEnd") + .doesNotContain("onEmbeddingError"); + } + @Test void onRunEndDropsOutputsWhenCaptureOutputsDisabled() { SigilClient client = newClient(); @@ -377,4 +574,67 @@ void createCallbacksProvidesOneTimeLifecycleWiring() { client.shutdown(); } } + + private static final class FrameworkConformanceEnv implements AutoCloseable { + private final CapturingExporter exporter = new CapturingExporter(); + private final InMemorySpanExporter spanExporter = InMemorySpanExporter.create(); + private final SdkTracerProvider tracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(spanExporter)) + .build(); + private final InMemoryMetricReader metricReader = InMemoryMetricReader.create(); + private final SdkMeterProvider meterProvider = SdkMeterProvider.builder() + .registerMetricReader(metricReader) + .build(); + private final SigilClient client = new SigilClient(new SigilClientConfig() + .setTracer(tracerProvider.get("sigil-framework-test")) + .setMeter(meterProvider.get("sigil-framework-test")) + .setGenerationExporter(exporter) + .setGenerationExport(new GenerationExportConfig() + .setBatchSize(1) + .setQueueSize(10) + .setFlushInterval(Duration.ofHours(1)) + .setMaxRetries(0))); + + private List metricNames() { + return metricReader.collectAllMetrics().stream() + .map(MetricData::getName) + .toList(); + } + + private SpanData latestGenerationSpan() { + return spanExporter.getFinishedSpanItems().stream() + .filter(span -> { + String operation = span.getAttributes().get(AttributeKey.stringKey("gen_ai.operation.name")); + return "generateText".equals(operation) || "streamText".equals(operation); + }) + .reduce((first, second) -> second) + .orElseThrow(); + } + + @Override + public void close() { + client.shutdown(); + tracerProvider.close(); + meterProvider.close(); + } + } + + private static final class CapturingExporter implements GenerationExporter { + private final List generations = new ArrayList<>(); + + @Override + public ExportGenerationsResponse exportGenerations(ExportGenerationsRequest request) { + List results = new ArrayList<>(); + for (Generation generation : request.getGenerations()) { + generations.add(generation.copy()); + results.add(new ExportGenerationResult().setGenerationId(generation.getId()).setAccepted(true)); + } + return new ExportGenerationsResponse().setResults(results); + } + + private Generation singleGeneration() { + assertThat(generations).hasSize(1); + return generations.get(0); + } + } } diff --git a/java/gradle/libs.versions.toml b/java/gradle/libs.versions.toml index 51826c2..e8349a1 100644 --- a/java/gradle/libs.versions.toml +++ b/java/gradle/libs.versions.toml @@ -1,13 +1,13 @@ [versions] assertj = "3.27.7" -jackson = "2.21.1" +jackson = "2.21.2" jacksonAnnotations = "2.21" jmh = "0.7.3" junit = "6.0.3" -otel = "1.59.0" -protobuf = "4.33.5" +otel = "1.60.1" +protobuf = "4.34.1" protobufPlugin = "0.9.6" -grpc = "1.79.0" +grpc = "1.80.0" mockwebserver = "5.3.2" javaxAnnotation = "1.3.2" @@ -41,9 +41,9 @@ junit-jupiter = { module = "org.junit.jupiter:junit-jupiter" } mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "mockwebserver" } javax-annotation = { module = "javax.annotation:javax.annotation-api", version.ref = "javaxAnnotation" } -openai-java = { module = "com.openai:openai-java", version = "4.22.0" } -anthropic-java = { module = "com.anthropic:anthropic-java", version = "2.15.0" } -google-genai = { module = "com.google.genai:google-genai", version = "1.40.0" } +openai-java = { module = "com.openai:openai-java", version = "4.29.1" } +anthropic-java = { module = "com.anthropic:anthropic-java", version = "2.18.0" } +google-genai = { module = "com.google.genai:google-genai", version = "1.44.0" } [plugins] protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" } diff --git a/java/gradle/wrapper/gradle-wrapper.properties b/java/gradle/wrapper/gradle-wrapper.properties index 37f78a6..c61a118 100644 --- a/java/gradle/wrapper/gradle-wrapper.properties +++ b/java/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/java/providers/anthropic/src/test/java/com/grafana/sigil/sdk/providers/anthropic/AnthropicAdapterTest.java b/java/providers/anthropic/src/test/java/com/grafana/sigil/sdk/providers/anthropic/AnthropicConformanceTest.java similarity index 75% rename from java/providers/anthropic/src/test/java/com/grafana/sigil/sdk/providers/anthropic/AnthropicAdapterTest.java rename to java/providers/anthropic/src/test/java/com/grafana/sigil/sdk/providers/anthropic/AnthropicConformanceTest.java index 88f310a..ad00b2c 100644 --- a/java/providers/anthropic/src/test/java/com/grafana/sigil/sdk/providers/anthropic/AnthropicAdapterTest.java +++ b/java/providers/anthropic/src/test/java/com/grafana/sigil/sdk/providers/anthropic/AnthropicConformanceTest.java @@ -25,7 +25,7 @@ import java.util.stream.Stream; import org.junit.jupiter.api.Test; -class AnthropicAdapterTest { +class AnthropicConformanceTest { @Test void syncAndStreamWrappersSetAnthropicProviderAndModes() throws Exception { CapturingExporter exporter = new CapturingExporter(); @@ -105,6 +105,56 @@ void providerErrorsPopulateCallError() { assertThat(exporter.generations.get(0).getCallError()).contains("anthropic failed"); } + @Test + void wrappersTolerateMissingProviderPayloadFields() throws Exception { + CapturingExporter exporter = new CapturingExporter(); + try (SigilClient client = new SigilClient(new SigilClientConfig() + .setTracer(GlobalOpenTelemetry.getTracer("test")) + .setGenerationExporter(exporter) + .setGenerationExport(new GenerationExportConfig().setBatchSize(1).setFlushInterval(Duration.ofMinutes(10)).setMaxRetries(0)))) { + + AnthropicAdapter.completion( + client, + request(), + _r -> ObjectMappers.jsonMapper().readValue( + """ + { + "id": "msg_malformed", + "content": [], + "model": "claude-sonnet-4", + "usage": { + "input_tokens": 0, + "output_tokens": 0 + } + } + """, + Message.class), + new AnthropicOptions()); + AnthropicAdapter.completionStream( + client, + request(), + _r -> new FakeStreamResponse<>(List.of()), + new AnthropicOptions()); + } + + assertThat(exporter.generations).hasSize(2); + assertThat(exporter.generations.get(0).getMode()).isEqualTo(GenerationMode.SYNC); + assertThat(exporter.generations.get(0).getResponseId()).isEqualTo("msg_malformed"); + assertThat(exporter.generations.get(0).getResponseModel()).isEqualTo("claude-sonnet-4"); + assertThat(exporter.generations.get(0).getOutput()).isEmpty(); + assertThat(exporter.generations.get(0).getStopReason()).isEmpty(); + assertThat(exporter.generations.get(1).getMode()).isEqualTo(GenerationMode.STREAM); + assertThat(exporter.generations.get(1).getResponseModel()).isEqualTo("claude-sonnet-4"); + assertThat(exporter.generations.get(1).getOutput()).isEmpty(); + } + + @Test + void embeddingConformanceIsExplicitlyUnsupportedWithoutPublicSurface() { + assertThat(AnthropicAdapter.class).isNotNull(); + assertThatThrownBy(() -> Class.forName("com.grafana.sigil.sdk.providers.anthropic.AnthropicEmbeddings")) + .isInstanceOf(ClassNotFoundException.class); + } + @Test void mapperSetsThinkingFalseWhenDisabled() throws Exception { MessageCreateParams request = MessageCreateParams.builder() @@ -118,6 +168,12 @@ void mapperSetsThinkingFalseWhenDisabled() throws Exception { assertThat(mapped.getThinkingEnabled()).isFalse(); } + @Test + void mapperRejectsMissingResponse() { + assertThatThrownBy(() -> AnthropicAdapter.fromRequestResponse(request(), null, new AnthropicOptions())) + .isInstanceOf(NullPointerException.class); + } + private static MessageCreateParams request() { return MessageCreateParams.builder() .model("claude-sonnet-4") diff --git a/java/providers/gemini/README.md b/java/providers/gemini/README.md index 7be3b9e..437fd5a 100644 --- a/java/providers/gemini/README.md +++ b/java/providers/gemini/README.md @@ -9,9 +9,11 @@ No simplified public DTO layer is exposed. - Wrappers: - `GeminiAdapter.completion(...)` - `GeminiAdapter.completionStream(...)` + - `GeminiAdapter.embedContent(...)` - Manual mappers: - `GeminiAdapter.fromRequestResponse(...)` - `GeminiAdapter.fromStream(...)` + - `GeminiAdapter.embeddingFromResponse(...)` ## Official SDK Types @@ -54,6 +56,22 @@ GeminiStreamSummary summary = GeminiAdapter.completionStream( ); ``` +## Embedding Example + +```java +EmbedContentResponse embeddingResponse = GeminiAdapter.embedContent( + sigilClient, + "gemini-embedding-001", + java.util.List.of("hello", "world"), + null, + (model, input, cfg) -> genai.models.embedContent(model, input, cfg), + new GeminiOptions() + .setConversationId("conv-1") + .setAgentName("assistant-gemini") + .setAgentVersion("1.0.0") +); +``` + ## Raw Artifact Policy - Default: OFF diff --git a/java/providers/gemini/src/test/java/com/grafana/sigil/sdk/providers/gemini/GeminiAdapterTest.java b/java/providers/gemini/src/test/java/com/grafana/sigil/sdk/providers/gemini/GeminiConformanceTest.java similarity index 84% rename from java/providers/gemini/src/test/java/com/grafana/sigil/sdk/providers/gemini/GeminiAdapterTest.java rename to java/providers/gemini/src/test/java/com/grafana/sigil/sdk/providers/gemini/GeminiConformanceTest.java index a40303d..79ee046 100644 --- a/java/providers/gemini/src/test/java/com/grafana/sigil/sdk/providers/gemini/GeminiAdapterTest.java +++ b/java/providers/gemini/src/test/java/com/grafana/sigil/sdk/providers/gemini/GeminiConformanceTest.java @@ -26,7 +26,7 @@ import java.util.concurrent.CopyOnWriteArrayList; import org.junit.jupiter.api.Test; -class GeminiAdapterTest { +class GeminiConformanceTest { @Test void syncAndStreamWrappersSetGeminiProviderAndModes() throws Exception { CapturingExporter exporter = new CapturingExporter(); @@ -105,6 +105,53 @@ void providerErrorsPopulateCallError() { assertThat(exporter.generations.get(0).getCallError()).contains("gemini failed"); } + @Test + void wrappersTolerateMissingProviderPayloadFields() throws Exception { + CapturingExporter exporter = new CapturingExporter(); + try (SigilClient client = new SigilClient(new SigilClientConfig() + .setTracer(GlobalOpenTelemetry.getTracer("test")) + .setGenerationExporter(exporter) + .setGenerationExport(new GenerationExportConfig().setBatchSize(1).setFlushInterval(Duration.ofMinutes(10)).setMaxRetries(0)))) { + + GeminiAdapter.completion( + client, + model(), + contents(), + config(), + (_m, _c, _cfg) -> GenerateContentResponse.fromJson( + """ + { + "responseId": "resp_malformed", + "modelVersion": "gemini-2.5-pro-001", + "candidates": [] + } + """), + new GeminiOptions()); + GeminiAdapter.completionStream( + client, + model(), + contents(), + config(), + (_m, _c, _cfg) -> List.of(GenerateContentResponse.fromJson( + """ + { + "modelVersion": "gemini-2.5-pro-001" + } + """)), + new GeminiOptions()); + } + + assertThat(exporter.generations).hasSize(2); + assertThat(exporter.generations.get(0).getMode()).isEqualTo(GenerationMode.SYNC); + assertThat(exporter.generations.get(0).getResponseId()).isEqualTo("resp_malformed"); + assertThat(exporter.generations.get(0).getResponseModel()).isEqualTo("gemini-2.5-pro-001"); + assertThat(exporter.generations.get(0).getOutput()).isEmpty(); + assertThat(exporter.generations.get(0).getStopReason()).isEmpty(); + assertThat(exporter.generations.get(1).getMode()).isEqualTo(GenerationMode.STREAM); + assertThat(exporter.generations.get(1).getResponseModel()).isEqualTo("gemini-2.5-pro-001"); + assertThat(exporter.generations.get(1).getOutput()).isEmpty(); + } + @Test void embeddingWrapperDoesNotEnqueueGenerations() throws Exception { CapturingExporter exporter = new CapturingExporter(); diff --git a/java/providers/openai/README.md b/java/providers/openai/README.md index a3bca17..a14c72d 100644 --- a/java/providers/openai/README.md +++ b/java/providers/openai/README.md @@ -19,6 +19,9 @@ No simplified OpenAI DTO layer is exposed. - `OpenAiResponses.createStreaming(...)` - `OpenAiResponses.fromRequestResponse(...)` - `OpenAiResponses.fromStream(...)` +- Embeddings: + - `OpenAiEmbeddings.create(...)` + - `OpenAiEmbeddings.fromRequestResponse(...)` ## Integration styles @@ -72,6 +75,23 @@ Response response = OpenAiResponses.create( ); ``` +## Embeddings Example + +```java +CreateEmbeddingResponse embeddingResponse = OpenAiEmbeddings.create( + sigilClient, + EmbeddingCreateParams.builder() + .model("text-embedding-3-small") + .inputOfArrayOfStrings(java.util.List.of("hello", "world")) + .build(), + params -> openAI.embeddings().create(params), + new OpenAiOptions() + .setConversationId("conv-1") + .setAgentName("assistant-openai") + .setAgentVersion("1.0.0") +); +``` + ## Manual instrumentation example (strict mapper) ```java diff --git a/java/providers/openai/src/test/java/com/grafana/sigil/sdk/providers/openai/OpenAiMappingAndRecorderTests.java b/java/providers/openai/src/test/java/com/grafana/sigil/sdk/providers/openai/OpenAiConformanceTest.java similarity index 88% rename from java/providers/openai/src/test/java/com/grafana/sigil/sdk/providers/openai/OpenAiMappingAndRecorderTests.java rename to java/providers/openai/src/test/java/com/grafana/sigil/sdk/providers/openai/OpenAiConformanceTest.java index d577cff..cc34495 100644 --- a/java/providers/openai/src/test/java/com/grafana/sigil/sdk/providers/openai/OpenAiMappingAndRecorderTests.java +++ b/java/providers/openai/src/test/java/com/grafana/sigil/sdk/providers/openai/OpenAiConformanceTest.java @@ -34,7 +34,7 @@ import java.util.stream.Stream; import org.junit.jupiter.api.Test; -class OpenAiMappingAndRecorderTests { +class OpenAiConformanceTest { @Test void chatSyncWrapperSetsSyncModeAndRawArtifactsOffByDefault() throws Exception { CapturingExporter exporter = new CapturingExporter(); @@ -137,6 +137,58 @@ void providerErrorsPopulateCallErrorForChatAndResponses() throws Exception { } } + @Test + void wrappersTolerateMissingProviderPayloadFields() throws Exception { + try (SigilClient client = newClient(new CapturingExporter())) { + OpenAiChatCompletions.create( + client, + chatRequestFixture(), + _request -> json( + """ + { + "id": "chatcmpl_malformed", + "choices": [], + "created": 1, + "model": "gpt-5", + "object": "chat.completion" + } + """, + ChatCompletion.class), + new OpenAiOptions()); + Generation chatGeneration = singleDebugGeneration(client); + assertThat(chatGeneration.getMode()).isEqualTo(GenerationMode.SYNC); + assertThat(chatGeneration.getResponseId()).isEqualTo("chatcmpl_malformed"); + assertThat(chatGeneration.getResponseModel()).isEqualTo("gpt-5"); + assertThat(chatGeneration.getOutput()).isEmpty(); + assertThat(chatGeneration.getStopReason()).isEmpty(); + } + + try (SigilClient client = newClient(new CapturingExporter())) { + OpenAiResponses.createStreaming( + client, + responsesRequestFixture(), + _request -> new FakeStreamResponse<>(List.of( + json( + """ + { + "type": "response.output_text.delta", + "content_index": 0, + "delta": 42, + "item_id": "msg_1", + "output_index": 0, + "sequence_number": 1 + } + """, + ResponseStreamEvent.class))), + new OpenAiOptions()); + Generation streamGeneration = singleDebugGeneration(client); + assertThat(streamGeneration.getMode()).isEqualTo(GenerationMode.STREAM); + assertThat(streamGeneration.getOutput()).hasSize(1); + assertThat(streamGeneration.getOutput().get(0).getParts()).hasSize(1); + assertThat(streamGeneration.getOutput().get(0).getParts().get(0).getText()).isEqualTo("42"); + } + } + @Test void embeddingsWrapperDoesNotEnqueueGenerations() throws Exception { CapturingExporter exporter = new CapturingExporter(); diff --git a/java/settings.gradle.kts b/java/settings.gradle.kts index aa61a8d..e352393 100644 --- a/java/settings.gradle.kts +++ b/java/settings.gradle.kts @@ -6,7 +6,6 @@ include(":providers:anthropic") include(":providers:gemini") include(":frameworks:google-adk") include(":benchmarks") -include(":devex-emitter") project(":providers:openai").projectDir = file("providers/openai") project(":providers:anthropic").projectDir = file("providers/anthropic") diff --git a/js/LICENSE b/js/LICENSE index ae8c60c..626a3ab 100644 --- a/js/LICENSE +++ b/js/LICENSE @@ -1,3 +1,3 @@ SPDX-License-Identifier: Apache-2.0 -See /sdks/LICENSE at repository root for full license text. +See /LICENSE at repository root for full license text. diff --git a/js/README.md b/js/README.md index 2865c75..2c35f2d 100644 --- a/js/README.md +++ b/js/README.md @@ -8,6 +8,20 @@ Sigil records normalized LLM generation and tool-execution telemetry using your pnpm add @grafana/sigil-sdk-js ``` +## Validation + +Run the shared core conformance suite for the JavaScript SDK from the repo root: + +```bash +mise run test:ts:sdk-conformance +``` + +Run the cross-language aggregate core conformance suite from the repo root: + +```bash +mise run sdk:conformance +``` + ## Quick Start ```ts @@ -257,6 +271,7 @@ Auth is configured for `generationExport`. - `mode: "none"` - `mode: "tenant"` (requires `tenantId`, injects `X-Scope-OrgID`) - `mode: "bearer"` (requires `bearerToken`, injects `Authorization: Bearer `) +- `mode: "basic"` (requires `basicPassword` + `basicUser` or `tenantId`, injects `Authorization: Basic `; also injects `X-Scope-OrgID` when `tenantId` is set — for self-hosted multi-tenancy only, not needed for Grafana Cloud) Invalid mode/field combinations throw during client config resolution. @@ -275,6 +290,35 @@ const client = new SigilClient({ }); ``` +### Grafana Cloud auth (basic) + +For Grafana Cloud, use `basic` auth mode. The username is your Grafana Cloud instance/tenant ID and the password is your Grafana Cloud API key: + +```ts +const client = new SigilClient({ + generationExport: { + protocol: "http", + endpoint: "https://.grafana.net/api/v1/generations:export", + auth: { + mode: "basic", + tenantId: process.env.GRAFANA_CLOUD_INSTANCE_ID, + basicPassword: process.env.GRAFANA_CLOUD_API_KEY, + }, + }, +}); +``` + +If your deployment requires a distinct username, set `basicUser` explicitly: + +```ts +auth: { + mode: "basic", + tenantId: process.env.GRAFANA_CLOUD_INSTANCE_ID, + basicUser: process.env.GRAFANA_CLOUD_INSTANCE_ID, + basicPassword: process.env.GRAFANA_CLOUD_API_KEY, +}, +``` + ## Env-secret wiring example The SDK does not auto-load env vars. Resolve env secrets in your app and map them into config. @@ -299,7 +343,8 @@ const client = new SigilClient({ Common topology: -- Generations direct to Sigil: generation `tenant` mode. +- Grafana Cloud: generation `basic` mode with instance ID and API key. +- Self-hosted direct to Sigil: generation `tenant` mode. - Traces/metrics via OTEL Collector/Alloy: configure exporters in your app OTEL SDK setup. - Enterprise proxy: generation `bearer` mode to proxy; proxy authenticates and forwards tenant header upstream. diff --git a/js/docs/providers/gemini.md b/js/docs/providers/gemini.md index f2b4027..25982e6 100644 --- a/js/docs/providers/gemini.md +++ b/js/docs/providers/gemini.md @@ -7,9 +7,11 @@ This helper maps strict Gemini `model/contents/config` payloads into Sigil `Gene - Wrapper calls: - `gemini.models.generateContent(client, model, contents, config, providerCall, options?)` - `gemini.models.generateContentStream(client, model, contents, config, providerCall, options?)` + - `gemini.models.embedContent(client, model, contents, config, providerCall, options?)` - Mapper functions: - `gemini.models.fromRequestResponse(model, contents, config, response, options?)` - `gemini.models.fromStream(model, contents, config, summary, options?)` + - `gemini.models.embeddingFromResponse(model, contents, config, response)` - Raw artifacts (debug opt-in): - `request` - `response` (sync) @@ -54,6 +56,21 @@ try { } ``` +## Embedding example + +```ts +const embeddingResponse = await gemini.models.embedContent( + client, + 'gemini-embedding-001', + [{ parts: [{ text: 'hello' }] }, { parts: [{ text: 'world' }] }], + { outputDimensionality: 256 }, + async (reqModel, reqContents, reqConfig) => + provider.models.embedContent({ model: reqModel, contents: reqContents, config: reqConfig }) +); + +console.log(embeddingResponse.embeddings?.length ?? 0); +``` + ## Raw artifact policy - Default OFF. diff --git a/js/docs/providers/openai.md b/js/docs/providers/openai.md index 9022117..045aa84 100644 --- a/js/docs/providers/openai.md +++ b/js/docs/providers/openai.md @@ -18,6 +18,11 @@ This helper now mirrors official OpenAI SDK shapes for both Chat Completions and - `openai.responses.fromRequestResponse(request, response, options?)` - `openai.responses.fromStream(request, summary, options?)` +- Embeddings wrapper: + - `openai.embeddings.create(client, request, providerCall, options?)` +- Embeddings mapper: + - `openai.embeddings.fromRequestResponse(request, response)` + ## Integration styles - Strict wrappers: call OpenAI and record in one step. @@ -100,6 +105,21 @@ try { } ``` +## Embeddings example + +```ts +const embeddingResponse = await openai.embeddings.create( + sigil, + { + model: 'text-embedding-3-small', + input: ['hello', 'world'], + }, + async (request) => provider.embeddings.create(request) +); + +console.log(embeddingResponse.model); +``` + ## Raw artifact policy Raw artifacts are OFF by default. diff --git a/js/package.json b/js/package.json index 2c3ec0f..718b263 100644 --- a/js/package.json +++ b/js/package.json @@ -53,25 +53,27 @@ "test:ci": "pnpm run test" }, "dependencies": { - "@anthropic-ai/sdk": "^0.78.0", + "@anthropic-ai/sdk": "^0.80.0", + "@google/adk": "^0.5.0", "@google/genai": "^1.41.0", - "@google/adk": "^0.3.0", "@grpc/grpc-js": "^1.14.1", "@grpc/proto-loader": "^0.8.0", "@langchain/core": "^1.0.0", "@langchain/langgraph": "^1.2.0", - "@openai/agents": "^0.5.0", + "@openai/agents": "^0.8.0", "@opentelemetry/api": "^1.9.0", - "@opentelemetry/exporter-metrics-otlp-grpc": "^0.212.0", - "@opentelemetry/exporter-metrics-otlp-http": "^0.212.0", - "@opentelemetry/exporter-trace-otlp-grpc": "^0.212.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.212.0", + "@opentelemetry/exporter-metrics-otlp-grpc": "^0.213.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.213.0", + "@opentelemetry/exporter-trace-otlp-grpc": "^0.213.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.213.0", "@opentelemetry/sdk-metrics": "^2.1.0", "@opentelemetry/sdk-trace-base": "^2.5.0", - "openai": "^6.21.0", - "llamaindex": "^0.12.1" + "llamaindex": "^0.12.1", + "openai": "^6.27.0" }, "devDependencies": { - "typescript": "^5.9.3" + "@opentelemetry/context-async-hooks": "^2.6.0", + "@types/node": "^24.11.0", + "typescript": "^6.0.0" } } diff --git a/js/proto/sigil/v1/generation_ingest.proto b/js/proto/sigil/v1/generation_ingest.proto index 733a27f..fd7355d 100644 --- a/js/proto/sigil/v1/generation_ingest.proto +++ b/js/proto/sigil/v1/generation_ingest.proto @@ -83,6 +83,7 @@ message ToolDefinition { string description = 2; string type = 3; bytes input_schema_json = 4; + bool deferred = 5; } message TokenUsage { @@ -92,6 +93,7 @@ message TokenUsage { int64 cache_read_input_tokens = 4; int64 cache_write_input_tokens = 5; int64 reasoning_tokens = 6; + int64 cache_creation_input_tokens = 7; } enum ArtifactKind { diff --git a/js/src/client.ts b/js/src/client.ts index 0f5404b..a1a944b 100644 --- a/js/src/client.ts +++ b/js/src/client.ts @@ -1,4 +1,11 @@ import { defaultLogger, mergeConfig } from './config.js'; +import { + agentNameFromContext, + agentVersionFromContext, + conversationIdFromContext, + conversationTitleFromContext, + userIdFromContext, +} from './context.js'; import { createDefaultGenerationExporter } from './exporters/default.js'; import { metrics, SpanKind, SpanStatusCode, trace, type Histogram, type Meter, type Span, type Tracer } from '@opentelemetry/api'; import type { @@ -34,11 +41,13 @@ import { cloneEmbeddingStart, cloneGeneration, cloneGenerationResult, + cloneGenerationStart, cloneModelRef, cloneToolDefinition, cloneMessage, cloneArtifact, cloneToolExecution, + cloneToolExecutionStart, cloneToolExecutionResult, defaultOperationNameForMode, defaultSleep, @@ -62,6 +71,8 @@ const spanAttrFrameworkRetryAttempt = 'sigil.framework.retry_attempt'; const spanAttrFrameworkLangGraphNode = 'sigil.framework.langgraph.node'; const spanAttrFrameworkEventID = 'sigil.framework.event_id'; const spanAttrConversationID = 'gen_ai.conversation.id'; +const spanAttrConversationTitle = 'sigil.conversation.title'; +const spanAttrUserID = 'user.id'; const spanAttrAgentName = 'gen_ai.agent.name'; const spanAttrAgentVersion = 'gen_ai.agent.version'; const spanAttrErrorType = 'error.type'; @@ -116,6 +127,8 @@ const metricTokenTypeReasoning = 'reasoning'; const instrumentationName = 'github.com/grafana/sigil/sdks/js'; const sdkName = 'sdk-js'; const defaultEmbeddingOperationName = 'embeddings'; +const metadataUserIDKey = 'sigil.user.id'; +const metadataLegacyUserIDKey = 'user.id'; export class SigilClient { private readonly config: SigilSdkConfig; @@ -221,7 +234,14 @@ export class SigilClient { callback?: RecorderCallback ): EmbeddingRecorder | Promise { this.assertOpen(); - const recorder = new EmbeddingRecorderImpl(this, start); + const seed = cloneEmbeddingStart(start); + if (!notEmpty(seed.agentName)) { + seed.agentName = agentNameFromContext(); + } + if (!notEmpty(seed.agentVersion)) { + seed.agentVersion = agentVersionFromContext(); + } + const recorder = new EmbeddingRecorderImpl(this, seed); if (callback === undefined) { return recorder; } @@ -413,6 +433,8 @@ export class SigilClient { setGenerationSpanAttributes(span, { id: seed.id, conversationId: seed.conversationId, + conversationTitle: seed.conversationTitle, + userId: seed.userId, agentName: seed.agentName, agentVersion: seed.agentVersion, operationName, @@ -457,6 +479,10 @@ export class SigilClient { } } + internalSyncGenerationSpan(span: Span, generation: Generation): void { + setGenerationSpanAttributes(span, generation); + } + internalFinalizeGenerationSpan( span: Span, generation: Generation, @@ -466,7 +492,6 @@ export class SigilClient { firstTokenAt: Date | undefined ): void { span.updateName(generationSpanName(generation.operationName, generation.model.name)); - setGenerationSpanAttributes(span, generation); if (callError !== undefined) { span.recordException(new Error(callError)); @@ -686,8 +711,9 @@ export class SigilClient { const errorCategory = finalError === undefined ? '' : errorCategoryFromError(finalError, true); this.operationDurationHistogram.record(durationSeconds, { [spanAttrOperationName]: 'execute_tool', - [spanAttrProviderName]: '', - [spanAttrRequestModel]: toolExecution.toolName, + [spanAttrProviderName]: (toolExecution.requestProvider ?? '').trim(), + [spanAttrRequestModel]: (toolExecution.requestModel ?? '').trim(), + [spanAttrToolName]: toolExecution.toolName.trim(), [spanAttrAgentName]: toolExecution.agentName ?? '', [spanAttrErrorType]: errorType, [spanAttrErrorCategory]: errorCategory, @@ -801,6 +827,7 @@ export class SigilClient { } class GenerationRecorderImpl implements GenerationRecorder { + private readonly seed: GenerationStart; private readonly startedAt: Date; private readonly mode: GenerationMode; private readonly span: Span; @@ -812,12 +839,31 @@ class GenerationRecorderImpl implements GenerationRecorder { constructor( private readonly client: SigilClient, - private readonly seed: GenerationStart, + seed: GenerationStart, defaultMode: GenerationMode ) { - this.mode = seed.mode ?? defaultMode; - this.startedAt = seed.startedAt ?? this.client.internalNow(); - this.span = this.client.internalStartGenerationSpan(seed, this.mode, this.startedAt); + this.seed = cloneGenerationStart(seed); + if (!notEmpty(this.seed.conversationId)) { + this.seed.conversationId = conversationIdFromContext(); + } + if (!notEmpty(this.seed.conversationTitle)) { + this.seed.conversationTitle = conversationTitleFromContext(); + } + if (!notEmpty(this.seed.userId)) { + this.seed.userId = userIdFromContext(); + } + if (!notEmpty(this.seed.agentName)) { + this.seed.agentName = agentNameFromContext(); + } + if (!notEmpty(this.seed.agentVersion)) { + this.seed.agentVersion = agentVersionFromContext(); + } + if (!notEmpty(this.seed.operationName)) { + this.seed.operationName = defaultOperationNameForMode(this.seed.mode ?? defaultMode); + } + this.mode = this.seed.mode ?? defaultMode; + this.startedAt = this.seed.startedAt ?? this.client.internalNow(); + this.span = this.client.internalStartGenerationSpan(this.seed, this.mode, this.startedAt); } setResult(result: GenerationResult): void { @@ -852,9 +898,11 @@ class GenerationRecorderImpl implements GenerationRecorder { const generation: Generation = { id: this.seed.id ?? newLocalID('gen'), - conversationId: this.result?.conversationId ?? this.seed.conversationId, - agentName: this.result?.agentName ?? this.seed.agentName, - agentVersion: this.result?.agentVersion ?? this.seed.agentVersion, + conversationId: firstNonEmptyString(this.result?.conversationId, this.seed.conversationId), + conversationTitle: firstNonEmptyString(this.result?.conversationTitle, this.seed.conversationTitle), + userId: firstNonEmptyString(this.result?.userId, this.seed.userId), + agentName: firstNonEmptyString(this.result?.agentName, this.seed.agentName), + agentVersion: firstNonEmptyString(this.result?.agentVersion, this.seed.agentVersion), mode: this.mode, operationName: this.result?.operationName ?? this.seed.operationName ?? defaultOperationNameForMode(this.mode), model: cloneModelRef(this.seed.model), @@ -873,16 +921,35 @@ class GenerationRecorderImpl implements GenerationRecorder { stopReason: this.result?.stopReason, startedAt: new Date(this.startedAt), completedAt: new Date(this.result?.completedAt ?? this.client.internalNow()), - tags: this.result?.tags ? { ...this.result.tags } : this.seed.tags ? { ...this.seed.tags } : undefined, - metadata: this.result?.metadata - ? { ...this.result.metadata } - : this.seed.metadata - ? { ...this.seed.metadata } - : undefined, + tags: mergeStringRecords(this.seed.tags, this.result?.tags), + metadata: mergeUnknownRecords(this.seed.metadata, this.result?.metadata), artifacts: this.result?.artifacts?.map(cloneArtifact), callError: this.callError, }; + generation.conversationTitle = firstNonEmptyString( + generation.conversationTitle, + metadataStringValue(generation.metadata, spanAttrConversationTitle) + )?.trim(); + if (notEmpty(generation.conversationTitle)) { + if (generation.metadata === undefined) { + generation.metadata = {}; + } + generation.metadata[spanAttrConversationTitle] = generation.conversationTitle; + } + + generation.userId = firstNonEmptyString( + generation.userId, + metadataStringValue(generation.metadata, metadataUserIDKey), + metadataStringValue(generation.metadata, metadataLegacyUserIDKey) + )?.trim(); + if (notEmpty(generation.userId)) { + if (generation.metadata === undefined) { + generation.metadata = {}; + } + generation.metadata[metadataUserIDKey] = generation.userId; + } + if (this.callError !== undefined) { if (generation.metadata === undefined) { generation.metadata = {}; @@ -894,6 +961,7 @@ class GenerationRecorderImpl implements GenerationRecorder { } generation.metadata[spanAttrSDKName] = sdkName; + this.client.internalSyncGenerationSpan(this.span, generation); this.client.internalApplyTraceContextFromSpan(this.span, generation); this.client.internalRecordGeneration(generation); @@ -993,6 +1061,7 @@ class EmbeddingRecorderImpl implements EmbeddingRecorder { } class ToolExecutionRecorderImpl implements ToolExecutionRecorder { + private readonly seed: ToolExecutionStart; private readonly startedAt: Date; private readonly span: Span; private ended = false; @@ -1002,10 +1071,23 @@ class ToolExecutionRecorderImpl implements ToolExecutionRecorder { constructor( private readonly client: SigilClient, - private readonly seed: ToolExecutionStart + seed: ToolExecutionStart ) { - this.startedAt = seed.startedAt ?? this.client.internalNow(); - this.span = this.client.internalStartToolExecutionSpan(seed, this.startedAt); + this.seed = cloneToolExecutionStart(seed); + if (!notEmpty(this.seed.conversationId)) { + this.seed.conversationId = conversationIdFromContext(); + } + if (!notEmpty(this.seed.conversationTitle)) { + this.seed.conversationTitle = conversationTitleFromContext(); + } + if (!notEmpty(this.seed.agentName)) { + this.seed.agentName = agentNameFromContext(); + } + if (!notEmpty(this.seed.agentVersion)) { + this.seed.agentVersion = agentVersionFromContext(); + } + this.startedAt = this.seed.startedAt ?? this.client.internalNow(); + this.span = this.client.internalStartToolExecutionSpan(this.seed, this.startedAt); } setResult(result: ToolExecutionResult): void { @@ -1035,8 +1117,11 @@ class ToolExecutionRecorderImpl implements ToolExecutionRecorder { toolType: this.seed.toolType, toolDescription: this.seed.toolDescription, conversationId: this.seed.conversationId, + conversationTitle: this.seed.conversationTitle, agentName: this.seed.agentName, agentVersion: this.seed.agentVersion, + requestModel: this.seed.requestModel, + requestProvider: this.seed.requestProvider, includeContent: this.seed.includeContent ?? false, startedAt: new Date(this.startedAt), completedAt: new Date(this.result?.completedAt ?? this.client.internalNow()), @@ -1122,6 +1207,8 @@ function setGenerationSpanAttributes( generation: { id?: string; conversationId?: string; + conversationTitle?: string; + userId?: string; agentName?: string; agentVersion?: string; operationName: string; @@ -1154,6 +1241,12 @@ function setGenerationSpanAttributes( if (notEmpty(generation.conversationId)) { span.setAttribute(spanAttrConversationID, generation.conversationId); } + if (notEmpty(generation.conversationTitle)) { + span.setAttribute(spanAttrConversationTitle, generation.conversationTitle); + } + if (notEmpty(generation.userId)) { + span.setAttribute(spanAttrUserID, generation.userId); + } if (notEmpty(generation.agentName)) { span.setAttribute(spanAttrAgentName, generation.agentName); } @@ -1309,8 +1402,11 @@ function setToolSpanAttributes( toolType?: string; toolDescription?: string; conversationId?: string; + conversationTitle?: string; agentName?: string; agentVersion?: string; + requestProvider?: string; + requestModel?: string; } ): void { span.setAttribute(spanAttrOperationName, 'execute_tool'); @@ -1329,12 +1425,21 @@ function setToolSpanAttributes( if (notEmpty(tool.conversationId)) { span.setAttribute(spanAttrConversationID, tool.conversationId); } + if (notEmpty(tool.conversationTitle)) { + span.setAttribute(spanAttrConversationTitle, tool.conversationTitle); + } if (notEmpty(tool.agentName)) { span.setAttribute(spanAttrAgentName, tool.agentName); } if (notEmpty(tool.agentVersion)) { span.setAttribute(spanAttrAgentVersion, tool.agentVersion); } + if (notEmpty(tool.requestProvider)) { + span.setAttribute(spanAttrProviderName, tool.requestProvider); + } + if (notEmpty(tool.requestModel)) { + span.setAttribute(spanAttrRequestModel, tool.requestModel); + } } function serializeToolContent(value: unknown): { value?: string; error?: Error } { @@ -1618,6 +1723,41 @@ function metadataIntValue(metadata: Record | undefined, key: st return undefined; } +function firstNonEmptyString(...values: Array): string | undefined { + for (const value of values) { + if (notEmpty(value)) { + return value; + } + } + return undefined; +} + +function mergeStringRecords( + left: Record | undefined, + right: Record | undefined +): Record | undefined { + if (left === undefined && right === undefined) { + return undefined; + } + return { + ...(left ?? {}), + ...(right ?? {}), + }; +} + +function mergeUnknownRecords( + left: Record | undefined, + right: Record | undefined +): Record | undefined { + if (left === undefined && right === undefined) { + return undefined; + } + return { + ...(left ?? {}), + ...(right ?? {}), + }; +} + function countToolCallParts(messages: Message[]): number { let total = 0; for (const message of messages) { diff --git a/js/src/config.ts b/js/src/config.ts index 2ffa342..4484314 100644 --- a/js/src/config.ts +++ b/js/src/config.ts @@ -120,8 +120,10 @@ function resolveHeadersWithAuth( const out = headers ? { ...headers } : undefined; if (mode === 'none') { - if (tenantId.length > 0 || bearerToken.length > 0) { - throw new Error(`${label} auth mode "none" does not allow tenantId or bearerToken`); + const basicUser = auth.basicUser?.trim() ?? ''; + const basicPassword = auth.basicPassword?.trim() ?? ''; + if (tenantId.length > 0 || bearerToken.length > 0 || basicUser.length > 0 || basicPassword.length > 0) { + throw new Error(`${label} auth mode "none" does not allow credentials`); } return out; } @@ -158,6 +160,29 @@ function resolveHeadersWithAuth( }; } + if (mode === 'basic') { + const password = auth.basicPassword?.trim() ?? ''; + if (password.length === 0) { + throw new Error(`${label} auth mode "basic" requires basicPassword`); + } + let user = auth.basicUser?.trim() ?? ''; + if (user.length === 0) { + user = tenantId; + } + if (user.length === 0) { + throw new Error(`${label} auth mode "basic" requires basicUser or tenantId`); + } + const result: Record = { ...(out ?? {}) }; + if (!hasHeaderKey(result, authorizationHeaderName)) { + const encoded = new TextEncoder().encode(`${user}:${password}`); + result[authorizationHeaderName] = 'Basic ' + btoa(String.fromCharCode(...encoded)); + } + if (tenantId.length > 0 && !hasHeaderKey(result, tenantHeaderName)) { + result[tenantHeaderName] = tenantId; + } + return result; + } + throw new Error(`unsupported ${label} auth mode: ${auth.mode}`); } diff --git a/js/src/context.ts b/js/src/context.ts new file mode 100644 index 0000000..4aee505 --- /dev/null +++ b/js/src/context.ts @@ -0,0 +1,75 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +type SigilContextValues = { + conversationId?: string; + conversationTitle?: string; + userId?: string; + agentName?: string; + agentVersion?: string; +}; + +const storage = new AsyncLocalStorage(); + +export function withConversationId(conversationId: string, callback: () => T): T { + return runWithContext({ conversationId }, callback); +} + +export function withConversationTitle(conversationTitle: string, callback: () => T): T { + return runWithContext({ conversationTitle }, callback); +} + +export function withUserId(userId: string, callback: () => T): T { + return runWithContext({ userId }, callback); +} + +export function withAgentName(agentName: string, callback: () => T): T { + return runWithContext({ agentName }, callback); +} + +export function withAgentVersion(agentVersion: string, callback: () => T): T { + return runWithContext({ agentVersion }, callback); +} + +export function conversationIdFromContext(): string | undefined { + return normalizedString(storage.getStore()?.conversationId); +} + +export function conversationTitleFromContext(): string | undefined { + return normalizedString(storage.getStore()?.conversationTitle); +} + +export function userIdFromContext(): string | undefined { + return normalizedString(storage.getStore()?.userId); +} + +export function agentNameFromContext(): string | undefined { + return normalizedString(storage.getStore()?.agentName); +} + +export function agentVersionFromContext(): string | undefined { + return normalizedString(storage.getStore()?.agentVersion); +} + +function runWithContext(nextValues: SigilContextValues, callback: () => T): T { + const currentValues = storage.getStore() ?? {}; + const mergedValues: SigilContextValues = { ...currentValues }; + + for (const [key, value] of Object.entries(nextValues)) { + const normalized = normalizedString(value); + if (normalized === undefined) { + delete mergedValues[key as keyof SigilContextValues]; + continue; + } + mergedValues[key as keyof SigilContextValues] = normalized; + } + + return storage.run(mergedValues, callback); +} + +function normalizedString(value: string | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} diff --git a/js/src/index.ts b/js/src/index.ts index a573479..64f7d7b 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -1,5 +1,17 @@ export { SigilClient } from './client.js'; export { defaultConfig } from './config.js'; +export { + agentNameFromContext, + agentVersionFromContext, + conversationIdFromContext, + conversationTitleFromContext, + userIdFromContext, + withAgentName, + withAgentVersion, + withConversationId, + withConversationTitle, + withUserId, +} from './context.js'; export type { ApiConfig, Artifact, diff --git a/js/src/providers/openai.ts b/js/src/providers/openai.ts index 1ee8580..50a1f23 100644 --- a/js/src/providers/openai.ts +++ b/js/src/providers/openai.ts @@ -503,7 +503,7 @@ function mapChatRequestMessages(request: ChatCreateRequest | ChatStreamRequest): const normalizedRole: Message['role'] = role === 'assistant' || role === 'tool' ? role : 'user'; const message: Message = { role: normalizedRole }; - if (content.length > 0) { + if (normalizedRole !== 'tool' && content.length > 0) { message.content = content; } @@ -511,6 +511,20 @@ function mapChatRequestMessages(request: ChatCreateRequest | ChatStreamRequest): message.name = rawMessage.name; } + if (normalizedRole === 'tool') { + const toolResult = mapToolResultMessage( + rawMessage.content, + rawMessage.tool_call_id ?? rawMessage.toolCallId ?? rawMessage.id, + rawMessage.name, + rawMessage.is_error, + 'tool_result' + ); + if (toolResult) { + input.push(toolResult); + } + continue; + } + if (normalizedRole === 'assistant' && Array.isArray(rawMessage.tool_calls)) { const parts = mapChatToolCallParts(rawMessage.tool_calls); if (parts.length > 0) { @@ -760,10 +774,15 @@ function mapResponsesRequest(request: ResponsesCreateRequest | ResponsesStreamRe } if (itemType === 'function_call_output') { - const outputValue = rawItem.output; - const content = typeof outputValue === 'string' ? outputValue : jsonString(outputValue); - if (content.length > 0) { - input.push({ role: 'tool', content }); + const toolResult = mapToolResultMessage( + rawItem.output, + rawItem.call_id ?? rawItem.callId, + rawItem.name, + rawItem.is_error, + 'tool_result' + ); + if (toolResult) { + input.push(toolResult); } continue; } @@ -899,11 +918,15 @@ function mapResponsesOutputItems(value: unknown): Message[] { } if (itemType === 'function_call_output') { - const content = typeof rawItem.output === 'string' - ? rawItem.output - : jsonString(rawItem.output); - if (content.length > 0) { - output.push({ role: 'tool', content }); + const toolResult = mapToolResultMessage( + rawItem.output, + rawItem.call_id ?? rawItem.callId, + rawItem.name, + rawItem.is_error, + 'tool_result' + ); + if (toolResult) { + output.push(toolResult); } continue; } @@ -1165,6 +1188,40 @@ function openAIThinkingBudget(reasoning: unknown): number | undefined { return undefined; } +function mapToolResultMessage( + value: unknown, + toolCallId: unknown, + name: unknown, + isError: unknown, + providerType: string +): Message | undefined { + const content = extractText(value); + const contentJSON = jsonString(value); + const renderedContent = content.length > 0 ? content : contentJSON; + + if (renderedContent.length === 0) { + return undefined; + } + + return { + role: 'tool', + content: renderedContent, + parts: [ + { + type: 'tool_result', + toolResult: { + toolCallId: typeof toolCallId === 'string' && toolCallId.trim().length > 0 ? toolCallId : undefined, + name: typeof name === 'string' && name.trim().length > 0 ? name : undefined, + content: renderedContent, + contentJSON, + isError: typeof isError === 'boolean' ? isError : undefined, + }, + metadata: { providerType }, + }, + ], + }; +} + function metadataWithThinkingBudget( metadata: Record | undefined, thinkingBudget: number | undefined diff --git a/js/src/types.ts b/js/src/types.ts index 35582f0..0e3210a 100644 --- a/js/src/types.ts +++ b/js/src/types.ts @@ -5,13 +5,17 @@ export type GenerationExportProtocol = 'grpc' | 'http' | 'none'; /** Generation execution mode. */ export type GenerationMode = 'SYNC' | 'STREAM'; /** Supported auth modes for transport exports. */ -export type ExportAuthMode = 'none' | 'tenant' | 'bearer'; +export type ExportAuthMode = 'none' | 'tenant' | 'bearer' | 'basic'; /** Per-export auth configuration. */ export interface ExportAuthConfig { mode: ExportAuthMode; tenantId?: string; bearerToken?: string; + /** Username for basic auth. When empty, tenantId is used. */ + basicUser?: string; + /** Password/token for basic auth. */ + basicPassword?: string; } /** Generation exporter runtime configuration. */ @@ -238,6 +242,8 @@ export interface Artifact { export interface GenerationStart { id?: string; conversationId?: string; + conversationTitle?: string; + userId?: string; agentName?: string; agentVersion?: string; mode?: GenerationMode; @@ -258,6 +264,8 @@ export interface GenerationStart { /** Final generation result fields. */ export interface GenerationResult { conversationId?: string; + conversationTitle?: string; + userId?: string; agentName?: string; agentVersion?: string; operationName?: string; @@ -304,6 +312,8 @@ export interface EmbeddingResult { export interface Generation { id: string; conversationId?: string; + conversationTitle?: string; + userId?: string; agentName?: string; agentVersion?: string; mode: GenerationMode; @@ -339,8 +349,13 @@ export interface ToolExecutionStart { toolType?: string; toolDescription?: string; conversationId?: string; + conversationTitle?: string; agentName?: string; agentVersion?: string; + /** The model that requested the tool call (e.g. "gpt-5"). */ + requestModel?: string; + /** The provider that served the model (e.g. "openai"). */ + requestProvider?: string; includeContent?: boolean; startedAt?: Date; } @@ -359,8 +374,11 @@ export interface ToolExecution { toolType?: string; toolDescription?: string; conversationId?: string; + conversationTitle?: string; agentName?: string; agentVersion?: string; + requestModel?: string; + requestProvider?: string; includeContent: boolean; startedAt: Date; completedAt: Date; diff --git a/js/src/utils.ts b/js/src/utils.ts index 44d28e8..91f15b0 100644 --- a/js/src/utils.ts +++ b/js/src/utils.ts @@ -5,11 +5,13 @@ import type { Generation, GenerationMode, GenerationResult, + GenerationStart, Message, MessagePart, ModelRef, ToolDefinition, ToolExecution, + ToolExecutionStart, ToolExecutionResult, } from './types.js'; @@ -159,6 +161,17 @@ export function cloneGenerationResult(result: GenerationResult): GenerationResul }; } +export function cloneGenerationStart(start: GenerationStart): GenerationStart { + return { + ...start, + model: cloneModelRef(start.model), + tools: start.tools?.map(cloneToolDefinition), + tags: start.tags ? { ...start.tags } : undefined, + metadata: start.metadata ? { ...start.metadata } : undefined, + startedAt: start.startedAt ? new Date(start.startedAt) : undefined, + }; +} + export function cloneEmbeddingStart(start: EmbeddingStart): EmbeddingStart { return { ...start, @@ -184,6 +197,13 @@ export function cloneToolExecution(toolExecution: ToolExecution): ToolExecution }; } +export function cloneToolExecutionStart(start: ToolExecutionStart): ToolExecutionStart { + return { + ...start, + startedAt: start.startedAt ? new Date(start.startedAt) : undefined, + }; +} + export function cloneToolExecutionResult(result: ToolExecutionResult): ToolExecutionResult { return { ...result, diff --git a/js/test/client.auth.config.test.mjs b/js/test/client.auth.config.test.mjs index c0984d3..852c01a 100644 --- a/js/test/client.auth.config.test.mjs +++ b/js/test/client.auth.config.test.mjs @@ -15,3 +15,135 @@ test('invalid generation auth config throws at client init', () => { /requires tenantId/ ); }); + +test('basic auth mode injects Authorization and X-Scope-OrgID headers', () => { + const client = new SigilClient({ + generationExport: { + auth: { + mode: 'basic', + tenantId: '42', + basicPassword: 'secret', + }, + }, + }); + + const expected = 'Basic ' + btoa('42:secret'); + assert.equal(client.config.generationExport.headers?.['Authorization'], expected); + assert.equal(client.config.generationExport.headers?.['X-Scope-OrgID'], '42'); + client.shutdown(); +}); + +test('basic auth mode uses basicUser over tenantId for credential', () => { + const client = new SigilClient({ + generationExport: { + auth: { + mode: 'basic', + tenantId: '42', + basicUser: 'probe-user', + basicPassword: 'secret', + }, + }, + }); + + const expected = 'Basic ' + btoa('probe-user:secret'); + assert.equal(client.config.generationExport.headers?.['Authorization'], expected); + assert.equal(client.config.generationExport.headers?.['X-Scope-OrgID'], '42'); + client.shutdown(); +}); + +test('basic auth mode requires basicPassword', () => { + assert.throws( + () => + new SigilClient({ + generationExport: { + auth: { + mode: 'basic', + tenantId: '42', + }, + }, + }), + /requires basicPassword/ + ); +}); + +test('basic auth mode requires basicUser or tenantId', () => { + assert.throws( + () => + new SigilClient({ + generationExport: { + auth: { + mode: 'basic', + basicPassword: 'secret', + }, + }, + }), + /requires basicUser or tenantId/ + ); +}); + +test('none auth mode rejects basicUser', () => { + assert.throws( + () => + new SigilClient({ + generationExport: { + auth: { + mode: 'none', + basicUser: 'user', + }, + }, + }), + /does not allow credentials/ + ); +}); + +test('none auth mode rejects basicPassword', () => { + assert.throws( + () => + new SigilClient({ + generationExport: { + auth: { + mode: 'none', + basicPassword: 'secret', + }, + }, + }), + /does not allow credentials/ + ); +}); + +test('basic auth handles non-ASCII credentials via UTF-8 encoding', () => { + const client = new SigilClient({ + generationExport: { + auth: { + mode: 'basic', + basicUser: 'ユーザー', + basicPassword: 'パスワード', + }, + }, + }); + + const encoded = Buffer.from('ユーザー:パスワード', 'utf-8').toString('base64'); + const expected = 'Basic ' + encoded; + assert.equal(client.config.generationExport.headers?.['Authorization'], expected); + client.shutdown(); +}); + +test('basic auth explicit headers win over auth-derived headers', () => { + const client = new SigilClient({ + generationExport: { + headers: { + Authorization: 'Basic override', + 'X-Scope-OrgID': 'override-tenant', + }, + auth: { + mode: 'basic', + tenantId: '42', + basicPassword: 'secret', + }, + }, + }); + + assert.equal(client.config.generationExport.headers?.['Authorization'], 'Basic override'); + assert.equal(client.config.generationExport.headers?.['X-Scope-OrgID'], 'override-tenant'); + client.shutdown(); +}); diff --git a/js/test/client.spans.test.mjs b/js/test/client.spans.test.mjs index 264041e..60987fb 100644 --- a/js/test/client.spans.test.mjs +++ b/js/test/client.spans.test.mjs @@ -105,6 +105,77 @@ test('generation result fields override seed and update span operation name', as } }); +test('generation normalization trims only title and user fields', async () => { + const harness = newHarness(); + + try { + const recorder = harness.client.startGeneration({ + conversationId: ' conv-seed ', + conversationTitle: ' title-seed ', + userId: ' user-seed ', + agentName: ' agent-seed ', + agentVersion: ' v-seed ', + model: { provider: 'openai', name: 'gpt-5' }, + }); + recorder.setResult({ + conversationId: ' conv-result ', + conversationTitle: ' title-result ', + userId: ' user-result ', + agentName: ' agent-result ', + agentVersion: ' v-result ', + }); + recorder.end(); + assert.equal(recorder.getError(), undefined); + + const generation = singleGeneration(harness.client); + assert.equal(generation.conversationId, ' conv-result '); + assert.equal(generation.conversationTitle, 'title-result'); + assert.equal(generation.userId, 'user-result'); + assert.equal(generation.agentName, ' agent-result '); + assert.equal(generation.agentVersion, ' v-result '); + assert.equal(generation.metadata?.['sigil.conversation.title'], 'title-result'); + assert.equal(generation.metadata?.['sigil.user.id'], 'user-result'); + + const span = singleGenerationSpan(harness.spanExporter); + assert.equal(span.attributes['gen_ai.conversation.id'], ' conv-result '); + assert.equal(span.attributes['sigil.conversation.title'], 'title-result'); + assert.equal(span.attributes['user.id'], 'user-result'); + assert.equal(span.attributes['gen_ai.agent.name'], ' agent-result '); + assert.equal(span.attributes['gen_ai.agent.version'], ' v-result '); + } finally { + await shutdownHarness(harness); + } +}); + +test('generation span reflects metadata fallback title and user id after normalization', async () => { + const harness = newHarness(); + + try { + const recorder = harness.client.startGeneration({ + model: { provider: 'openai', name: 'gpt-5' }, + metadata: { + 'sigil.conversation.title': ' Meta title ', + 'user.id': ' legacy-user ', + }, + }); + recorder.setResult({}); + recorder.end(); + assert.equal(recorder.getError(), undefined); + + const generation = singleGeneration(harness.client); + assert.equal(generation.conversationTitle, 'Meta title'); + assert.equal(generation.userId, 'legacy-user'); + assert.equal(generation.metadata?.['sigil.conversation.title'], 'Meta title'); + assert.equal(generation.metadata?.['sigil.user.id'], 'legacy-user'); + + const span = singleGenerationSpan(harness.spanExporter); + assert.equal(span.attributes['sigil.conversation.title'], 'Meta title'); + assert.equal(span.attributes['user.id'], 'legacy-user'); + } finally { + await shutdownHarness(harness); + } +}); + test('generation callError sets metadata and provider_call_error span status', async () => { const harness = newHarness(); @@ -249,6 +320,8 @@ test('tool execution includeContent controls argument/result attributes', async conversationId: 'conv-tool', agentName: 'agent-tool', agentVersion: 'v-tool', + requestProvider: 'openai', + requestModel: 'gpt-5', }); withContent.setResult({ arguments: { city: 'Paris' }, @@ -280,6 +353,8 @@ test('tool execution includeContent controls argument/result attributes', async assert.equal(contentSpan.attributes['gen_ai.conversation.id'], 'conv-tool'); assert.equal(contentSpan.attributes['gen_ai.agent.name'], 'agent-tool'); assert.equal(contentSpan.attributes['gen_ai.agent.version'], 'v-tool'); + assert.equal(contentSpan.attributes['gen_ai.provider.name'], 'openai'); + assert.equal(contentSpan.attributes['gen_ai.request.model'], 'gpt-5'); assert.equal(contentSpan.attributes['sigil.sdk.name'], 'sdk-js'); assert.equal(noContentSpan.attributes['gen_ai.tool.call.arguments'], undefined); assert.equal(noContentSpan.attributes['gen_ai.tool.call.result'], undefined); diff --git a/js/test/conformance.test.mjs b/js/test/conformance.test.mjs new file mode 100644 index 0000000..cdd33cf --- /dev/null +++ b/js/test/conformance.test.mjs @@ -0,0 +1,716 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import * as grpc from '@grpc/grpc-js'; +import * as protoLoader from '@grpc/proto-loader'; +import { AggregationTemporality, InMemoryMetricExporter, MeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; +import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { + SigilClient, + defaultConfig, + withAgentName, + withAgentVersion, + withConversationTitle, + withUserId, +} from '../.test-dist/index.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const protoPath = join(__dirname, '../proto/sigil/v1/generation_ingest.proto'); +const protoLoadOptions = { + keepCase: false, + longs: String, + enums: String, + defaults: false, + oneofs: true, +}; + +test('conformance sync roundtrip semantics', async () => { + const env = await createConformanceEnv(); + + try { + const recorder = env.client.startGeneration({ + id: 'gen-roundtrip', + conversationId: 'conv-roundtrip', + conversationTitle: 'Roundtrip conversation', + userId: 'user-roundtrip', + agentName: 'agent-roundtrip', + agentVersion: 'v-roundtrip', + model: { provider: 'openai', name: 'gpt-5' }, + maxTokens: 256, + temperature: 0.2, + topP: 0.9, + toolChoice: 'required', + thinkingEnabled: false, + tools: [{ name: 'weather', description: 'Get weather', type: 'function' }], + tags: { tenant: 'dev' }, + metadata: { trace: 'roundtrip' }, + }); + recorder.setResult({ + responseId: 'resp-roundtrip', + responseModel: 'gpt-5-2026', + input: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }], + output: [ + { + role: 'assistant', + parts: [ + { type: 'thinking', thinking: 'reasoning' }, + { + type: 'tool_call', + toolCall: { + id: 'call-1', + name: 'weather', + inputJSON: '{"city":"Paris"}', + }, + }, + ], + }, + { + role: 'tool', + parts: [ + { + type: 'tool_result', + toolResult: { + toolCallId: 'call-1', + name: 'weather', + content: 'sunny', + contentJSON: '{"temp_c":18}', + }, + }, + ], + }, + ], + usage: { + inputTokens: 12, + outputTokens: 7, + totalTokens: 19, + cacheReadInputTokens: 2, + cacheWriteInputTokens: 1, + cacheCreationInputTokens: 3, + reasoningTokens: 4, + }, + stopReason: 'stop', + tags: { region: 'eu' }, + metadata: { result: 'ok' }, + artifacts: [ + { type: 'request', name: 'request', mimeType: 'application/json', payload: '{"prompt":"hello"}' }, + { type: 'response', name: 'response', mimeType: 'application/json', payload: '{"text":"sunny"}' }, + ], + }); + recorder.end(); + assert.equal(recorder.getError(), undefined); + + await env.client.shutdown(); + const generation = env.singleGeneration(); + const span = env.latestGenerationSpan(); + const metricNames = await env.metricNames(); + + assert.equal(generation.mode, 'GENERATION_MODE_SYNC'); + assert.equal(generation.operationName, 'generateText'); + assert.equal(generation.conversationId, 'conv-roundtrip'); + assert.equal(generation.agentName, 'agent-roundtrip'); + assert.equal(generation.agentVersion, 'v-roundtrip'); + assert.equal(generation.traceId, span.spanContext().traceId); + assert.equal(generation.spanId, span.spanContext().spanId); + assert.equal(generation.metadata?.fields?.['sigil.conversation.title']?.stringValue, 'Roundtrip conversation'); + assert.equal(generation.metadata?.fields?.['sigil.user.id']?.stringValue, 'user-roundtrip'); + assert.equal(generation.input?.[0]?.parts?.[0]?.text, 'hello'); + assert.equal(generation.output?.[0]?.parts?.[0]?.thinking, 'reasoning'); + assert.equal(generation.output?.[0]?.parts?.[1]?.toolCall?.name, 'weather'); + assert.equal(generation.output?.[1]?.parts?.[0]?.toolResult?.content, 'sunny'); + assert.equal(Number(generation.maxTokens), 256); + assert.equal(generation.temperature, 0.2); + assert.equal(generation.topP, 0.9); + assert.equal(generation.toolChoice, 'required'); + assert.equal(generation.thinkingEnabled, false); + assert.equal(Number(generation.usage?.inputTokens ?? 0), 12); + assert.equal(Number(generation.usage?.outputTokens ?? 0), 7); + assert.equal(Number(generation.usage?.totalTokens ?? 0), 19); + assert.equal(Number(generation.usage?.cacheReadInputTokens ?? 0), 2); + assert.equal(Number(generation.usage?.cacheWriteInputTokens ?? 0), 1); + assert.equal(Number(generation.usage?.reasoningTokens ?? 0), 4); + assert.equal(generation.stopReason, 'stop'); + assert.equal(generation.tags?.tenant, 'dev'); + assert.equal(generation.tags?.region, 'eu'); + assert.equal((generation.rawArtifacts ?? []).length, 2); + assert.equal(span.attributes['gen_ai.operation.name'], 'generateText'); + assert.equal(span.attributes['sigil.conversation.title'], 'Roundtrip conversation'); + assert.equal(span.attributes['user.id'], 'user-roundtrip'); + assert.ok(metricNames.includes('gen_ai.client.operation.duration')); + assert.ok(metricNames.includes('gen_ai.client.token.usage')); + assert.ok(!metricNames.includes('gen_ai.client.time_to_first_token')); + } finally { + await env.close(); + } +}); + +for (const testCase of [ + { name: 'explicit wins', startTitle: 'Explicit', contextTitle: 'Context', metadataTitle: 'Meta', expected: 'Explicit' }, + { name: 'context fallback', startTitle: '', contextTitle: 'Context', metadataTitle: '', expected: 'Context' }, + { name: 'metadata fallback', startTitle: '', contextTitle: '', metadataTitle: 'Meta', expected: 'Meta' }, + { name: 'whitespace trimmed', startTitle: ' Padded ', contextTitle: '', metadataTitle: '', expected: 'Padded' }, + { name: 'whitespace omitted', startTitle: ' ', contextTitle: '', metadataTitle: '', expected: '' }, +]) { + test(`conformance conversation title semantics: ${testCase.name}`, async () => { + const env = await createConformanceEnv(); + + try { + await runWithMaybeContext(testCase.contextTitle, withConversationTitle, async () => { + const recorder = env.client.startGeneration({ + model: { provider: 'openai', name: 'gpt-5' }, + conversationTitle: testCase.startTitle, + metadata: testCase.metadataTitle.length > 0 ? { 'sigil.conversation.title': testCase.metadataTitle } : undefined, + }); + recorder.setResult({}); + recorder.end(); + assert.equal(recorder.getError(), undefined); + }); + + await env.client.shutdown(); + const generation = env.singleGeneration(); + const span = env.latestGenerationSpan(); + if (testCase.expected.length === 0) { + assert.equal(generation.metadata?.fields?.['sigil.conversation.title'], undefined); + assert.equal(span.attributes['sigil.conversation.title'], undefined); + return; + } + + assert.equal(generation.metadata?.fields?.['sigil.conversation.title']?.stringValue, testCase.expected); + assert.equal(span.attributes['sigil.conversation.title'], testCase.expected); + } finally { + await env.close(); + } + }); +} + +for (const testCase of [ + { name: 'explicit wins', startUserId: 'explicit', contextUserId: 'ctx', canonicalUserId: 'canonical', legacyUserId: 'legacy', expected: 'explicit' }, + { name: 'context fallback', startUserId: '', contextUserId: 'ctx', canonicalUserId: '', legacyUserId: '', expected: 'ctx' }, + { name: 'canonical metadata', startUserId: '', contextUserId: '', canonicalUserId: 'canonical', legacyUserId: '', expected: 'canonical' }, + { name: 'legacy metadata', startUserId: '', contextUserId: '', canonicalUserId: '', legacyUserId: 'legacy', expected: 'legacy' }, + { name: 'canonical beats legacy', startUserId: '', contextUserId: '', canonicalUserId: 'canonical', legacyUserId: 'legacy', expected: 'canonical' }, + { name: 'whitespace trimmed', startUserId: ' padded ', contextUserId: '', canonicalUserId: '', legacyUserId: '', expected: 'padded' }, +]) { + test(`conformance user id semantics: ${testCase.name}`, async () => { + const env = await createConformanceEnv(); + + try { + await runWithMaybeContext(testCase.contextUserId, withUserId, async () => { + const metadata = {}; + if (testCase.canonicalUserId.length > 0) { + metadata['sigil.user.id'] = testCase.canonicalUserId; + } + if (testCase.legacyUserId.length > 0) { + metadata['user.id'] = testCase.legacyUserId; + } + + const recorder = env.client.startGeneration({ + model: { provider: 'openai', name: 'gpt-5' }, + userId: testCase.startUserId, + metadata, + }); + recorder.setResult({}); + recorder.end(); + assert.equal(recorder.getError(), undefined); + }); + + await env.client.shutdown(); + const generation = env.singleGeneration(); + const span = env.latestGenerationSpan(); + assert.equal(generation.metadata?.fields?.['sigil.user.id']?.stringValue, testCase.expected); + assert.equal(span.attributes['user.id'], testCase.expected); + } finally { + await env.close(); + } + }); +} + +for (const testCase of [ + { + name: 'explicit fields', + startName: 'agent-explicit', + startVersion: 'v1.2.3', + contextName: '', + contextVersion: '', + resultName: '', + resultVersion: '', + expectedName: 'agent-explicit', + expectedVersion: 'v1.2.3', + }, + { + name: 'context fallback', + startName: '', + startVersion: '', + contextName: 'agent-context', + contextVersion: 'v-context', + resultName: '', + resultVersion: '', + expectedName: 'agent-context', + expectedVersion: 'v-context', + }, + { + name: 'result override', + startName: 'agent-seed', + startVersion: 'v-seed', + contextName: '', + contextVersion: '', + resultName: 'agent-result', + resultVersion: 'v-result', + expectedName: 'agent-result', + expectedVersion: 'v-result', + }, + { + name: 'empty omission', + startName: '', + startVersion: '', + contextName: '', + contextVersion: '', + resultName: '', + resultVersion: '', + expectedName: '', + expectedVersion: '', + }, +]) { + test(`conformance agent identity semantics: ${testCase.name}`, async () => { + const env = await createConformanceEnv(); + + try { + await runWithMaybeContext(testCase.contextName, withAgentName, async () => { + await runWithMaybeContext(testCase.contextVersion, withAgentVersion, async () => { + const recorder = env.client.startGeneration({ + model: { provider: 'openai', name: 'gpt-5' }, + agentName: testCase.startName, + agentVersion: testCase.startVersion, + }); + recorder.setResult({ + agentName: testCase.resultName, + agentVersion: testCase.resultVersion, + }); + recorder.end(); + assert.equal(recorder.getError(), undefined); + }); + }); + + await env.client.shutdown(); + const generation = env.singleGeneration(); + const span = env.latestGenerationSpan(); + assert.equal(generation.agentName ?? '', testCase.expectedName); + assert.equal(generation.agentVersion ?? '', testCase.expectedVersion); + assert.equal(span.attributes['gen_ai.agent.name'], testCase.expectedName || undefined); + assert.equal(span.attributes['gen_ai.agent.version'], testCase.expectedVersion || undefined); + } finally { + await env.close(); + } + }); +} + +test('conformance streaming telemetry semantics', async () => { + const env = await createConformanceEnv(); + + try { + const startedAt = new Date('2026-03-12T09:00:00Z'); + const recorder = env.client.startStreamingGeneration({ + model: { provider: 'openai', name: 'gpt-5' }, + startedAt, + }); + recorder.setFirstTokenAt(new Date('2026-03-12T09:00:00.250Z')); + recorder.setResult({ + output: [{ role: 'assistant', parts: [{ type: 'text', text: 'Hello world' }] }], + usage: { inputTokens: 4, outputTokens: 3, totalTokens: 7 }, + startedAt, + completedAt: new Date('2026-03-12T09:00:01Z'), + }); + recorder.end(); + assert.equal(recorder.getError(), undefined); + + await env.client.shutdown(); + const generation = env.singleGeneration(); + const span = env.latestGenerationSpan(); + const metricNames = await env.metricNames(); + + assert.equal(generation.mode, 'GENERATION_MODE_STREAM'); + assert.equal(generation.operationName, 'streamText'); + assert.equal(generation.output?.[0]?.parts?.[0]?.text, 'Hello world'); + assert.equal(span.name, 'streamText gpt-5'); + assert.ok(metricNames.includes('gen_ai.client.operation.duration')); + assert.ok(metricNames.includes('gen_ai.client.time_to_first_token')); + } finally { + await env.close(); + } +}); + +test('conformance tool execution semantics', async () => { + const env = await createConformanceEnv(); + + try { + await runWithMaybeContext('Context title', withConversationTitle, async () => { + await runWithMaybeContext('agent-context', withAgentName, async () => { + await runWithMaybeContext('v-context', withAgentVersion, async () => { + const recorder = env.client.startToolExecution({ + toolName: 'weather', + toolCallId: 'call-weather-1', + toolType: 'function', + includeContent: true, + }); + recorder.setResult({ + arguments: { city: 'Paris' }, + result: { forecast: 'sunny' }, + }); + recorder.end(); + assert.equal(recorder.getError(), undefined); + }); + }); + }); + + await env.client.shutdown(); + const span = env.latestSpanByOperation('execute_tool'); + const metricNames = await env.metricNames(); + + assert.equal(env.receivedRequests.length, 0); + assert.equal(span.name, 'execute_tool weather'); + assert.equal(span.attributes['gen_ai.operation.name'], 'execute_tool'); + assert.equal(span.attributes['gen_ai.tool.name'], 'weather'); + assert.equal(span.attributes['gen_ai.tool.call.id'], 'call-weather-1'); + assert.equal(span.attributes['gen_ai.tool.type'], 'function'); + assert.match(String(span.attributes['gen_ai.tool.call.arguments'] ?? ''), /Paris/); + assert.match(String(span.attributes['gen_ai.tool.call.result'] ?? ''), /sunny/); + assert.equal(span.attributes['sigil.conversation.title'], 'Context title'); + assert.equal(span.attributes['gen_ai.agent.name'], 'agent-context'); + assert.equal(span.attributes['gen_ai.agent.version'], 'v-context'); + assert.ok(metricNames.includes('gen_ai.client.operation.duration')); + assert.ok(!metricNames.includes('gen_ai.client.time_to_first_token')); + } finally { + await env.close(); + } +}); + +test('conformance embedding semantics', async () => { + const env = await createConformanceEnv(); + + try { + await runWithMaybeContext('agent-context', withAgentName, async () => { + await runWithMaybeContext('v-context', withAgentVersion, async () => { + const recorder = env.client.startEmbedding({ + model: { provider: 'openai', name: 'text-embedding-3-small' }, + dimensions: 512, + }); + recorder.setResult({ + inputCount: 2, + inputTokens: 8, + inputTexts: ['hello', 'world'], + responseModel: 'text-embedding-3-small', + dimensions: 512, + }); + recorder.end(); + assert.equal(recorder.getError(), undefined); + }); + }); + + await env.client.shutdown(); + const span = env.latestSpanByOperation('embeddings'); + const metricNames = await env.metricNames(); + + assert.equal(env.receivedRequests.length, 0); + assert.equal(span.name, 'embeddings text-embedding-3-small'); + assert.equal(span.attributes['gen_ai.operation.name'], 'embeddings'); + assert.equal(span.attributes['gen_ai.agent.name'], 'agent-context'); + assert.equal(span.attributes['gen_ai.agent.version'], 'v-context'); + assert.equal(span.attributes['gen_ai.embeddings.input_count'], 2); + assert.equal(span.attributes['gen_ai.embeddings.dimension.count'], 512); + assert.equal(span.attributes['gen_ai.response.model'], 'text-embedding-3-small'); + assert.ok(metricNames.includes('gen_ai.client.operation.duration')); + assert.ok(metricNames.includes('gen_ai.client.token.usage')); + assert.ok(!metricNames.includes('gen_ai.client.time_to_first_token')); + assert.ok(!metricNames.includes('gen_ai.client.tool_calls_per_operation')); + } finally { + await env.close(); + } +}); + +test('conformance validation and provider call error semantics', async () => { + const env = await createConformanceEnv(); + + try { + const invalid = env.client.startGeneration({ + model: { provider: 'anthropic', name: 'claude-sonnet-4-5' }, + }); + invalid.setResult({ + input: [ + { + role: 'user', + parts: [{ type: 'tool_call', toolCall: { name: 'weather' } }], + }, + ], + }); + invalid.end(); + + assert.match(invalid.getError()?.message ?? '', /tool_call only allowed for assistant role/); + assert.equal(env.receivedRequests.length, 0); + assert.equal(env.latestGenerationSpan().attributes['error.type'], 'validation_error'); + + const callError = env.client.startGeneration({ + model: { provider: 'openai', name: 'gpt-5' }, + }); + callError.setCallError(new Error('provider unavailable')); + callError.setResult({}); + callError.end(); + assert.equal(callError.getError(), undefined); + + await env.client.shutdown(); + const generation = env.singleGeneration(); + const span = env.latestGenerationSpan(); + assert.equal(generation.callError, 'provider unavailable'); + assert.equal(generation.metadata?.fields?.call_error?.stringValue, 'provider unavailable'); + assert.equal(span.attributes['error.type'], 'provider_call_error'); + } finally { + await env.close(); + } +}); + +test('conformance rating submission semantics', async () => { + const env = await createConformanceEnv(); + + try { + const response = await env.client.submitConversationRating('conv-rating', { + ratingId: 'rat-1', + rating: 'CONVERSATION_RATING_VALUE_BAD', + comment: 'wrong answer', + metadata: { channel: 'assistant' }, + }); + + assert.equal(env.ratingPath, '/api/v1/conversations/conv-rating/ratings'); + assert.deepEqual(env.ratingPayload, { + rating_id: 'rat-1', + rating: 'CONVERSATION_RATING_VALUE_BAD', + comment: 'wrong answer', + metadata: { channel: 'assistant' }, + }); + assert.equal(response.rating.conversationId, 'conv-rating'); + assert.equal(response.summary.badCount, 1); + } finally { + await env.close(); + } +}); + +test('conformance shutdown flush semantics', async () => { + const env = await createConformanceEnv({ batchSize: 10 }); + + try { + const recorder = env.client.startGeneration({ + conversationId: 'conv-shutdown', + agentName: 'agent-shutdown', + agentVersion: 'v-shutdown', + model: { provider: 'openai', name: 'gpt-5' }, + }); + recorder.setResult({}); + recorder.end(); + assert.equal(recorder.getError(), undefined); + assert.equal(env.receivedRequests.length, 0); + + await env.client.shutdown(); + const generation = env.singleGeneration(); + assert.equal(generation.conversationId, 'conv-shutdown'); + assert.equal(generation.agentName, 'agent-shutdown'); + assert.equal(generation.agentVersion, 'v-shutdown'); + } finally { + await env.close(); + } +}); + +async function createConformanceEnv(options = {}) { + const receivedRequests = []; + const grpcServer = await startGRPCServer((request) => { + receivedRequests.push(request); + }); + + let ratingPath = ''; + let ratingPayload = undefined; + const ratingServer = createServer(async (request, response) => { + ratingPath = request.url ?? ''; + const chunks = []; + for await (const chunk of request) { + chunks.push(chunk); + } + ratingPayload = JSON.parse(Buffer.concat(chunks).toString('utf8')); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + rating: { + rating_id: 'rat-1', + conversation_id: 'conv-rating', + rating: 'CONVERSATION_RATING_VALUE_BAD', + created_at: '2026-03-12T09:00:00Z', + }, + summary: { + total_count: 1, + good_count: 0, + bad_count: 1, + latest_rating: 'CONVERSATION_RATING_VALUE_BAD', + latest_rated_at: '2026-03-12T09:00:00Z', + has_bad_rating: true, + }, + }) + ); + }); + await listen(ratingServer); + const ratingAddress = ratingServer.address(); + if (ratingAddress === null || typeof ratingAddress === 'string') { + throw new Error('failed to resolve rating server address'); + } + + const spanExporter = new InMemorySpanExporter(); + const tracerProvider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(spanExporter)], + }); + const metricExporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE); + const metricReader = new PeriodicExportingMetricReader({ + exporter: metricExporter, + exportIntervalMillis: 60_000, + }); + const meterProvider = new MeterProvider({ + readers: [metricReader], + }); + + const defaults = defaultConfig(); + const client = new SigilClient({ + tracer: tracerProvider.getTracer('sigil-conformance-test'), + meter: meterProvider.getMeter('sigil-conformance-test'), + generationExport: { + ...defaults.generationExport, + protocol: 'grpc', + endpoint: `127.0.0.1:${grpcServer.port}`, + insecure: true, + batchSize: options.batchSize ?? 1, + queueSize: 10, + flushIntervalMs: 60 * 60 * 1_000, + maxRetries: 1, + initialBackoffMs: 1, + maxBackoffMs: 2, + }, + api: { + endpoint: `http://127.0.0.1:${ratingAddress.port}`, + }, + }); + + let closed = false; + return { + client, + receivedRequests, + get ratingPath() { + return ratingPath; + }, + get ratingPayload() { + return ratingPayload; + }, + singleGeneration() { + assert.equal(receivedRequests.length, 1); + assert.equal(receivedRequests[0].generations?.length, 1); + return receivedRequests[0].generations[0]; + }, + latestGenerationSpan() { + const spans = spanExporter.getFinishedSpans().filter((span) => { + const operation = span.attributes['gen_ai.operation.name']; + return operation === 'generateText' || operation === 'streamText'; + }); + assert.ok(spans.length > 0); + return spans.at(-1); + }, + latestSpanByOperation(operationName) { + const spans = spanExporter + .getFinishedSpans() + .filter((span) => span.attributes['gen_ai.operation.name'] === operationName); + assert.ok(spans.length > 0); + return spans.at(-1); + }, + async metricNames() { + await meterProvider.forceFlush(); + return metricExporter + .getMetrics() + .flatMap((resourceMetrics) => resourceMetrics.scopeMetrics) + .flatMap((scopeMetrics) => scopeMetrics.metrics) + .map((metric) => metric.descriptor.name); + }, + async close() { + if (closed) { + return; + } + closed = true; + await client.shutdown(); + await meterProvider.shutdown(); + await tracerProvider.shutdown(); + await close(ratingServer); + await stopGRPCServer(grpcServer.server); + }, + }; +} + +async function runWithMaybeContext(value, wrapper, callback) { + if (typeof value === 'string' && value.trim().length > 0) { + return await wrapper(value, callback); + } + return await callback(); +} + +function listen(server) { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); +} + +function close(server) { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +async function startGRPCServer(onRequest) { + const packageDefinition = await protoLoader.load(protoPath, protoLoadOptions); + const loaded = grpc.loadPackageDefinition(packageDefinition); + const service = loaded.sigil.v1.GenerationIngestService; + + const server = new grpc.Server(); + server.addService(service.service, { + ExportGenerations(call, callback) { + onRequest(call.request, call.metadata.getMap()); + callback(null, { + results: (call.request.generations ?? []).map((generation) => ({ + generationId: generation.id, + accepted: true, + })), + }); + }, + }); + + const port = await new Promise((resolve, reject) => { + server.bindAsync('127.0.0.1:0', grpc.ServerCredentials.createInsecure(), (error, boundPort) => { + if (error) { + reject(error); + return; + } + resolve(boundPort); + }); + }); + + server.start(); + return { server, port }; +} + +function stopGRPCServer(server) { + return new Promise((resolve) => { + server.tryShutdown(() => { + resolve(); + }); + }); +} diff --git a/js/test/frameworks.additional.test.mjs b/js/test/frameworks.additional.test.mjs index e84bccd..8c81fd2 100644 --- a/js/test/frameworks.additional.test.mjs +++ b/js/test/frameworks.additional.test.mjs @@ -1,5 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { context, trace } from '@opentelemetry/api'; +import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; import { defaultConfig, SigilClient } from '../.test-dist/index.js'; import { @@ -254,6 +256,87 @@ for (const framework of frameworks) { assert.deepEqual(generation.metadata.first, { nested: { ok: true } }); assert.deepEqual(generation.metadata.second, { nested: { ok: true } }); }); + + test(`${framework.name} generation span tracks active parent span and preserves export lineage`, async () => { + const spanExporter = new InMemorySpanExporter(); + const tracerProvider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(spanExporter)], + }); + const baseTracer = tracerProvider.getTracer('sigil-framework-test'); + let parentContext; + const tracer = { + startSpan(name, options, contextArg) { + return baseTracer.startSpan(name, options, contextArg ?? parentContext); + }, + startActiveSpan(...args) { + return baseTracer.startActiveSpan(...args); + }, + }; + const defaults = defaultConfig(); + const exporter = new CapturingExporter(); + const client = new SigilClient({ + generationExport: { + ...defaults.generationExport, + batchSize: 10, + flushIntervalMs: 60_000, + }, + generationExporter: exporter, + tracer, + }); + + try { + const handler = new framework.handlerCtor(client); + const parentSpan = baseTracer.startSpan('framework.request'); + parentContext = trace.setSpan(context.active(), parentSpan); + await handler.handleChatModelStart( + { name: 'ChatModel' }, + [[{ type: 'human', content: 'hello' }]], + 'run-lineage', + 'parent-run-lineage', + { invocation_params: { model: 'gpt-5' } }, + ['prod'], + { + conversation_id: 'framework-conversation-lineage-42', + thread_id: 'framework-thread-lineage-42', + } + ); + await handler.handleLLMEnd( + { + generations: [[{ text: 'world' }]], + llm_output: { model_name: 'gpt-5', finish_reason: 'stop' }, + }, + 'run-lineage' + ); + parentSpan.end(); + + await client.flush(); + const generation = exporter.requests[0].generations[0]; + const generationSpan = spanExporter + .getFinishedSpans() + .find((span) => span.attributes['gen_ai.operation.name'] === 'generateText'); + + assert.ok(generationSpan); + assert.equal(generationSpan.parentSpanContext?.spanId, parentSpan.spanContext().spanId); + assert.equal(generationSpan.spanContext().traceId, parentSpan.spanContext().traceId); + assert.equal(generation.traceId, generationSpan.spanContext().traceId); + assert.equal(generation.spanId, generationSpan.spanContext().spanId); + } finally { + await client.shutdown(); + await tracerProvider.shutdown(); + } + }); + + test(`${framework.name} handler explicitly has no embedding lifecycle`, async () => { + const client = new SigilClient(defaultConfig()); + try { + const handler = new framework.handlerCtor(client); + assert.equal(typeof handler.handleEmbeddingStart, 'undefined'); + assert.equal(typeof handler.handleEmbeddingEnd, 'undefined'); + assert.equal(typeof handler.handleEmbeddingError, 'undefined'); + } finally { + await client.shutdown(); + } + }); } async function captureSingleGeneration(run) { diff --git a/js/test/frameworks.langchain.test.mjs b/js/test/frameworks.langchain.test.mjs index f4bf9e0..c081c29 100644 --- a/js/test/frameworks.langchain.test.mjs +++ b/js/test/frameworks.langchain.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { SpanStatusCode } from '@opentelemetry/api'; +import { context, SpanStatusCode, trace } from '@opentelemetry/api'; import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; import { defaultConfig, SigilClient } from '../.test-dist/index.js'; import { SigilLangChainHandler } from '../.test-dist/frameworks/langchain/index.js'; @@ -143,6 +143,72 @@ test('langchain handler records first token timestamp once per run', async () => } }); +test('langchain generation span tracks active parent span and preserves export lineage', async () => { + const spanExporter = new InMemorySpanExporter(); + const tracerProvider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(spanExporter)], + }); + const baseTracer = tracerProvider.getTracer('sigil-framework-test'); + let parentContext; + const tracer = { + startSpan(name, options, contextArg) { + return baseTracer.startSpan(name, options, contextArg ?? parentContext); + }, + startActiveSpan(...args) { + return baseTracer.startActiveSpan(...args); + }, + }; + const defaults = defaultConfig(); + const exporter = new CapturingExporter(); + const client = new SigilClient({ + generationExport: { + ...defaults.generationExport, + batchSize: 10, + flushIntervalMs: 60_000, + }, + generationExporter: exporter, + tracer, + }); + + try { + const handler = new SigilLangChainHandler(client); + const parentSpan = baseTracer.startSpan('framework.request'); + parentContext = trace.setSpan(context.active(), parentSpan); + await handler.handleChatModelStart( + { name: 'ChatOpenAI' }, + [[{ type: 'human', content: 'hello' }]], + 'run-lineage', + 'parent-run-lineage', + { invocation_params: { model: 'gpt-5' } }, + ['prod'], + { thread_id: 'chain-thread-lineage-42' } + ); + await handler.handleLLMEnd( + { + generations: [[{ text: 'world' }]], + llm_output: { model_name: 'gpt-5', finish_reason: 'stop' }, + }, + 'run-lineage' + ); + parentSpan.end(); + + await client.flush(); + const generation = exporter.requests[0].generations[0]; + const generationSpan = spanExporter + .getFinishedSpans() + .find((span) => span.attributes['gen_ai.operation.name'] === 'generateText'); + + assert.ok(generationSpan); + assert.equal(generationSpan.parentSpanContext?.spanId, parentSpan.spanContext().spanId); + assert.equal(generationSpan.spanContext().traceId, parentSpan.spanContext().traceId); + assert.equal(generation.traceId, generationSpan.spanContext().traceId); + assert.equal(generation.spanId, generationSpan.spanContext().spanId); + } finally { + await client.shutdown(); + await tracerProvider.shutdown(); + } +}); + test('langchain provider mapping covers openai anthopic gemini and fallback', async () => { const providers = []; @@ -177,6 +243,18 @@ test('langchain handler sets call_error on llm error', async () => { assert.equal(generation.tags['sigil.framework.name'], 'langchain'); }); +test('langchain handler explicitly has no embedding lifecycle', async () => { + const client = new SigilClient(defaultConfig()); + try { + const handler = new SigilLangChainHandler(client); + assert.equal(typeof handler.handleEmbeddingStart, 'undefined'); + assert.equal(typeof handler.handleEmbeddingEnd, 'undefined'); + assert.equal(typeof handler.handleEmbeddingError, 'undefined'); + } finally { + await client.shutdown(); + } +}); + test('langchain handler maps tool callbacks and emits chain/retriever spans', async () => { const spanExporter = new InMemorySpanExporter(); const tracerProvider = new BasicTracerProvider({ diff --git a/js/test/frameworks.langgraph.test.mjs b/js/test/frameworks.langgraph.test.mjs index 25c7807..d3e541f 100644 --- a/js/test/frameworks.langgraph.test.mjs +++ b/js/test/frameworks.langgraph.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { SpanStatusCode } from '@opentelemetry/api'; +import { context, SpanStatusCode, trace } from '@opentelemetry/api'; import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; import { defaultConfig, SigilClient } from '../.test-dist/index.js'; import { SigilLangGraphHandler } from '../.test-dist/frameworks/langgraph/index.js'; @@ -97,6 +97,72 @@ test('langgraph handler records stream mode and token fallback output', async () assert.equal(generation.output[0].content, 'hello world'); }); +test('langgraph generation span tracks active parent span and preserves export lineage', async () => { + const spanExporter = new InMemorySpanExporter(); + const tracerProvider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(spanExporter)], + }); + const baseTracer = tracerProvider.getTracer('sigil-framework-test'); + let parentContext; + const tracer = { + startSpan(name, options, contextArg) { + return baseTracer.startSpan(name, options, contextArg ?? parentContext); + }, + startActiveSpan(...args) { + return baseTracer.startActiveSpan(...args); + }, + }; + const defaults = defaultConfig(); + const exporter = new CapturingExporter(); + const client = new SigilClient({ + generationExport: { + ...defaults.generationExport, + batchSize: 10, + flushIntervalMs: 60_000, + }, + generationExporter: exporter, + tracer, + }); + + try { + const handler = new SigilLangGraphHandler(client); + const parentSpan = baseTracer.startSpan('framework.request'); + parentContext = trace.setSpan(context.active(), parentSpan); + await handler.handleChatModelStart( + { name: 'ChatOpenAI' }, + [[{ type: 'human', content: 'hello' }]], + 'run-lineage', + 'parent-run-lineage', + { invocation_params: { model: 'gpt-5' } }, + ['prod'], + { thread_id: 'graph-thread-lineage-42', langgraph_node: 'answer_node' } + ); + await handler.handleLLMEnd( + { + generations: [[{ text: 'world' }]], + llm_output: { model_name: 'gpt-5', finish_reason: 'stop' }, + }, + 'run-lineage' + ); + parentSpan.end(); + + await client.flush(); + const generation = exporter.requests[0].generations[0]; + const generationSpan = spanExporter + .getFinishedSpans() + .find((span) => span.attributes['gen_ai.operation.name'] === 'generateText'); + + assert.ok(generationSpan); + assert.equal(generationSpan.parentSpanContext?.spanId, parentSpan.spanContext().spanId); + assert.equal(generationSpan.spanContext().traceId, parentSpan.spanContext().traceId); + assert.equal(generation.traceId, generationSpan.spanContext().traceId); + assert.equal(generation.spanId, generationSpan.spanContext().spanId); + } finally { + await client.shutdown(); + await tracerProvider.shutdown(); + } +}); + test('langgraph provider mapping covers openai anthopic gemini and fallback', async () => { const providers = []; @@ -131,6 +197,18 @@ test('langgraph handler sets call_error on llm error', async () => { assert.equal(generation.tags['sigil.framework.name'], 'langgraph'); }); +test('langgraph handler explicitly has no embedding lifecycle', async () => { + const client = new SigilClient(defaultConfig()); + try { + const handler = new SigilLangGraphHandler(client); + assert.equal(typeof handler.handleEmbeddingStart, 'undefined'); + assert.equal(typeof handler.handleEmbeddingEnd, 'undefined'); + assert.equal(typeof handler.handleEmbeddingError, 'undefined'); + } finally { + await client.shutdown(); + } +}); + test('langgraph handler maps tool callbacks and emits chain/retriever spans', async () => { const spanExporter = new InMemorySpanExporter(); const tracerProvider = new BasicTracerProvider({ diff --git a/js/test/providers.test.mjs b/js/test/providers.test.mjs index c2c1687..b77ccbc 100644 --- a/js/test/providers.test.mjs +++ b/js/test/providers.test.mjs @@ -549,6 +549,172 @@ test('embedding provider wrapper errors set provider_call_error span status', as } }); +test('provider mappers throw on missing provider responses and stream summaries', () => { + assert.throws( + () => openai.chat.completions.fromRequestResponse( + { + model: 'gpt-5', + messages: [{ role: 'user', content: 'hello' }], + }, + undefined + ), + /reading 'id'/ + ); + assert.throws( + () => openai.responses.fromRequestResponse( + { + model: 'gpt-5', + input: 'hello', + }, + undefined + ), + /reading 'id'/ + ); + assert.throws( + () => openai.chat.completions.fromStream( + { + model: 'gpt-5', + stream: true, + messages: [{ role: 'user', content: 'hello' }], + }, + undefined + ), + /reading 'outputText'/ + ); + assert.throws( + () => openai.responses.fromStream( + { + model: 'gpt-5', + stream: true, + input: 'hello', + }, + undefined + ), + /reading 'events'/ + ); + + assert.throws( + () => anthropic.messages.fromRequestResponse( + { + model: 'claude-sonnet-4-5', + max_tokens: 128, + messages: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }], + }, + undefined + ), + /reading 'content'/ + ); + assert.throws( + () => anthropic.messages.fromStream( + { + model: 'claude-sonnet-4-5', + max_tokens: 128, + messages: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }], + }, + undefined + ), + /reading 'events'/ + ); + + assert.throws( + () => gemini.models.fromRequestResponse( + 'gemini-2.5-pro', + [{ role: 'user', parts: [{ text: 'hello' }] }], + undefined, + undefined + ), + /reading 'candidates'/ + ); + assert.throws( + () => gemini.models.fromStream( + 'gemini-2.5-pro', + [{ role: 'user', parts: [{ text: 'hello' }] }], + undefined, + undefined + ), + /reading 'responses'/ + ); +}); + +test('provider wrappers surface mapper failures when provider payloads are missing', async () => { + for (const suite of [ + { + provider: 'openai', + error: /reading 'id'/, + run: async (client) => { + await openai.chat.completions.create( + client, + { + model: 'gpt-5', + messages: [{ role: 'user', content: 'hello' }], + }, + async () => undefined + ); + }, + }, + { + provider: 'openai', + error: /reading 'id'/, + run: async (client) => { + await openai.responses.create( + client, + { + model: 'gpt-5', + input: 'hello', + }, + async () => undefined + ); + }, + }, + { + provider: 'anthropic', + error: /reading 'content'/, + run: async (client) => { + await anthropic.messages.create( + client, + { + model: 'claude-sonnet-4-5', + max_tokens: 128, + messages: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }], + }, + async () => undefined + ); + }, + }, + { + provider: 'gemini', + error: /reading 'candidates'/, + run: async (client) => { + await gemini.models.generateContent( + client, + 'gemini-2.5-pro', + [{ role: 'user', parts: [{ text: 'hello' }] }], + undefined, + async () => undefined + ); + }, + }, + ]) { + const exporter = new CapturingExporter(); + const client = newClient(exporter); + try { + await assert.rejects(suite.run(client), suite.error); + await client.flush(); + const generation = firstGeneration(exporter); + assert.equal(generation.model.provider, suite.provider); + assert.match(generation.callError ?? '', suite.error); + assert.equal(generation.output, undefined); + } finally { + await client.shutdown(); + } + } +}); + +test('anthropic provider namespace explicitly has no embeddings surface', () => { + assert.ok(anthropic.messages); + assert.equal(anthropic.embeddings, undefined); +}); + test('provider wrappers propagate provider errors and persist callError', async () => { for (const suite of [ { @@ -653,7 +819,7 @@ test('openai chat mapper aggregates system/developer, preserves tool role, and a { role: 'system', content: 'system-message' }, { role: 'developer', content: 'developer-message' }, { role: 'user', content: 'hello' }, - { role: 'tool', content: '{"ok":true}', name: 'tool-weather' }, + { role: 'tool', tool_call_id: 'call_weather', content: '{"ok":true}', name: 'tool-weather' }, ], tools: [ { @@ -704,6 +870,10 @@ test('openai chat mapper aggregates system/developer, preserves tool role, and a assert.equal(mappedDefault.input.length, 2); assert.equal(mappedDefault.input[0].role, 'user'); assert.equal(mappedDefault.input[1].role, 'tool'); + assert.equal(mappedDefault.input[1].parts[0].type, 'tool_result'); + assert.equal(mappedDefault.input[1].parts[0].toolResult.toolCallId, 'call_weather'); + assert.equal(mappedDefault.input[1].parts[0].toolResult.name, 'tool-weather'); + assert.equal(mappedDefault.input[1].parts[0].toolResult.content, '{"ok":true}'); assert.equal(mappedDefault.maxTokens, 256); assert.equal(mappedDefault.temperature, 0.3); assert.equal(mappedDefault.topP, 0.8); @@ -731,6 +901,12 @@ test('openai responses mapper maps input/output/usage and stream fallback from e role: 'user', content: [{ type: 'input_text', text: 'hello' }], }, + { + type: 'function_call_output', + call_id: 'call_weather', + name: 'weather', + output: { temp_c: 18 }, + }, ], max_output_tokens: 300, tool_choice: { type: 'function', name: 'weather' }, @@ -756,6 +932,13 @@ test('openai responses mapper maps input/output/usage and stream fallback from e name: 'weather', arguments: '{"city":"Paris"}', }, + { + id: 'result-1', + type: 'function_call_output', + call_id: 'call_weather', + name: 'weather', + output: { temp_c: 18 }, + }, ], status: 'completed', parallel_tool_calls: false, @@ -777,15 +960,23 @@ test('openai responses mapper maps input/output/usage and stream fallback from e const mapped = openai.responses.fromRequestResponse(request, response); assert.equal(mapped.responseModel, 'gpt-5'); - assert.equal(mapped.input.length, 1); + assert.equal(mapped.input.length, 2); assert.equal(mapped.input[0].role, 'user'); assert.equal(mapped.input[0].content, 'hello'); + assert.equal(mapped.input[1].role, 'tool'); + assert.equal(mapped.input[1].parts[0].type, 'tool_result'); + assert.equal(mapped.input[1].parts[0].toolResult.toolCallId, 'call_weather'); + assert.equal(mapped.input[1].parts[0].toolResult.contentJSON, '{"temp_c":18}'); assert.equal(mapped.maxTokens, 300); assert.equal(mapped.stopReason, 'stop'); assert.equal(mapped.thinkingEnabled, true); assert.equal(mapped.metadata['sigil.gen_ai.request.thinking.budget_tokens'], 640); assert.equal(mapped.usage.totalTokens, 100); - assert.equal(mapped.output.length > 0, true); + assert.equal(mapped.output.length, 3); + assert.equal(mapped.output[2].role, 'tool'); + assert.equal(mapped.output[2].parts[0].type, 'tool_result'); + assert.equal(mapped.output[2].parts[0].toolResult.toolCallId, 'call_weather'); + assert.equal(mapped.output[2].parts[0].toolResult.contentJSON, '{"temp_c":18}'); const streamed = openai.responses.fromStream( { ...request, stream: true }, @@ -813,7 +1004,7 @@ test('openai responses mapper maps input/output/usage and stream fallback from e ); assert.equal(streamed.responseModel, 'gpt-5'); - assert.equal(streamed.input.length, 1); + assert.equal(streamed.input.length, 2); assert.equal(streamed.input[0].content, 'hello'); assert.equal(streamed.output.length, 1); assert.equal(streamed.output[0].content, 'delta-one delta-two'); diff --git a/js/tsconfig.json b/js/tsconfig.json index 05f6fb8..cfb0672 100644 --- a/js/tsconfig.json +++ b/js/tsconfig.json @@ -7,7 +7,8 @@ "noImplicitOverride": true, "noUncheckedIndexedAccess": true, "skipLibCheck": true, - "noEmit": true + "noEmit": true, + "types": ["node"] }, "include": ["src/**/*.ts"] } diff --git a/js/tsconfig.test.json b/js/tsconfig.test.json index f4844f7..096bea5 100644 --- a/js/tsconfig.test.json +++ b/js/tsconfig.test.json @@ -4,7 +4,8 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "noEmit": false, - "outDir": ".test-dist" + "outDir": ".test-dist", + "rootDir": "./src" }, "include": ["src/**/*.ts"] } diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..96e0dd6 --- /dev/null +++ b/mise.toml @@ -0,0 +1,428 @@ +[env] +PYTHON_BIN = "python3" + +# --- Dependencies --- + +[tasks.deps] +description = "Install JS/TS dependencies" +run = "pnpm install" + +# --- Formatting --- + +[tasks."format:go"] +description = "Format Go code in all modules" +run = """ +#!/usr/bin/env bash +set -euo pipefail +while IFS= read -r moddir; do + echo "==> gofmt ${moddir}" + gofmt -w "${moddir}" +done < <(find . -name go.mod -not -path './node_modules/*' -exec dirname {} \\; | sort) +""" + +[tasks."format:cs"] +description = "Format .NET SDK code" +run = "dotnet format dotnet/Sigil.DotNet.sln" + +[tasks.format] +description = "Format all code" +depends = ["format:go", "format:cs"] + +# --- Linting --- + +[tasks."lint:go"] +description = "Lint Go code in all modules" +run = """ +#!/usr/bin/env bash +set -euo pipefail +while IFS= read -r moddir; do + if [[ -z "$(cd "${moddir}" && GOWORK=off go list ./... 2>/dev/null || true)" ]]; then + echo "==> skip golangci-lint ${moddir} (no Go packages)" + continue + fi + echo "==> golangci-lint ${moddir}" + (cd "${moddir}" && GOWORK=off golangci-lint run ./...) +done < <(find . -name go.mod -not -path './node_modules/*' -exec dirname {} \\; | sort) +""" + +[tasks."lint:cs"] +description = "Verify .NET SDK formatting and analyzer checks" +run = "dotnet format dotnet/Sigil.DotNet.sln --verify-no-changes" + +[tasks.lint] +description = "Run all linting" +depends = ["lint:go", "lint:cs"] + +# --- Type checking --- + +[tasks."typecheck:ts:sdk-js"] +description = "Type-check TypeScript/JavaScript SDK code" +depends = ["deps"] +run = "pnpm --filter @grafana/sigil-sdk-js run typecheck" + +[tasks.typecheck] +description = "Run all type checks" +depends = ["typecheck:ts:sdk-js"] + +# --- Go SDK tests --- + +[tasks."test:go:sdk-core"] +description = "Run Go SDK core tests as standalone module" +dir = "go" +run = "GOWORK=off go test ./..." + +[tasks."test:go:sdk-anthropic"] +description = "Run Anthropic provider helper tests as standalone module" +dir = "go-providers/anthropic" +run = "GOWORK=off go test ./..." + +[tasks."test:go:sdk-openai"] +description = "Run OpenAI provider helper tests as standalone module" +dir = "go-providers/openai" +run = "GOWORK=off go test ./..." + +[tasks."test:go:sdk-gemini"] +description = "Run Gemini provider helper tests as standalone module" +dir = "go-providers/gemini" +run = "GOWORK=off go test ./..." + +[tasks."test:go:sdk-google-adk"] +description = "Run Google ADK framework helper tests as standalone module" +dir = "go-frameworks/google-adk" +run = "GOWORK=off go test ./..." + +[tasks."test:go:sdk-conformance"] +description = "Run the Go SDK core conformance harness" +dir = "go" +run = "GOWORK=off go test ./sigil -run '^TestConformance' -count=1" + +# --- TypeScript/JavaScript SDK tests --- + +[tasks."test:ts:sdk-js"] +description = "Run TypeScript/JavaScript SDK runtime tests" +depends = ["deps"] +run = "pnpm --filter @grafana/sigil-sdk-js run test:ci" + +[tasks."test:ts:sdk-conformance"] +description = "Run TypeScript/JavaScript SDK core conformance tests" +depends = ["deps"] +dir = "js" +run = "pnpm run test:build && node --test test/conformance.test.mjs" + +[tasks."test:ts:sdk-provider-conformance"] +description = "Run TypeScript/JavaScript provider-wrapper conformance tests" +depends = ["deps"] +dir = "js" +run = "pnpm run test:build && node --test test/providers.test.mjs" + +[tasks."test:ts:sdk-framework-conformance"] +description = "Run TypeScript/JavaScript framework-adapter conformance tests" +depends = ["deps"] +run = "mise run test:ts:sdk-js-frameworks" + +[tasks."test:ts:sdk-js-frameworks"] +description = "Run TypeScript/JavaScript SDK framework handler tests" +depends = ["deps"] +dir = "js" +run = "pnpm run test:build && node --test test/frameworks.langchain.test.mjs test/frameworks.langgraph.test.mjs test/frameworks.additional.test.mjs test/frameworks.vercel-ai-sdk.mapping.test.mjs test/frameworks.vercel-ai-sdk.test.mjs" + +# --- Python SDK tests --- + +[tasks."test:py:sdk-core"] +description = "Run Python SDK core parity tests" +run = "uv run --python \"$PYTHON_BIN\" --with '.[dev]' --directory python pytest tests" + +[tasks."test:py:sdk-conformance"] +description = "Run Python SDK core conformance tests" +run = "uv run --python \"$PYTHON_BIN\" --with '.[dev]' --directory python pytest tests/test_conformance.py" + +[tasks."test:py:sdk-openai"] +description = "Run Python OpenAI provider helper tests" +run = "uv run --python \"$PYTHON_BIN\" --with './python[dev]' --with './python-providers/openai[dev]' pytest python-providers/openai/tests" + +[tasks."test:py:sdk-anthropic"] +description = "Run Python Anthropic provider helper tests" +run = "uv run --python \"$PYTHON_BIN\" --with './python[dev]' --with './python-providers/anthropic[dev]' pytest python-providers/anthropic/tests" + +[tasks."test:py:sdk-gemini"] +description = "Run Python Gemini provider helper tests" +run = "uv run --python \"$PYTHON_BIN\" --with './python[dev]' --with './python-providers/gemini[dev]' pytest python-providers/gemini/tests" + +[tasks."test:py:sdk-langchain"] +description = "Run Python LangChain framework handler tests" +run = "uv run --python \"$PYTHON_BIN\" --with './python[dev]' --with './python-frameworks/langchain[dev]' pytest python-frameworks/langchain/tests" + +[tasks."test:py:sdk-langgraph"] +description = "Run Python LangGraph framework handler tests" +run = "uv run --python \"$PYTHON_BIN\" --with './python[dev]' --with './python-frameworks/langgraph[dev]' pytest python-frameworks/langgraph/tests" + +[tasks."test:py:sdk-openai-agents"] +description = "Run Python OpenAI Agents framework handler tests" +run = "uv run --python \"$PYTHON_BIN\" --with './python[dev]' --with './python-frameworks/openai-agents[dev]' pytest python-frameworks/openai-agents/tests" + +[tasks."test:py:sdk-llamaindex"] +description = "Run Python LlamaIndex framework handler tests" +run = "uv run --python \"$PYTHON_BIN\" --with './python[dev]' --with './python-frameworks/llamaindex[dev]' pytest python-frameworks/llamaindex/tests" + +[tasks."test:py:sdk-google-adk"] +description = "Run Python Google ADK framework handler tests" +run = "uv run --python \"$PYTHON_BIN\" --with './python[dev]' --with './python-frameworks/google-adk[dev]' pytest python-frameworks/google-adk/tests" + +[tasks."test:py:sdk-provider-conformance"] +description = "Run Python provider-wrapper conformance tests" +run = """ +#!/usr/bin/env bash +set -euo pipefail +mise run test:py:sdk-openai +mise run test:py:sdk-anthropic +mise run test:py:sdk-gemini +""" + +[tasks."test:py:sdk-framework-conformance"] +description = "Run Python framework-adapter conformance tests" +run = """ +#!/usr/bin/env bash +set -euo pipefail +mise run test:py:sdk-langchain +mise run test:py:sdk-langgraph +mise run test:py:sdk-openai-agents +mise run test:py:sdk-llamaindex +mise run test:py:sdk-google-adk +""" + +# --- .NET SDK tests --- + +[tasks."test:cs:sdk-core"] +description = "Run .NET SDK core runtime tests" +dir = "dotnet" +run = "dotnet test tests/Grafana.Sigil.Tests/Grafana.Sigil.Tests.csproj -c Release" + +[tasks."test:cs:sdk-conformance"] +description = "Run .NET SDK core conformance tests" +dir = "dotnet" +run = "dotnet test tests/Grafana.Sigil.Tests/Grafana.Sigil.Tests.csproj -c Release --filter FullyQualifiedName~ConformanceTests" + +[tasks."test:cs:sdk-openai"] +description = "Run .NET OpenAI provider helper tests" +dir = "dotnet" +run = "dotnet test tests/Grafana.Sigil.OpenAI.Tests/Grafana.Sigil.OpenAI.Tests.csproj -c Release" + +[tasks."test:cs:sdk-anthropic"] +description = "Run .NET Anthropic provider helper tests" +dir = "dotnet" +run = "dotnet test tests/Grafana.Sigil.Anthropic.Tests/Grafana.Sigil.Anthropic.Tests.csproj -c Release" + +[tasks."test:cs:sdk-gemini"] +description = "Run .NET Gemini provider helper tests" +dir = "dotnet" +run = "dotnet test tests/Grafana.Sigil.Gemini.Tests/Grafana.Sigil.Gemini.Tests.csproj -c Release" + +[tasks."test:cs:sdk-provider-conformance"] +description = "Run .NET provider-wrapper conformance tests" +run = """ +#!/usr/bin/env bash +set -euo pipefail +mise run test:cs:sdk-openai +mise run test:cs:sdk-anthropic +mise run test:cs:sdk-gemini +""" + +# --- Java SDK tests --- + +[tasks."test:java:sdk-core"] +description = "Run Java SDK core tests" +dir = "java" +run = "./gradlew --no-daemon :core:test" + +[tasks."test:java:sdk-conformance"] +description = "Run Java SDK core conformance tests" +dir = "java" +run = "./gradlew --no-daemon :core:test --tests 'com.grafana.sigil.sdk.ConformanceTest'" + +[tasks."test:java:sdk-openai"] +description = "Run Java OpenAI provider adapter tests" +dir = "java" +run = "./gradlew --no-daemon :providers:openai:test" + +[tasks."test:java:sdk-anthropic"] +description = "Run Java Anthropic provider adapter tests" +dir = "java" +run = "./gradlew --no-daemon :providers:anthropic:test" + +[tasks."test:java:sdk-gemini"] +description = "Run Java Gemini provider adapter tests" +dir = "java" +run = "./gradlew --no-daemon :providers:gemini:test" + +[tasks."test:java:sdk-google-adk"] +description = "Run Java Google ADK framework adapter tests" +dir = "java" +run = "./gradlew --no-daemon :frameworks:google-adk:test" + +[tasks."test:java:sdk-all"] +description = "Run all Java SDK tests" +dir = "java" +run = "./gradlew --no-daemon :core:test :providers:openai:test :providers:anthropic:test :providers:gemini:test :frameworks:google-adk:test" + +[tasks."test:java:sdk-provider-conformance"] +description = "Run Java provider-wrapper conformance tests" +dir = "java" +run = "./gradlew --no-daemon :providers:openai:test :providers:anthropic:test :providers:gemini:test" + +[tasks."test:java:sdk-framework-conformance"] +description = "Run Java framework-adapter conformance tests" +dir = "java" +run = "./gradlew --no-daemon :frameworks:google-adk:test" + +[tasks."benchmark:java:sdk"] +description = "Run Java SDK JMH benchmarks" +dir = "java" +run = "./gradlew --no-daemon :benchmarks:jmh" + +# --- Cross-SDK conformance --- + +[tasks."test:sdk:core-conformance"] +description = "Run core conformance suites across Go, TypeScript/JavaScript, Python, Java, and .NET SDKs" +run = """ +#!/usr/bin/env bash +set -euo pipefail +mise run test:go:sdk-conformance +mise run test:ts:sdk-conformance +mise run test:py:sdk-conformance +mise run test:java:sdk-conformance +mise run test:cs:sdk-conformance +""" + +[tasks."test:sdk:provider-conformance"] +description = "Run provider-wrapper conformance suites across all shipped SDKs" +run = """ +#!/usr/bin/env bash +set -euo pipefail +mise run test:go:sdk-anthropic +mise run test:go:sdk-openai +mise run test:go:sdk-gemini +mise run test:ts:sdk-provider-conformance +mise run test:py:sdk-provider-conformance +mise run test:cs:sdk-provider-conformance +mise run test:java:sdk-provider-conformance +""" + +[tasks."test:sdk:framework-conformance"] +description = "Run framework-adapter conformance suites across all shipped SDKs" +run = """ +#!/usr/bin/env bash +set -euo pipefail +mise run test:go:sdk-google-adk +mise run test:ts:sdk-framework-conformance +mise run test:py:sdk-framework-conformance +mise run test:java:sdk-framework-conformance +""" + +[tasks."test:sdk:conformance"] +description = "Run core, provider-wrapper, and framework-adapter conformance suites across supported SDKs" +run = """ +#!/usr/bin/env bash +set -euo pipefail +mise run test:sdk:core-conformance +mise run test:sdk:provider-conformance +mise run test:sdk:framework-conformance +""" + +[tasks."test:sdk:all"] +description = "Run all SDK checks (Go, TypeScript/JavaScript, Python, Java, and .NET SDK suites)" +run = """ +#!/usr/bin/env bash +set -euo pipefail +mise run test:go:sdk-core +mise run test:go:sdk-anthropic +mise run test:go:sdk-openai +mise run test:go:sdk-gemini +mise run test:go:sdk-google-adk +mise run test:ts:sdk-js +mise run test:py:sdk-core +mise run test:py:sdk-provider-conformance +mise run test:py:sdk-framework-conformance +mise run test:cs:sdk-core +mise run test:cs:sdk-provider-conformance +mise run test:java:sdk-all +""" + +[tasks."sdk:conformance"] +description = "Alias for test:sdk:conformance" +run = "mise run test:sdk:conformance" + +# --- Python version bump --- + +[tasks."sdk:py:bump"] +description = "Bump version across all 9 Python SDK pyproject.toml files" +run = ''' +#!/usr/bin/env bash +set -euo pipefail + +VERSION="${1:?Usage: mise run sdk:py:bump }" +if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: version must be in MAJOR.MINOR.PATCH format (got: $VERSION)" >&2 + exit 1 +fi + +PACKAGE_DIRS=( + python + python-providers/openai + python-providers/anthropic + python-providers/gemini + python-frameworks/langchain + python-frameworks/langgraph + python-frameworks/openai-agents + python-frameworks/llamaindex + python-frameworks/google-adk +) + +for dir in "${PACKAGE_DIRS[@]}"; do + file="${dir}/pyproject.toml" + perl -i -pe "s/^version = \".*\"/version = \"${VERSION}\"/" "$file" + if [[ "$dir" != "python" ]]; then + perl -i -pe "s/\"sigil-sdk>=.*\"/\"sigil-sdk>=${VERSION}\"/" "$file" + fi + echo " updated ${file}" +done + +echo "All Python SDK versions bumped to ${VERSION}" +''' + +# --- Mock generation (stubs) --- + +[tasks."generate:mocks:sdk-go"] +description = "Generate Go SDK mocks" +run = """ +#!/usr/bin/env bash +set -euo pipefail +echo "==> generating Go SDK mocks" +echo "No Go SDK mocks to generate yet." +""" + +[tasks."generate:mocks:sdk-go-providers"] +description = "Generate Go SDK provider mocks" +run = """ +#!/usr/bin/env bash +set -euo pipefail +echo "==> generating Go SDK provider mocks" +echo "No Go SDK provider mocks to generate yet." +""" + +[tasks."generate:mocks:sdk-go-frameworks"] +description = "Generate Go SDK framework mocks" +run = """ +#!/usr/bin/env bash +set -euo pipefail +echo "==> generating Go SDK framework mocks" +echo "No Go SDK framework mocks to generate yet." +""" + +[tasks."generate:mocks"] +description = "Generate all mock implementations" +depends = ["generate:mocks:sdk-go", "generate:mocks:sdk-go-providers", "generate:mocks:sdk-go-frameworks"] + +[tasks.check] +description = "Run lint + typecheck + tests" +depends = ["lint", "typecheck"] +run = "mise run test:sdk:all" diff --git a/package.json b/package.json new file mode 100644 index 0000000..2c256e4 --- /dev/null +++ b/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "packageManager": "pnpm@10.12.1" +} diff --git a/plugins/opencode/.gitignore b/plugins/opencode/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/plugins/opencode/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/plugins/opencode/README.md b/plugins/opencode/README.md new file mode 100644 index 0000000..d752ac8 --- /dev/null +++ b/plugins/opencode/README.md @@ -0,0 +1,41 @@ +# opencode-sigil + +OpenCode plugin that records LLM generations to Grafana Sigil for AI observability. + +## What it does + +Hooks into OpenCode's chat lifecycle to capture assistant messages and send them to Sigil as generation telemetry. Tracks conversation context, tool usage, model metadata, and optionally full message content with PII redaction. + +## Setup + +1. Create `~/.config/opencode/opencode-sigil.json`: + +```json +{ + "enabled": true, + "endpoint": "http://localhost:8080/api/v1/generations:export", + "auth": { "mode": "none" }, + "agentName": "opencode", + "contentCapture": true +} +``` + +2. Register the plugin in your OpenCode configuration. + +### Auth modes + +- `none` -- no authentication (local dev) +- `bearer` -- `{ "mode": "bearer", "bearerToken": "..." }` +- `tenant` -- `{ "mode": "tenant", "tenantId": "..." }` +- `basic` -- `{ "mode": "basic", "tenantId": "...", "token": "..." }` + +## Development + +```bash +# From the repo root +pnpm install +pnpm --filter opencode-sigil build +pnpm --filter opencode-sigil test +``` + +The `@grafana/sigil-sdk-js` dependency resolves via pnpm workspace linking to `sdks/js`. diff --git a/plugins/opencode/package.json b/plugins/opencode/package.json new file mode 100644 index 0000000..386564e --- /dev/null +++ b/plugins/opencode/package.json @@ -0,0 +1,34 @@ +{ + "name": "opencode-sigil", + "version": "0.1.0", + "description": "OpenCode plugin for Grafana Sigil AI telemetry", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": ["dist", "skills"], + "scripts": { + "build": "bash scripts/build.sh", + "typecheck": "tsc --noEmit", + "deploy": "bash scripts/deploy.sh", + "test": "vitest run", + "test:watch": "vitest" + }, + "peerDependencies": { + "@opencode-ai/plugin": "^1.2.16" + }, + "devDependencies": { + "@grafana/sigil-sdk-js": "workspace:*", + "@opencode-ai/plugin": "^1.3.0", + "@opencode-ai/sdk": "^1.3.2", + "@types/node": "^24.0.0", + "esbuild": "^0.27.3", +"typescript": "^6.0.0", + "vitest": "^4.1.0" + } +} diff --git a/plugins/opencode/scripts/build.sh b/plugins/opencode/scripts/build.sh new file mode 100755 index 0000000..7161f73 --- /dev/null +++ b/plugins/opencode/scripts/build.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SDK_DIR="$(cd "${SCRIPT_DIR}/../../../sdks/js" && pwd)" + +# Build the SDK so workspace-linked types are available +if [ ! -f "${SDK_DIR}/dist/index.d.ts" ]; then + echo "Building @grafana/sigil-sdk-js..." + npx tsc --project "${SDK_DIR}/tsconfig.build.json" +fi + +tsc --noEmit + +npx esbuild src/index.ts \ + --bundle \ + --format=esm \ + --platform=node \ + --target=es2022 \ + --outfile=dist/index.js \ + --external:@opencode-ai/plugin \ + --external:@opencode-ai/sdk + +tsc --emitDeclarationOnly --declaration --declarationMap --outDir dist diff --git a/plugins/opencode/scripts/deploy.sh b/plugins/opencode/scripts/deploy.sh new file mode 100755 index 0000000..6594dd0 --- /dev/null +++ b/plugins/opencode/scripts/deploy.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN_DIR="$(dirname "$SCRIPT_DIR")" +OPENCODE_DIR="${HOME}/.config/opencode" + +echo "Deploying opencode-sigil..." + +mkdir -p "${OPENCODE_DIR}/plugins" "${OPENCODE_DIR}/skills" + +ln -sf "${PLUGIN_DIR}/dist/index.js" "${OPENCODE_DIR}/plugins/opencode-sigil.js" +echo " [link] opencode-sigil.js" + +if [ -d "${PLUGIN_DIR}/skills/sigil" ]; then + rm -rf "${OPENCODE_DIR}/skills/sigil" + cp -R "${PLUGIN_DIR}/skills/sigil" "${OPENCODE_DIR}/skills/sigil" + echo " [copy] sigil skill" +fi + +echo "Done. Restart OpenCode to pick up changes." diff --git a/plugins/opencode/skills/sigil/SKILL.md b/plugins/opencode/skills/sigil/SKILL.md new file mode 100644 index 0000000..c7f1b3d --- /dev/null +++ b/plugins/opencode/skills/sigil/SKILL.md @@ -0,0 +1,151 @@ +# Claude Code Prompt: Sigil Instrumentation + +You are running in Opencode with repository files and shell access. + +- Prefer direct file edits over speculative refactors. +- Before proposing broad changes, confirm impact scope with quick evidence. + +## Sigil Agent-First Instrumentation Brief + +You are acting as a coding agent inside this repository. Your goal is to add or improve Grafana Sigil instrumentation with minimal, safe changes. + +## Mission + +1. Find AI generation and tool/agent execution paths. +2. Add Sigil instrumentation using the local language SDK where possible. +3. Preserve behavior and keep diffs small. +4. Add or update tests for changed instrumentation behavior. +5. Explain what was instrumented and why. + +## Output contract (required) + +Return: + +- Top opportunities first (highest traffic / highest impact) +- For each opportunity: + - exact file path(s) + - why this location matters + - concrete diff proposal + - test plan + - any risk or compatibility concern + +## Sigil architecture and ingest model (must follow) + +- Sigil uses generation-first ingest: + - gRPC: `sigil.v1.GenerationIngestService.ExportGenerations` + - HTTP parity: `POST /api/v1/generations:export` +- Traces/metrics go through OTEL collector/alloy, not through Sigil ingest. +- Required generation modes: + - non-stream: `SYNC` + - stream: `STREAM` +- Raw provider artifacts are default OFF and only enabled for explicit debug opt-in. + +Authoritative references in this repo: + +- `ARCHITECTURE.md` +- `docs/references/generation-ingest-contract.md` +- `docs/references/semantic-conventions.md` + +## Telemetry fields to prioritize + +On generation and tool spans, capture or preserve these when available: + +- identity and routing: + - `gen_ai.operation.name` + - `sigil.generation.id` + - `gen_ai.conversation.id` + - `gen_ai.agent.name` + - `gen_ai.agent.version` + - `sigil.sdk.name` +- model: + - `gen_ai.provider.name` + - `gen_ai.request.model` + - `gen_ai.response.model` +- request controls: + - `gen_ai.request.max_tokens` + - `gen_ai.request.temperature` + - `gen_ai.request.top_p` + - `sigil.gen_ai.request.tool_choice` + - `sigil.gen_ai.request.thinking.enabled` + - `sigil.gen_ai.request.thinking.budget_tokens` +- usage and outcomes: + - `gen_ai.usage.input_tokens` + - `gen_ai.usage.output_tokens` + - `gen_ai.usage.cache_read_input_tokens` + - `gen_ai.usage.cache_creation_input_tokens` + - `gen_ai.usage.reasoning_tokens` + - `gen_ai.response.finish_reasons` + - error classification fields (`error.type`, `error.category`) + +## SDK locations and how to instrument + +Prefer these existing SDKs and wrappers before inventing custom plumbing: + +- Go core SDK: `sdks/go` (see `sdks/go/README.md`) + - `StartGeneration`, `StartStreamingGeneration`, `StartToolExecution`, `StartEmbedding` +- JS/TS SDK: `sdks/js` (see `sdks/js/README.md`) + - `startGeneration`, `startStreamingGeneration`, `startToolExecution`, `startEmbedding` +- Python SDK: `sdks/python` (see `sdks/python/README.md`) + - `start_generation`, `start_streaming_generation`, `start_tool_execution`, `start_embedding` +- Java SDK: `sdks/java` (see `sdks/java/README.md`) + - `startGeneration`, `startStreamingGeneration`, `withGeneration`, `withToolExecution` +- .NET SDK: `sdks/dotnet` (see `sdks/dotnet/README.md`) + - `StartGeneration`, `StartStreamingGeneration`, `StartToolExecution`, `StartEmbedding` + +Provider wrappers and framework adapters already exist; reuse them where possible: + +- Go providers: `sdks/go-providers/openai`, `sdks/go-providers/anthropic`, `sdks/go-providers/gemini` +- Python providers: `sdks/python-providers/*` +- Java providers: `sdks/java/providers/*` +- .NET providers: `sdks/dotnet/src/Grafana.Sigil.*` +- Framework adapters: + - Python: `sdks/python-frameworks/*` + - Go Google ADK: `sdks/go-frameworks/google-adk` + - Java Google ADK: `sdks/java/frameworks/google-adk` + - JS subpath adapters documented in `sdks/js/README.md` + +## Useful repo examples to copy patterns from + +- Go explicit generation flow: + - `sdks/go/sigil/example_test.go` + - `sdks/go/cmd/devex-emitter/main.go` +- Go provider wrapper examples: + - `sdks/go-providers/openai/sdk_example_test.go` + - `sdks/go-providers/anthropic/sdk_example_test.go` + - `sdks/go-providers/gemini/sdk_example_test.go` +- .NET end-to-end emitter: + - `sdks/dotnet/examples/Grafana.Sigil.DevExEmitter/Program.cs` +- JS transport and framework behavior: + - `sdks/js/test/client.transport.test.mjs` + - `sdks/js/test/frameworks.vercel-ai-sdk.test.mjs` +- Python framework integration tests: + - `sdks/python-frameworks/*/tests/*.py` + +## Implementation rules + +- Keep behavior unchanged except instrumentation additions/fixes. +- Prefer small targeted patches over refactors. +- Use existing conventions in each language package. +- Keep raw artifacts disabled unless explicitly asked. +- Ensure non-stream wrappers set `SYNC`, stream wrappers set `STREAM`. +- Ensure lifecycle flush/shutdown semantics are preserved. + +## Validation checklist + +After proposing edits, include checks for: + +- span attributes emitted as expected +- generation payload shape valid for ingest contract +- no regressions in existing tests +- language-specific tests or focused test additions for new instrumentation logic + +## Deliverable format (strict) + +Provide: + +1. Prioritized instrumentation opportunities +2. Proposed diffs per opportunity +3. Test updates per opportunity +4. Rollout/risk notes + +If no safe opportunities are found, explain exactly why and list what evidence you checked. diff --git a/plugins/opencode/src/client.ts b/plugins/opencode/src/client.ts new file mode 100644 index 0000000..ed05aa6 --- /dev/null +++ b/plugins/opencode/src/client.ts @@ -0,0 +1,67 @@ +import { SigilClient } from "@grafana/sigil-sdk-js"; +import type { SigilConfig, SigilAuthConfig } from "./config.js"; + +// Matches ExportAuthConfig from @grafana/sigil-sdk-js (not re-exported from package index) +type ResolvedAuth = { + mode: "none" | "tenant" | "bearer"; + tenantId?: string; + bearerToken?: string; +}; + +export function resolveEnvVars(value: string): string { + return value.replace(/\$\{(\w+)\}/g, (_match, name) => { + return process.env[name] ?? ""; + }); +} + +type ResolvedTransport = { + auth: ResolvedAuth; + headers?: Record; +}; + +function resolveAuth(auth: SigilAuthConfig): ResolvedTransport { + switch (auth.mode) { + case "bearer": + return { auth: { mode: "bearer", bearerToken: resolveEnvVars(auth.bearerToken) } }; + case "tenant": + return { auth: { mode: "tenant", tenantId: resolveEnvVars(auth.tenantId) } }; + case "basic": { + // JS SDK doesn't support Basic auth natively — use + // mode "none" and inject the Authorization header manually. + const user = resolveEnvVars(auth.tenantId); + const pass = resolveEnvVars(auth.token); + const encoded = Buffer.from(`${user}:${pass}`).toString("base64"); + return { + auth: { mode: "none" }, + headers: { Authorization: `Basic ${encoded}` }, + }; + } + case "none": + return { auth: { mode: "none" } }; + } +} + +const GENERATION_EXPORT_PATH = "/api/v1/generations:export"; + +export function createSigilClient(config: SigilConfig): SigilClient | null { + try { + if (!config.endpoint.includes(GENERATION_EXPORT_PATH)) { + console.warn( + `[sigil] endpoint "${config.endpoint}" does not include "${GENERATION_EXPORT_PATH}" -- ` + + `the JS SDK requires the full export URL (e.g. "http://localhost:8080${GENERATION_EXPORT_PATH}")`, + ); + } + const transport = resolveAuth(config.auth); + return new SigilClient({ + generationExport: { + protocol: "http", + endpoint: config.endpoint, + auth: transport.auth, + ...(transport.headers && { headers: transport.headers }), + }, + }); + } catch { + console.warn("[sigil] failed to create SigilClient"); + return null; + } +} diff --git a/plugins/opencode/src/config.ts b/plugins/opencode/src/config.ts new file mode 100644 index 0000000..ae3b1e2 --- /dev/null +++ b/plugins/opencode/src/config.ts @@ -0,0 +1,51 @@ +import { readFile } from "fs/promises"; +import { join } from "path"; +import { homedir } from "os"; + +export type SigilAuthConfig = + | { mode: "bearer"; bearerToken: string } + | { mode: "tenant"; tenantId: string } + | { mode: "basic"; tenantId: string; token: string } + | { mode: "none" }; + +export type SigilConfig = { + enabled: boolean; + endpoint: string; + auth: SigilAuthConfig; + agentName?: string; + agentVersion?: string; + contentCapture?: boolean; +}; + +const CONFIG_PATH = join(homedir(), ".config", "opencode", "opencode-sigil.json"); + +const DISABLED: SigilConfig = { + enabled: false, + endpoint: "", + auth: { mode: "none" }, +}; + +export async function loadSigilConfig(): Promise { + try { + const raw = await readFile(CONFIG_PATH, "utf-8"); + const parsed = JSON.parse(raw); + return parseSigilConfig(parsed) ?? DISABLED; + } catch { + return DISABLED; + } +} + +export function parseSigilConfig(raw: unknown): SigilConfig | undefined { + if (!raw || typeof raw !== "object") return undefined; + const obj = raw as Record; + if (obj.enabled !== true) return undefined; + if (typeof obj.endpoint !== "string" || !obj.endpoint) { + console.warn("[sigil] enabled but endpoint is missing -- disabling"); + return undefined; + } + if (!obj.auth || typeof obj.auth !== "object") { + console.warn("[sigil] enabled but auth config is missing -- disabling"); + return undefined; + } + return raw as SigilConfig; +} diff --git a/plugins/opencode/src/hooks.ts b/plugins/opencode/src/hooks.ts new file mode 100644 index 0000000..9aecfc9 --- /dev/null +++ b/plugins/opencode/src/hooks.ts @@ -0,0 +1,189 @@ +import type { SigilClient } from "@grafana/sigil-sdk-js"; +import type { AssistantMessage, UserMessage, Part } from "@opencode-ai/sdk"; +import type { PluginInput } from "@opencode-ai/plugin"; +import type { SigilConfig } from "./config.js"; +import { createSigilClient } from "./client.js"; +import { Redactor } from "./redact.js"; +import { mapGeneration, mapError, mapToolDefinitions } from "./mappers.js"; + +type OpencodeClient = PluginInput["client"]; + +// Track recorded messages per session for dedup and cleanup +const recordedMessages = new Map>(); + +// Pending generation store: user-side data captured before assistant responds +type PendingGeneration = { + systemPrompt: string | undefined; + userParts: Part[]; + tools: Record | undefined; +}; +const pendingGenerations = new Map(); + +function buildAgentName(prefix: string | undefined, mode: string | undefined): string { + const base = prefix || "opencode"; + return mode ? `${base}:${mode}` : base; +} + +/** + * Called from the chat.message hook. Stores user-side data for later use + * when the assistant message completes. + */ +function handleChatMessage( + input: { sessionID: string }, + output: { message: UserMessage; parts: Part[] }, +): void { + pendingGenerations.set(input.sessionID, { + systemPrompt: output.message.system, + userParts: output.parts, + tools: output.message.tools, + }); +} + +async function handleEvent( + sigil: SigilClient, + config: SigilConfig, + client: OpencodeClient, + redactor: Redactor, + event: { type: string; properties: unknown }, +): Promise { + if (event.type !== "message.updated") return; + + const properties = event.properties as { info?: { role?: string } } | undefined; + const msg = properties?.info; + if (!msg || msg.role !== "assistant") return; + + const assistantMsg = msg as AssistantMessage; + + // Only record terminal messages + const isTerminal = assistantMsg.finish || assistantMsg.error || assistantMsg.time.completed; + if (!isTerminal) return; + + // Dedup + const sessionSet = recordedMessages.get(assistantMsg.sessionID) ?? new Set(); + if (sessionSet.has(assistantMsg.id)) return; + sessionSet.add(assistantMsg.id); + recordedMessages.set(assistantMsg.sessionID, sessionSet); + + // Look up pending generation (user-side data) + const pending = pendingGenerations.get(assistantMsg.sessionID); + + // Fetch assistant parts via REST + let assistantParts: Part[] = []; + try { + const response = await client.session.message({ + path: { id: assistantMsg.sessionID, messageID: assistantMsg.id }, + }); + assistantParts = response.data?.parts ?? []; + } catch { + // REST fetch failed — fall back to metadata-only + } + + const contentCapture = config.contentCapture ?? true; + + const seed = { + conversationId: assistantMsg.sessionID, + agentName: buildAgentName(config.agentName, assistantMsg.mode), + agentVersion: config.agentVersion, + model: { provider: assistantMsg.providerID, name: assistantMsg.modelID }, + startedAt: new Date(assistantMsg.time.created), + ...(contentCapture && { + systemPrompt: pending?.systemPrompt, + tools: mapToolDefinitions(pending?.tools), + }), + }; + + // When contentCapture is enabled, map full content with redaction; + // otherwise fall back to metadata-only result (no message content). + const result = contentCapture + ? mapGeneration(assistantMsg, pending?.userParts ?? [], assistantParts, redactor) + : mapGeneration(assistantMsg, [], [], redactor); + + try { + if (assistantMsg.error) { + await sigil.startGeneration(seed, async (recorder) => { + recorder.setResult(result); + recorder.setCallError(mapError(assistantMsg.error!)); + }); + } else { + await sigil.startGeneration(seed, async (recorder) => { + recorder.setResult(result); + }); + } + } catch { + // Sigil recording failure should never break the plugin + } + + // Clean up pending generation + pendingGenerations.delete(assistantMsg.sessionID); +} + +async function handleLifecycle( + sigil: SigilClient, + event: { type: string; properties: unknown }, +): Promise { + const type = event.type as string; + + if (type === "session.idle") { + try { + await sigil.flush(); + } catch { + // flush failure is non-fatal + } + } + + if (type === "session.deleted") { + const properties = event.properties as { info?: { id?: string } } | undefined; + const sessionId = properties?.info?.id; + if (sessionId) { + recordedMessages.delete(sessionId); + pendingGenerations.delete(sessionId); + } + } + + if (type === "global.disposed") { + try { + await sigil.shutdown(); + } catch { + // shutdown failure is non-fatal + } + } +} + +export type SigilHooks = { + event: (input: { event: { type: string; properties: unknown } }) => Promise; + chatMessage: ( + input: { sessionID: string }, + output: { message: UserMessage; parts: Part[] }, + ) => void; +}; + +export async function createSigilHooks( + config: SigilConfig, + client: OpencodeClient, +): Promise { + if (!config.enabled) return null; + + if (!config.endpoint) { + console.warn("[sigil] endpoint is required when enabled -- skipping Sigil initialization"); + return null; + } + + const sigil = createSigilClient(config); + if (!sigil) return null; + + const redactor = new Redactor(); + + process.on("beforeExit", () => { + sigil.shutdown().catch(() => {}); + }); + + return { + event: async (input) => { + await handleEvent(sigil, config, client, redactor, input.event); + await handleLifecycle(sigil, input.event); + }, + chatMessage: (input, output) => { + handleChatMessage(input, output); + }, + }; +} diff --git a/plugins/opencode/src/index.ts b/plugins/opencode/src/index.ts new file mode 100644 index 0000000..88e897b --- /dev/null +++ b/plugins/opencode/src/index.ts @@ -0,0 +1,22 @@ +import type { Plugin } from "@opencode-ai/plugin"; +import { loadSigilConfig } from "./config.js"; +import { createSigilHooks } from "./hooks.js"; + +export const SigilPlugin: Plugin = async ({ client }) => { + const config = await loadSigilConfig(); + if (!config.enabled) return {}; + + const hooks = await createSigilHooks(config, client); + if (!hooks) return {}; + + return { + "chat.message": async (input, output) => { + hooks.chatMessage(input, output); + }, + event: async ({ event }) => { + await hooks.event({ + event: event as { type: string; properties: unknown }, + }); + }, + }; +}; diff --git a/plugins/opencode/src/mappers.test.ts b/plugins/opencode/src/mappers.test.ts new file mode 100644 index 0000000..7ffb0e7 --- /dev/null +++ b/plugins/opencode/src/mappers.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect } from "vitest"; +import { mapGeneration, mapInputMessages, mapOutputMessages, mapToolDefinitions } from "./mappers.js"; +import { Redactor } from "./redact.js"; +import type { AssistantMessage, Part } from "@opencode-ai/sdk"; + +const redactor = new Redactor(); + +function makeAssistantMsg(overrides?: Partial): AssistantMessage { + return { + id: "msg-1", + sessionID: "sess-1", + role: "assistant", + parentID: "parent-1", + modelID: "claude-opus-4-20250514", + providerID: "anthropic", + mode: "code", + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0.01, + tokens: { input: 100, output: 50, reasoning: 10, cache: { read: 5, write: 3 } }, + time: { created: Date.now(), completed: Date.now() + 1000 }, + finish: "end_turn", + ...overrides, + } as AssistantMessage; +} + +describe("mapInputMessages", () => { + it("maps TextParts to Sigil user messages", () => { + const parts = [ + { id: "p1", sessionID: "s1", messageID: "m1", type: "text" as const, text: "hello world" }, + ] as Part[]; + const result = mapInputMessages(parts); + expect(result).toHaveLength(1); + expect(result[0].role).toBe("user"); + expect(result[0].parts?.[0]).toEqual({ type: "text", text: "hello world" }); + }); + + it("skips non-text parts", () => { + const parts = [ + { id: "p1", sessionID: "s1", messageID: "m1", type: "file" as const, mime: "image/png", url: "..." }, + ] as Part[]; + expect(mapInputMessages(parts)).toHaveLength(0); + }); + + it("skips text parts with empty or whitespace-only text", () => { + const parts = [ + { id: "p1", sessionID: "s1", messageID: "m1", type: "text" as const, text: "" }, + { id: "p2", sessionID: "s1", messageID: "m1", type: "text" as const, text: " " }, + { id: "p3", sessionID: "s1", messageID: "m1", type: "text" as const, text: "\n\t" }, + { id: "p4", sessionID: "s1", messageID: "m1", type: "text" as const, text: "hello" }, + ] as Part[]; + const result = mapInputMessages(parts); + expect(result).toHaveLength(1); + expect(result[0].parts?.[0]).toEqual({ type: "text", text: "hello" }); + }); +}); + +describe("mapOutputMessages", () => { + it("maps TextParts with lightweight redaction", () => { + const parts = [ + { id: "p1", sessionID: "s1", messageID: "m1", type: "text" as const, text: "The result is 42" }, + ] as Part[]; + const result = mapOutputMessages(parts, redactor); + expect(result).toHaveLength(1); + expect(result[0].role).toBe("assistant"); + expect(result[0].parts?.[0]).toEqual({ type: "text", text: "The result is 42" }); + }); + + it("redacts secrets in tool output but not in assistant text (lightweight)", () => { + const secretToken = "glc_abcdefghijklmnopqrstuvwxyz1234"; + const textParts = [ + { id: "p1", sessionID: "s1", messageID: "m1", type: "text" as const, text: `Found token: ${secretToken}` }, + ] as Part[]; + const result = mapOutputMessages(textParts, redactor); + // Tier 1 patterns fire even in lightweight mode + expect(result[0].parts?.[0]).toHaveProperty("type", "text"); + const textContent = (result[0].parts?.[0] as any).text; + expect(textContent).not.toContain(secretToken); + expect(textContent).toContain("[REDACTED:"); + }); + + it("maps completed ToolParts to tool_call + tool_result with full redaction", () => { + const parts = [ + { + id: "p1", sessionID: "s1", messageID: "m1", type: "tool" as const, + callID: "call-1", tool: "bash", + state: { + status: "completed" as const, + input: { command: "echo test" }, + output: "test output", + title: "Run bash", + metadata: {}, + time: { start: 1000, end: 2000 }, + }, + }, + ] as Part[]; + const result = mapOutputMessages(parts, redactor); + expect(result).toHaveLength(2); + expect(result[0].role).toBe("assistant"); + expect(result[0].parts?.[0].type).toBe("tool_call"); + expect(result[1].role).toBe("tool"); + expect(result[1].parts?.[0].type).toBe("tool_result"); + }); + + it("skips text parts with empty or whitespace-only text", () => { + const parts = [ + { id: "p1", sessionID: "s1", messageID: "m1", type: "text" as const, text: "" }, + { id: "p2", sessionID: "s1", messageID: "m1", type: "text" as const, text: " " }, + { id: "p3", sessionID: "s1", messageID: "m1", type: "text" as const, text: "actual content" }, + ] as Part[]; + const result = mapOutputMessages(parts, redactor); + expect(result).toHaveLength(1); + expect(result[0].parts?.[0]).toEqual({ type: "text", text: "actual content" }); + }); + + it("skips reasoning parts with empty or whitespace-only text", () => { + const parts = [ + { id: "p1", sessionID: "s1", messageID: "m1", type: "reasoning" as const, text: "", time: { start: 1000 } }, + { id: "p2", sessionID: "s1", messageID: "m1", type: "reasoning" as const, text: " ", time: { start: 1000 } }, + { id: "p3", sessionID: "s1", messageID: "m1", type: "reasoning" as const, text: "thinking about it", time: { start: 1000 } }, + ] as Part[]; + const result = mapOutputMessages(parts, redactor); + expect(result).toHaveLength(1); + expect(result[0].parts?.[0]).toEqual({ type: "thinking", thinking: "thinking about it" }); + }); + + it("maps error ToolParts to tool_call + tool_result with is_error flag", () => { + const parts = [ + { + id: "p1", sessionID: "s1", messageID: "m1", type: "tool" as const, + callID: "call-1", tool: "bash", + state: { + status: "error" as const, + input: { command: "fail" }, + error: "command failed", + metadata: {}, + time: { start: 1000, end: 2000 }, + }, + }, + ] as Part[]; + const result = mapOutputMessages(parts, redactor); + expect(result).toHaveLength(2); + expect(result[0].role).toBe("assistant"); + expect(result[0].parts?.[0].type).toBe("tool_call"); + const toolCall = (result[0].parts?.[0] as any).toolCall; + expect(toolCall.id).toBe("call-1"); + expect(toolCall.name).toBe("bash"); + expect(result[1].role).toBe("tool"); + expect(result[1].parts?.[0].type).toBe("tool_result"); + const toolResult = (result[1].parts?.[0] as any).toolResult; + expect(toolResult.toolCallId).toBe("call-1"); + expect(toolResult.isError).toBe(true); + expect(toolResult.content).toBe("command failed"); + }); +}); + +describe("mapToolDefinitions", () => { + it("maps enabled tools to ToolDefinition array, excludes disabled", () => { + const tools = { bash: true, read: true, write: false }; + const result = mapToolDefinitions(tools); + expect(result).toHaveLength(2); + expect(result.map((t) => t.name)).toContain("bash"); + expect(result.map((t) => t.name)).toContain("read"); + expect(result.map((t) => t.name)).not.toContain("write"); + }); + + it("returns empty array for undefined", () => { + expect(mapToolDefinitions(undefined)).toEqual([]); + }); +}); + +describe("mapGeneration", () => { + it("maps usage tokens and cost from assistant message", () => { + const msg = makeAssistantMsg(); + const userParts = [ + { id: "p1", sessionID: "s1", messageID: "m1", type: "text" as const, text: "hello" }, + ] as Part[]; + const assistantParts = [ + { id: "p2", sessionID: "s1", messageID: "m2", type: "text" as const, text: "hi there" }, + ] as Part[]; + const result = mapGeneration(msg, userParts, assistantParts, redactor); + expect(result.input).toHaveLength(1); + expect(result.output).toHaveLength(1); + expect(result.usage?.inputTokens).toBe(100); + expect(result.metadata?.cost).toBe(0.01); + }); + + it("maps response model, stop reason, and completion timestamp from assistant message", () => { + const msg = makeAssistantMsg(); + const result = mapGeneration(msg, [], [], redactor); + expect(result.responseModel).toBe("claude-opus-4-20250514"); + expect(result.stopReason).toBe("end_turn"); + expect(result.completedAt).toBeInstanceOf(Date); + }); +}); diff --git a/plugins/opencode/src/mappers.ts b/plugins/opencode/src/mappers.ts new file mode 100644 index 0000000..3177655 --- /dev/null +++ b/plugins/opencode/src/mappers.ts @@ -0,0 +1,167 @@ +import type { AssistantMessage, Part } from "@opencode-ai/sdk"; +import type { + GenerationResult, + Message, + ToolDefinition, +} from "@grafana/sigil-sdk-js"; +import type { Redactor } from "./redact.js"; + +export type { GenerationResult }; + +/** + * Map user-side parts to Sigil input messages. No redaction applied — user text is the + * user's own data and Sigil needs it verbatim for prompt analysis. Tier 1 patterns in + * user text (e.g., pasted connection strings) are a known accepted gap; apply redaction + * here if this becomes a problem. + */ +export function mapInputMessages(parts: Part[]): Message[] { + const messages: Message[] = []; + for (const part of parts) { + if (part.type === "text" && part.text.trim().length > 0) { + messages.push({ + role: "user", + parts: [{ type: "text", text: part.text }], + }); + } + } + return messages; +} + +/** Map assistant-side parts to Sigil output messages with redaction. */ +export function mapOutputMessages(parts: Part[], redactor: Redactor): Message[] { + const messages: Message[] = []; + for (const part of parts) { + switch (part.type) { + case "text": { + const text = redactor.redactLightweight(part.text); + if (text.trim().length > 0) { + messages.push({ + role: "assistant", + parts: [{ type: "text", text }], + }); + } + break; + } + case "reasoning": { + const thinking = redactor.redactLightweight(part.text); + if (thinking.trim().length > 0) { + messages.push({ + role: "assistant", + parts: [{ type: "thinking", thinking }], + }); + } + break; + } + case "tool": { + const { state } = part; + if (state.status === "completed") { + messages.push({ + role: "assistant", + parts: [{ + type: "tool_call", + toolCall: { + id: part.callID, + name: part.tool, + inputJSON: redactor.redact(JSON.stringify(state.input ?? {})), + }, + }], + }); + messages.push({ + role: "tool", + parts: [{ + type: "tool_result", + toolResult: { + toolCallId: part.callID, + name: part.tool, + content: redactor.redact(state.output ?? ""), + }, + }], + }); + } else if (state.status === "error") { + messages.push({ + role: "assistant", + parts: [{ + type: "tool_call", + toolCall: { + id: part.callID, + name: part.tool, + inputJSON: redactor.redact(JSON.stringify(state.input ?? {})), + }, + }], + }); + messages.push({ + role: "tool", + parts: [{ + type: "tool_result", + toolResult: { + toolCallId: part.callID, + name: part.tool, + content: redactor.redact(state.error ?? "unknown error"), + isError: true, + }, + }], + }); + } + break; + } + } + } + return messages; +} + +/** Convert opencode tool name map to Sigil ToolDefinition array. Only includes enabled tools. */ +export function mapToolDefinitions( + tools: Record | undefined, +): ToolDefinition[] { + if (!tools) return []; + return Object.entries(tools) + .filter(([, enabled]) => enabled) + .map(([name]) => ({ name })); +} + +/** Map an AssistantMessage + parts to a Sigil GenerationResult with content. */ +export function mapGeneration( + msg: AssistantMessage, + userParts: Part[], + assistantParts: Part[], + redactor: Redactor, +): GenerationResult { + return { + input: mapInputMessages(userParts), + output: mapOutputMessages(assistantParts, redactor), + usage: { + inputTokens: msg.tokens.input, + outputTokens: msg.tokens.output, + reasoningTokens: msg.tokens.reasoning, + cacheReadInputTokens: msg.tokens.cache.read, + cacheCreationInputTokens: msg.tokens.cache.write, + }, + responseModel: msg.modelID, + stopReason: msg.finish, + completedAt: msg.time.completed ? new Date(msg.time.completed) : undefined, + metadata: { + cost: msg.cost, + }, + }; +} + +export function mapError( + error: NonNullable, +): Error { + switch (error.name) { + case "ProviderAuthError": + return new Error("provider_auth"); + case "APIError": + return new Error(`api_error: ${error.data.statusCode ?? "unknown"}`); + case "MessageOutputLengthError": + return new Error("output_length_exceeded"); + case "MessageAbortedError": + return new Error("aborted"); + case "UnknownError": + return new Error("unknown_error"); + default: { + const _exhaustive: never = error; + return new Error("unknown_error"); + } + } +} diff --git a/plugins/opencode/src/redact.test.ts b/plugins/opencode/src/redact.test.ts new file mode 100644 index 0000000..5aea6ee --- /dev/null +++ b/plugins/opencode/src/redact.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from "vitest"; +import { Redactor } from "./redact.js"; + +describe("Redactor", () => { + const redactor = new Redactor(); + + describe("redact (full — tier 1 + tier 2)", () => { + it("redacts Grafana Cloud tokens", () => { + const input = "token: glc_abcdefghijklmnopqrstuvwxyz1234"; + const result = redactor.redact(input); + expect(result).not.toContain("glc_abcdefghijklmnopqrstuvwxyz1234"); + expect(result).toContain("[REDACTED:"); + }); + + it("redacts Grafana service account tokens", () => { + const input = "glsa_abcdefghijklmnopqrstuvwxyz1234"; + const result = redactor.redact(input); + expect(result).not.toContain("glsa_"); + expect(result).toContain("[REDACTED:"); + }); + + it("redacts AWS access keys", () => { + const input = "aws_access_key_id = AKIAIOSFODNN7REALKEY"; + const result = redactor.redact(input); + expect(result).not.toContain("AKIAIOSFODNN7REALKEY"); + expect(result).toContain("[REDACTED:"); + }); + + it("redacts GitHub personal access tokens", () => { + const input = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij"; + const result = redactor.redact(input); + expect(result).not.toContain("ghp_"); + expect(result).toContain("[REDACTED:"); + }); + + it("redacts PEM private keys", () => { + const input = `-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8PbnGy5AhEiS0C5 +-----END RSA PRIVATE KEY-----`; + const result = redactor.redact(input); + expect(result).not.toContain("MIIEpAIBAAKCAQ"); + expect(result).toContain("[REDACTED:"); + }); + + it("redacts connection strings with passwords", () => { + const input = "postgres://admin:s3cretP4ss@db.example.com:5432/mydb"; + const result = redactor.redact(input); + expect(result).not.toContain("s3cretP4ss"); + expect(result).toContain("[REDACTED:"); + }); + + it("redacts Anthropic API keys", () => { + const input = "sk-ant-api03-" + "a".repeat(93) + "AA"; + const result = redactor.redact(input); + expect(result).not.toContain("sk-ant-api03-"); + expect(result).toContain("[REDACTED:"); + }); + + it("redacts modern OpenAI project keys (sk-proj-)", () => { + const input = "sk-proj-" + "a".repeat(50); + const result = redactor.redact(input); + expect(result).not.toContain("sk-proj-"); + expect(result).toContain("[REDACTED:"); + }); + + it("redacts OpenAI service account keys (sk-svcacct-)", () => { + const input = "sk-svcacct-" + "b".repeat(50); + const result = redactor.redact(input); + expect(result).not.toContain("sk-svcacct-"); + expect(result).toContain("[REDACTED:"); + }); + + it("redacts env file secret values (tier 2)", () => { + const input = "DATABASE_PASSWORD=hunter2secret123"; + const result = redactor.redact(input); + expect(result).toContain("DATABASE_PASSWORD="); + expect(result).not.toContain("hunter2secret123"); + expect(result).toContain("[REDACTED:"); + }); + + it("redacts bearer tokens in headers", () => { + const input = 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U'; + const result = redactor.redact(input); + expect(result).toContain("[REDACTED:"); + }); + + it("does NOT redact normal text", () => { + const input = "The function returns a list of users from the database."; + expect(redactor.redact(input)).toBe(input); + }); + + it("does NOT redact UUIDs", () => { + const input = "session-id: 550e8400-e29b-41d4-a716-446655440000"; + expect(redactor.redact(input)).toBe(input); + }); + + it("handles empty string", () => { + expect(redactor.redact("")).toBe(""); + }); + + it("handles multiple secrets in one string", () => { + const input = "key=AKIAIOSFODNN7REALKEY token=glc_abcdefghijklmnopqrstuvwxyz1234"; + const result = redactor.redact(input); + expect(result).not.toContain("AKIAIOSFODNN7REALKEY"); + expect(result).not.toContain("glc_abcdefghijklmnopqrstuvwxyz1234"); + }); + }); + + describe("redactLightweight (tier 1 only)", () => { + it("redacts Grafana Cloud tokens", () => { + const input = "I found the token: glc_abcdefghijklmnopqrstuvwxyz1234"; + const result = redactor.redactLightweight(input); + expect(result).not.toContain("glc_abcdefghijklmnopqrstuvwxyz1234"); + expect(result).toContain("[REDACTED:"); + }); + + it("does NOT redact env file patterns (tier 2 only)", () => { + const input = "The file contains DATABASE_PASSWORD=hunter2secret123"; + const result = redactor.redactLightweight(input); + expect(result).toContain("hunter2secret123"); + }); + + it("does NOT redact normal text", () => { + const input = "The API key configuration is stored in the settings panel."; + expect(redactor.redactLightweight(input)).toBe(input); + }); + }); +}); diff --git a/plugins/opencode/src/redact.ts b/plugins/opencode/src/redact.ts new file mode 100644 index 0000000..76b8d94 --- /dev/null +++ b/plugins/opencode/src/redact.ts @@ -0,0 +1,102 @@ +/** + * Secret redaction engine for Sigil content capture. + * + * ~20 high-confidence patterns hand-curated from Gitleaks + * (https://github.com/gitleaks/gitleaks). Two tiers: + * - Tier 1: definite secret formats — used by both redact() and redactLightweight() + * - Tier 2: heuristic env patterns — used only by redact() + * + * Add more patterns when concrete unredacted secrets are observed. + */ + +interface SecretPattern { + id: string; + regex: RegExp; + tier: 1 | 2; +} + +// --- Tier 1: High-confidence patterns (definite secret formats) --- +const TIER1_PATTERNS: SecretPattern[] = [ + // Grafana + { id: "grafana-cloud-token", regex: /\bglc_[A-Za-z0-9_-]{20,}/g, tier: 1 }, + { id: "grafana-service-account-token", regex: /\bglsa_[A-Za-z0-9_-]{20,}/g, tier: 1 }, + // AWS + { id: "aws-access-token", regex: /\b(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16}\b/g, tier: 1 }, + // GitHub + { id: "github-pat", regex: /\bghp_[A-Za-z0-9_]{36,}/g, tier: 1 }, + { id: "github-oauth", regex: /\bgho_[A-Za-z0-9_]{36,}/g, tier: 1 }, + { id: "github-app-token", regex: /\bghs_[A-Za-z0-9_]{36,}/g, tier: 1 }, + { id: "github-fine-grained-pat", regex: /\bgithub_pat_[A-Za-z0-9_]{82}/g, tier: 1 }, + // Anthropic + { id: "anthropic-api-key", regex: /\bsk-ant-api03-[a-zA-Z0-9_-]{93}AA/g, tier: 1 }, + { id: "anthropic-admin-key", regex: /\bsk-ant-admin01-[a-zA-Z0-9_-]{93}AA/g, tier: 1 }, + // OpenAI (legacy format + modern sk-proj-/sk-svcacct- formats) + { id: "openai-api-key", regex: /\bsk-[a-zA-Z0-9]{20}T3BlbkFJ[a-zA-Z0-9]{20}/g, tier: 1 }, + { id: "openai-project-key", regex: /\bsk-proj-[a-zA-Z0-9_-]{40,}/g, tier: 1 }, + { id: "openai-svcacct-key", regex: /\bsk-svcacct-[a-zA-Z0-9_-]{40,}/g, tier: 1 }, + // GCP + { id: "gcp-api-key", regex: /\bAIza[A-Za-z0-9_-]{35}/g, tier: 1 }, + // PEM private keys + { id: "private-key", regex: /-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z ]*PRIVATE KEY-----/g, tier: 1 }, + // Connection strings with embedded credentials + { id: "connection-string", regex: /(?:postgres|mysql|mongodb|redis|amqp):\/\/[^\s'"]+@[^\s'"]+/g, tier: 1 }, + // Bearer tokens in Authorization headers + { id: "bearer-token", regex: /[Bb]earer\s+[A-Za-z0-9_.\-~+/]{20,}={0,3}/g, tier: 1 }, + // Slack tokens + { id: "slack-token", regex: /\bxox[bporas]-[A-Za-z0-9-]{10,}/g, tier: 1 }, + // Stripe keys + { id: "stripe-key", regex: /\b[sr]k_(?:live|test)_[A-Za-z0-9]{20,}/g, tier: 1 }, + // SendGrid + { id: "sendgrid-api-key", regex: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g, tier: 1 }, + // Twilio + { id: "twilio-api-key", regex: /\bSK[a-f0-9]{32}/g, tier: 1 }, + // npm tokens + { id: "npm-token", regex: /\bnpm_[A-Za-z0-9]{36}/g, tier: 1 }, + // PyPI tokens + { id: "pypi-token", regex: /\bpypi-[A-Za-z0-9_-]{50,}/g, tier: 1 }, +]; + +// --- Tier 2: Heuristic patterns (env file values) --- +const TIER2_PATTERNS: SecretPattern[] = [ + { + id: "env-secret-value", + regex: /(?<=(?:PASSWORD|SECRET|TOKEN|KEY|CREDENTIAL|API_KEY|PRIVATE_KEY|ACCESS_KEY)\s*[=:]\s*)\S+/gi, + tier: 2, + }, +]; + +/** + * Note: Pattern arrays are shared by reference across Redactor instances. + * This is safe because: (1) there's a single Redactor instance in production, + * (2) JS is single-threaded so .replace() completes synchronously, and + * (3) lastIndex is reset before each replace call. If this class is ever used + * in workers or multiple instances, clone regexes in the constructor. + */ +export class Redactor { + private tier1 = TIER1_PATTERNS; + private tier2 = TIER2_PATTERNS; + + /** Full redaction: tier 1 + tier 2. Use for tool call args and tool results. */ + redact(text: string): string { + let result = text; + for (const pattern of this.tier1) { + pattern.regex.lastIndex = 0; + result = result.replace(pattern.regex, `[REDACTED:${pattern.id}]`); + } + for (const pattern of this.tier2) { + pattern.regex.lastIndex = 0; + result = result.replace(pattern.regex, `[REDACTED:${pattern.id}]`); + } + return result; + } + + /** Lightweight redaction: tier 1 only. Use for assistant text and reasoning. */ + redactLightweight(text: string): string { + let result = text; + for (const pattern of this.tier1) { + pattern.regex.lastIndex = 0; + result = result.replace(pattern.regex, `[REDACTED:${pattern.id}]`); + } + return result; + } +} diff --git a/plugins/opencode/tsconfig.json b/plugins/opencode/tsconfig.json new file mode 100644 index 0000000..ce62ced --- /dev/null +++ b/plugins/opencode/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/plugins/opencode/vitest.config.ts b/plugins/opencode/vitest.config.ts new file mode 100644 index 0000000..ae847ff --- /dev/null +++ b/plugins/opencode/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..5ef2747 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,6556 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} + + js: + dependencies: + '@anthropic-ai/sdk': + specifier: ^0.80.0 + version: 0.80.0(zod@4.3.6) + '@google/adk': + specifier: ^0.5.0 + version: 0.5.0(ee6095569807c0f2faf9175cb5eca775) + '@google/genai': + specifier: ^1.41.0 + version: 1.47.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)) + '@grpc/grpc-js': + specifier: ^1.14.1 + version: 1.14.3 + '@grpc/proto-loader': + specifier: ^0.8.0 + version: 0.8.0 + '@langchain/core': + specifier: ^1.0.0 + version: 1.1.38(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(openai@6.33.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) + '@langchain/langgraph': + specifier: ^1.2.0 + version: 1.2.6(@langchain/core@1.1.38(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(openai@6.33.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6) + '@openai/agents': + specifier: ^0.8.0 + version: 0.8.2(@cfworker/json-schema@4.1.1)(ws@8.20.0)(zod@4.3.6) + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.1 + '@opentelemetry/exporter-metrics-otlp-grpc': + specifier: ^0.213.0 + version: 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': + specifier: ^0.213.0 + version: 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-grpc': + specifier: ^0.213.0 + version: 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': + specifier: ^0.213.0 + version: 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': + specifier: ^2.1.0 + version: 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': + specifier: ^2.5.0 + version: 2.6.1(@opentelemetry/api@1.9.1) + llamaindex: + specifier: ^0.12.1 + version: 0.12.1(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(hono@4.12.9)(tree-sitter@0.22.4)(web-tree-sitter@0.24.7)(zod@4.3.6) + openai: + specifier: ^6.27.0 + version: 6.33.0(ws@8.20.0)(zod@4.3.6) + devDependencies: + '@opentelemetry/context-async-hooks': + specifier: ^2.6.0 + version: 2.6.1(@opentelemetry/api@1.9.1) + '@types/node': + specifier: ^24.11.0 + version: 24.12.0 + typescript: + specifier: ^6.0.0 + version: 6.0.2 + + plugins/opencode: + devDependencies: + '@grafana/sigil-sdk-js': + specifier: workspace:* + version: link:../../js + '@opencode-ai/plugin': + specifier: ^1.3.0 + version: 1.3.13 + '@opencode-ai/sdk': + specifier: ^1.3.2 + version: 1.3.13 + '@types/node': + specifier: ^24.0.0 + version: 24.12.0 + esbuild: + specifier: ^0.27.3 + version: 0.27.4 + typescript: + specifier: ^6.0.0 + version: 6.0.2 + vitest: + specifier: ^4.1.0 + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.4)) + +packages: + + '@a2a-js/sdk@0.3.13': + resolution: {integrity: sha512-BZr0f9JVNQs3GKOM9xINWCh6OKIJWZFPyqqVqTym5mxO2Eemc6I/0zL7zWnljHzGdaf5aZQyQN5xa6PSH62q+A==} + engines: {node: '>=18'} + peerDependencies: + '@bufbuild/protobuf': ^2.10.2 + '@grpc/grpc-js': ^1.11.0 + express: ^4.21.2 || ^5.1.0 + peerDependenciesMeta: + '@bufbuild/protobuf': + optional: true + '@grpc/grpc-js': + optional: true + express: + optional: true + + '@anthropic-ai/sdk@0.80.0': + resolution: {integrity: sha512-WeXLn7zNVk3yjeshn+xZHvld6AoFUOR3Sep6pSoHho5YbSi6HwcirqgPA5ccFuW8QTVJAAU7N8uQQC6Wa9TG+g==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/types@3.973.6': + resolution: {integrity: sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==} + engines: {node: '>=20.0.0'} + + '@azure-rest/core-client@2.5.1': + resolution: {integrity: sha512-EHaOXW0RYDKS5CFffnixdyRPak5ytiCtU7uXDcP/uiY+A6jFRwNGzzJBiznkCzvi5EYpY+YWinieqHb0oY916A==} + engines: {node: '>=20.0.0'} + + '@azure/abort-controller@2.1.2': + resolution: {integrity: sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==} + engines: {node: '>=18.0.0'} + + '@azure/core-auth@1.10.1': + resolution: {integrity: sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==} + engines: {node: '>=20.0.0'} + + '@azure/core-client@1.10.1': + resolution: {integrity: sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==} + engines: {node: '>=20.0.0'} + + '@azure/core-http-compat@2.3.2': + resolution: {integrity: sha512-Tf6ltdKzOJEgxZeWLCjMxrxbodB/ZeCbzzA1A2qHbhzAjzjHoBVSUeSl/baT/oHAxhc4qdqVaDKnc2+iE932gw==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@azure/core-client': ^1.10.0 + '@azure/core-rest-pipeline': ^1.22.0 + + '@azure/core-lro@2.7.2': + resolution: {integrity: sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==} + engines: {node: '>=18.0.0'} + + '@azure/core-paging@1.6.2': + resolution: {integrity: sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==} + engines: {node: '>=18.0.0'} + + '@azure/core-rest-pipeline@1.23.0': + resolution: {integrity: sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==} + engines: {node: '>=20.0.0'} + + '@azure/core-tracing@1.3.1': + resolution: {integrity: sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==} + engines: {node: '>=20.0.0'} + + '@azure/core-util@1.13.1': + resolution: {integrity: sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==} + engines: {node: '>=20.0.0'} + + '@azure/identity@4.13.1': + resolution: {integrity: sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==} + engines: {node: '>=20.0.0'} + + '@azure/keyvault-common@2.0.0': + resolution: {integrity: sha512-wRLVaroQtOqfg60cxkzUkGKrKMsCP6uYXAOomOIysSMyt1/YM0eUn9LqieAWM8DLcU4+07Fio2YGpPeqUbpP9w==} + engines: {node: '>=18.0.0'} + + '@azure/keyvault-keys@4.10.0': + resolution: {integrity: sha512-eDT7iXoBTRZ2n3fLiftuGJFD+yjkiB1GNqzU2KbY1TLYeXeSPVTVgn2eJ5vmRTZ11978jy2Kg2wI7xa9Tyr8ag==} + engines: {node: '>=18.0.0'} + + '@azure/logger@1.3.0': + resolution: {integrity: sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==} + engines: {node: '>=20.0.0'} + + '@azure/msal-browser@5.6.2': + resolution: {integrity: sha512-ZgcN9ToRJ80f+wNPBBKYJ+DG0jlW7ktEjYtSNkNsTrlHVMhKB8tKMdI1yIG1I9BJtykkXtqnuOjlJaEMC7J6aw==} + engines: {node: '>=0.8.0'} + + '@azure/msal-common@16.4.0': + resolution: {integrity: sha512-twXt09PYtj1PffNNIAzQlrBd0DS91cdA6i1gAfzJ6BnPM4xNk5k9q/5xna7jLIjU3Jnp0slKYtucshGM8OGNAw==} + engines: {node: '>=0.8.0'} + + '@azure/msal-node@5.1.1': + resolution: {integrity: sha512-71grXU6+5hl+3CL3joOxlj/AW6rmhthuTlG0fRqsTrhPArQBpZuUFzCIlKOGdcafLUa/i1hBdV78ZxJdlvRA+g==} + engines: {node: '>=20'} + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@cfworker/json-schema@4.1.1': + resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + + '@colors/colors@1.6.0': + resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} + engines: {node: '>=0.1.90'} + + '@dabh/diagnostics@2.0.8': + resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} + + '@emnapi/core@1.9.1': + resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} + + '@emnapi/runtime@1.9.1': + resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + + '@emnapi/wasi-threads@1.2.0': + resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@finom/zod-to-json-schema@3.24.11': + resolution: {integrity: sha512-fL656yBPiWebtfGItvtXLWrFNGlF1NcDFS0WdMQXMs9LluVg0CfT5E2oXYp0pidl0vVG53XkW55ysijNkU5/hA==} + deprecated: 'Use https://www.npmjs.com/package/zod-v3-to-json-schema instead. See issue comment for details: https://github.com/StefanTerdell/zod-to-json-schema/issues/178#issuecomment-3533122539' + peerDependencies: + zod: ^4.0.14 + + '@gar/promisify@1.1.3': + resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} + + '@google-cloud/opentelemetry-cloud-monitoring-exporter@0.21.0': + resolution: {integrity: sha512-+lAew44pWt6rA4l8dQ1gGhH7Uo95wZKfq/GBf9aEyuNDDLQ2XppGEEReu6ujesSqTtZ8ueQFt73+7SReSHbwqg==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/core': ^2.0.0 + '@opentelemetry/resources': ^2.0.0 + '@opentelemetry/sdk-metrics': ^2.0.0 + + '@google-cloud/opentelemetry-cloud-trace-exporter@3.0.0': + resolution: {integrity: sha512-mUfLJBFo+ESbO0dAGboErx2VyZ7rbrHcQvTP99yH/J72dGaPbH2IzS+04TFbTbEd1VW5R9uK3xq2CqawQaG+1Q==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@opentelemetry/core': ^2.0.0 + '@opentelemetry/resources': ^2.0.0 + '@opentelemetry/sdk-trace-base': ^2.0.0 + + '@google-cloud/opentelemetry-resource-util@3.0.0': + resolution: {integrity: sha512-CGR/lNzIfTKlZoZFfS6CkVzx+nsC9gzy6S8VcyaLegfEJbiPjxbMLP7csyhJTvZe/iRRcQJxSk0q8gfrGqD3/Q==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/core': ^2.0.0 + '@opentelemetry/resources': ^2.0.0 + + '@google-cloud/paginator@5.0.2': + resolution: {integrity: sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==} + engines: {node: '>=14.0.0'} + + '@google-cloud/precise-date@4.0.0': + resolution: {integrity: sha512-1TUx3KdaU3cN7nfCdNf+UVqA/PSX29Cjcox3fZZBtINlRrXVTmUkQnCKv2MbBUbCopbK4olAT1IHl76uZyCiVA==} + engines: {node: '>=14.0.0'} + + '@google-cloud/projectify@4.0.0': + resolution: {integrity: sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==} + engines: {node: '>=14.0.0'} + + '@google-cloud/promisify@4.0.0': + resolution: {integrity: sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==} + engines: {node: '>=14'} + + '@google-cloud/storage@7.19.0': + resolution: {integrity: sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==} + engines: {node: '>=14'} + + '@google/adk@0.5.0': + resolution: {integrity: sha512-VQbiGayQewKm3IXJIUX2iq+eqY7WHd71MOT0XwPJTXnViuo3/ysiwxCQ2D8sxAGQt+fol8YHaHxBUkoIpqOBzA==} + peerDependencies: + '@google-cloud/opentelemetry-cloud-monitoring-exporter': ^0.21.0 + '@google-cloud/opentelemetry-cloud-trace-exporter': ^3.0.0 + '@google-cloud/storage': ^7.17.1 + '@mikro-orm/mariadb': ^6.6.6 + '@mikro-orm/mssql': ^6.6.6 + '@mikro-orm/mysql': ^6.6.6 + '@mikro-orm/postgresql': ^6.6.6 + '@mikro-orm/sqlite': ^6.6.6 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': ^0.205.0 + '@opentelemetry/exporter-logs-otlp-http': ^0.205.0 + '@opentelemetry/exporter-metrics-otlp-http': ^0.205.0 + '@opentelemetry/exporter-trace-otlp-http': ^0.205.0 + '@opentelemetry/resource-detector-gcp': ^0.40.0 + '@opentelemetry/resources': ^2.1.0 + '@opentelemetry/sdk-logs': ^0.205.0 + '@opentelemetry/sdk-metrics': ^2.1.0 + '@opentelemetry/sdk-trace-base': ^2.1.0 + '@opentelemetry/sdk-trace-node': ^2.1.0 + + '@google/genai@1.47.0': + resolution: {integrity: sha512-0VV7AaXm5rQu3oRHNZNEubRAOL2lv5u+YA72eWnDwcOx3B1jFRbvtgL4drRHlocRHOnludvr3xmbQGbR+/RQAQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + + '@grpc/grpc-js@1.14.3': + resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.0': + resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} + engines: {node: '>=6'} + hasBin: true + + '@hono/node-server@1.19.12': + resolution: {integrity: sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@js-joda/core@5.7.0': + resolution: {integrity: sha512-WBu4ULVVxySLLzK1Ppq+OdfP+adRS4ntmDQT915rzDJ++i95gc2jZkM5B6LWEAwN3lGXpfie3yPABozdD3K3Vg==} + + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + + '@langchain/core@1.1.38': + resolution: {integrity: sha512-C340wH1YL10CiVOFlEpQMp0zQE85/eBLKX/gi1Lv7shAyUmR3CQ0t/mXlCd5RsZa6ntAN1kDJnp64ArWey9XAA==} + engines: {node: '>=20'} + + '@langchain/langgraph-checkpoint@1.0.1': + resolution: {integrity: sha512-HM0cJLRpIsSlWBQ/xuDC67l52SqZ62Bh2Y61DX+Xorqwoh5e1KxYvfCD7GnSTbWWhjBOutvnR0vPhu4orFkZfw==} + engines: {node: '>=18'} + peerDependencies: + '@langchain/core': ^1.0.1 + + '@langchain/langgraph-sdk@1.8.3': + resolution: {integrity: sha512-Py0S5yVtlOHz410aEsSGLRKjtsK2giDvfPS1JjAjTdcs71khuJufFtUZFwmwdJCbsG4DaGurRLHOAJu9jO4a0g==} + peerDependencies: + '@langchain/core': ^1.1.16 + react: ^18 || ^19 + react-dom: ^18 || ^19 + svelte: ^4.0.0 || ^5.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@langchain/core': + optional: true + react: + optional: true + react-dom: + optional: true + svelte: + optional: true + vue: + optional: true + + '@langchain/langgraph@1.2.6': + resolution: {integrity: sha512-5cX402dNGN6w9+0mlMU2dgUiKx6Y1tlENp7x05e9ByDbQCHSDc0kyqRWNFLGc7vatQ92S4ylxQrcCJvi8Fr4SQ==} + engines: {node: '>=18'} + peerDependencies: + '@langchain/core': ^1.1.16 + zod: ^3.25.32 || ^4.2.0 + zod-to-json-schema: ^3.x + peerDependenciesMeta: + zod-to-json-schema: + optional: true + + '@llamaindex/core@0.6.22': + resolution: {integrity: sha512-/BXyemkvpxMaUhOkbwJ2PTvzKjSWkL8+6QLpz/n+pk8xBwMMe1GVBgli/J57gCyi8GbrlBafBj6GaPOgWub2Eg==} + + '@llamaindex/env@0.1.30': + resolution: {integrity: sha512-y6kutMcCevzbmexUgz+HXf7KiZemzAoFEYSjAILfR+cG6FmYSF8XvLbGOB34Kx8mlRi7EI8rZXpezJ5qCqOyZg==} + peerDependencies: + '@huggingface/transformers': ^3.5.0 + gpt-tokenizer: ^2.5.0 + peerDependenciesMeta: + '@huggingface/transformers': + optional: true + gpt-tokenizer: + optional: true + + '@llamaindex/node-parser@2.0.22': + resolution: {integrity: sha512-uj5O89WShAAyiSZ8f8tU7hnLJ6pSmlY2a6hkAOs8odkUgT87dEqaPHpsK7w0iJdEFiob7GoLeRhv2K624FooXg==} + peerDependencies: + '@llamaindex/core': 0.6.22 + '@llamaindex/env': 0.1.30 + tree-sitter: ^0.22.0 + web-tree-sitter: ^0.24.3 + + '@llamaindex/workflow-core@1.3.4': + resolution: {integrity: sha512-nDQ61VEYY5lTJFLHZzN7swdpbYrkoqLHKmct/KXF0wZN2Ih7sB4jk9eaVqWbd6zmkkOADT84I6eSd7hSY9kurg==} + deprecated: 'This package is deprecated. Please use LlamaAgents (Python Workflows) instead: https://developers.llamaindex.ai/python/llamaagents/overview/' + peerDependencies: + '@modelcontextprotocol/sdk': ^1.7.0 + hono: ^4.7.4 + next: ^15.2.2 + p-retry: ^6.2.1 + rxjs: ^7.8.2 + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + hono: + optional: true + next: + optional: true + p-retry: + optional: true + rxjs: + optional: true + zod: + optional: true + + '@llamaindex/workflow@1.1.24': + resolution: {integrity: sha512-VyKsbRkFlnT5dRNKbgLXQV+ZpQ+CAFgmC9LaZv6hD/fIKo6wq1wQW/ZqLZgZt569xeHgxmrXPB6KHdqn/AhPbQ==} + peerDependencies: + '@llamaindex/core': 0.6.22 + '@llamaindex/env': 0.1.30 + + '@mikro-orm/core@6.6.11': + resolution: {integrity: sha512-+edc3ctapRi0lyb2B0+QfUpoWkNmXOcaApDT6RhBxyFo74bpoU/tEb9aMobemN86VhAt/rjM1KDKbJYLM9lxTg==} + engines: {node: '>= 18.12.0'} + + '@mikro-orm/knex@6.6.11': + resolution: {integrity: sha512-MUxqw+3COpcM06DC3ufW4Aov5RZWpW1Rv/kMfJkHQX+bO81jPdinXkRtx1l8EVWFRiLJEB+3MNhptFQRlmJNXA==} + engines: {node: '>= 18.12.0'} + peerDependencies: + '@mikro-orm/core': ^6.0.0 + better-sqlite3: '*' + libsql: '*' + mariadb: '*' + peerDependenciesMeta: + better-sqlite3: + optional: true + libsql: + optional: true + mariadb: + optional: true + + '@mikro-orm/mariadb@6.6.11': + resolution: {integrity: sha512-TPUFGJHGPGiQC2LE263iyyBbaG1nwSsa6UVQ8ma2QFxLRt62XqGFEw7XJ1uUXXoqZn/4RW8jrIAWFgbrmfnx3g==} + engines: {node: '>= 18.12.0'} + peerDependencies: + '@mikro-orm/core': ^6.0.0 + + '@mikro-orm/mssql@6.6.11': + resolution: {integrity: sha512-LjMiObzrwKJw9Pt/VUgAgsNU3j/FDZjW8wxvD8522rPdXnNyHtNBDAQQhdWt/e+yJr4bZh7HhoQYekH8Z9G6Yg==} + engines: {node: '>= 18.12.0'} + peerDependencies: + '@mikro-orm/core': ^6.0.0 + + '@mikro-orm/mysql@6.6.11': + resolution: {integrity: sha512-SPtBLl82Qq+pKx/d5rF276LosKz6JO7D8vTaeudadk6/zlXjpE3SciGmyvt5/+htzts4k348F8zQMCf297NdzQ==} + engines: {node: '>= 18.12.0'} + peerDependencies: + '@mikro-orm/core': ^6.0.0 + + '@mikro-orm/postgresql@6.6.11': + resolution: {integrity: sha512-YIQroXsAPXRJc3ruk8M5ynbQEQtGUO0Swjb/MMtjn5o9qypqmPBoq4ANCwUY9P2jVlmheQM1O5VK/1OBm7/EVg==} + engines: {node: '>= 18.12.0'} + peerDependencies: + '@mikro-orm/core': ^6.0.0 + + '@mikro-orm/reflection@6.6.11': + resolution: {integrity: sha512-EG8C79sOzkvqiI1Kvig2TO1ME1YlhxVGLDQaKQur2xUIR31U0cmjWIWd449lCD4mLdrUj1sem7WULLmo2tj7UA==} + engines: {node: '>= 18.12.0'} + peerDependencies: + '@mikro-orm/core': ^6.0.0 + + '@mikro-orm/sqlite@6.6.11': + resolution: {integrity: sha512-WCO9w6JERp7qMRJKXoNF1ELrQ6PrMBU24EwDdhkY8LH76uqDM4jtfSbIcBDafORiZG/D+Rs8JshS1qEQEX9x7w==} + engines: {node: '>= 18.12.0'} + peerDependencies: + '@mikro-orm/core': ^6.0.0 + + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@napi-rs/wasm-runtime@1.1.2': + resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@npmcli/fs@1.1.1': + resolution: {integrity: sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==} + + '@npmcli/move-file@1.1.2': + resolution: {integrity: sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==} + engines: {node: '>=10'} + deprecated: This functionality has been moved to @npmcli/fs + + '@openai/agents-core@0.8.2': + resolution: {integrity: sha512-oxp8XmdFcZwturpfWzqpW/2doNJ75FHwYDfZdBxfSK4Q2vmwLsx9wNPmU67i8dubWUXmIzOSXNUCbdL6+iLNlg==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@openai/agents-openai@0.8.2': + resolution: {integrity: sha512-h81JngBj2EcFDPDTnlKZqWp6JZN/SdR4MPR+f6NKYpqNjDWwJQGpq+w4lguLORUrZmXKWwy7xSZwAJzPB0EoCQ==} + peerDependencies: + zod: ^4.0.0 + + '@openai/agents-realtime@0.8.2': + resolution: {integrity: sha512-aY1BGOkhpbb+aq+bU3OkwRMr/CXEc0LZxPFw7vn28NTcVWn2rKdeDEkY2k1EbUv35vk5wo8bwI2LJkQfm+d6uw==} + peerDependencies: + zod: ^4.0.0 + + '@openai/agents@0.8.2': + resolution: {integrity: sha512-chxJPncuVbOqAUUpxUuVnT2tZTIr82hD9eVA59GaNzM0uG13fjaiIYgbNpWpAz9w5jQh84HMybWzXL9QNp7daA==} + peerDependencies: + zod: ^4.0.0 + + '@opencode-ai/plugin@1.3.13': + resolution: {integrity: sha512-zHgtWfdDz8Wu8srE8f8HUtPT9i6c3jTmgQKoFZUZ+RR5CMQF1kAlb1cxeEe9Xm2DRNFVJog9Cv/G1iUHYgXSUQ==} + peerDependencies: + '@opentui/core': '>=0.1.95' + '@opentui/solid': '>=0.1.95' + peerDependenciesMeta: + '@opentui/core': + optional: true + '@opentui/solid': + optional: true + + '@opencode-ai/sdk@1.3.13': + resolution: {integrity: sha512-/M6HlNnba+xf1EId6qFb2tG0cvq0db3PCQDug1glrf8wYOU57LYNF8WvHX9zoDKPTMv0F+O4pcP/8J+WvDaxHA==} + + '@opentelemetry/api-logs@0.205.0': + resolution: {integrity: sha512-wBlPk1nFB37Hsm+3Qy73yQSobVn28F4isnWIBvKpd5IUH/eat8bwcL02H9yzmHyyPmukeccSl2mbN5sDQZYnPg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api-logs@0.213.0': + resolution: {integrity: sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/context-async-hooks@2.6.1': + resolution: {integrity: sha512-XHzhwRNkBpeP8Fs/qjGrAf9r9PRv67wkJQ/7ZPaBQQ68DYlTBBx5MF9LvPx7mhuXcDessKK2b+DcxqwpgkcivQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.1.0': + resolution: {integrity: sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.6.0': + resolution: {integrity: sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.6.1': + resolution: {integrity: sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-http@0.205.0': + resolution: {integrity: sha512-5JteMyVWiro4ghF0tHQjfE6OJcF7UBUcoEqX3UIQ5jutKP1H+fxFdyhqjjpmeHMFxzOHaYuLlNR1Bn7FOjGyJg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-grpc@0.213.0': + resolution: {integrity: sha512-Z8gYKUAU48qwm+a1tjnGv9xbE7a5lukVIwgF6Z5i3VPXPVMe4Sjra0nN3zU7m277h+V+ZpsPGZJ2Xf0OTkL7/w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.213.0': + resolution: {integrity: sha512-yw3fTIw4KQIRXC/ZyYQq5gtA3Ogfdfz/g5HVgleobQAcjUUE8Nj3spGMx8iQPp+S+u6/js7BixufRkXhzLmpJA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.213.0': + resolution: {integrity: sha512-L8y6piP4jBIIx1Nv7/9hkx25ql6/Cro/kQrs+f9e8bPF0Ar5Dm991v7PnbtubKz6Q4fT872H56QXUWVnz/Cs4Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-http@0.213.0': + resolution: {integrity: sha512-tnRmJD39aWrE/Sp7F6AbRNAjKHToDkAqBi6i0lESpGWz3G+f4bhVAV6mgSXH2o18lrDVJXo6jf9bAywQw43wRA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.205.0': + resolution: {integrity: sha512-2MN0C1IiKyo34M6NZzD6P9Nv9Dfuz3OJ3rkZwzFmF6xzjDfqqCTatc9v1EpNfaP55iDOCLHFyYNCgs61FFgtUQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.213.0': + resolution: {integrity: sha512-MegxAP1/n09Ob2dQvY5NBDVjAFkZRuKtWKxYev1R2M8hrsgXzQGkaMgoEKeUOyQ0FUyYcO29UOnYdQWmWa0PXg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-grpc-exporter-base@0.213.0': + resolution: {integrity: sha512-XgRGuLE9usFNlnw2lgMIM4HTwpcIyjdU/xPoJ8v3LbBLBfjaDkIugjc9HoWa7ZSJ/9Bhzgvm/aD0bGdYUFgnTw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.205.0': + resolution: {integrity: sha512-KmObgqPtk9k/XTlWPJHdMbGCylRAmMJNXIRh6VYJmvlRDMfe+DonH41G7eenG8t4FXn3fxOGh14o/WiMRR6vPg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.213.0': + resolution: {integrity: sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resource-detector-gcp@0.40.3': + resolution: {integrity: sha512-C796YjBA5P1JQldovApYfFA/8bQwFfpxjUbOtGhn1YZkVTLoNQN+kvBwgALfTPWzug6fWsd0xhn9dzeiUcndag==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resources@2.1.0': + resolution: {integrity: sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.6.0': + resolution: {integrity: sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.6.1': + resolution: {integrity: sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.205.0': + resolution: {integrity: sha512-nyqhNQ6eEzPWQU60Nc7+A5LIq8fz3UeIzdEVBQYefB4+msJZ2vuVtRuk9KxPMw1uHoHDtYEwkr2Ct0iG29jU8w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.213.0': + resolution: {integrity: sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.1.0': + resolution: {integrity: sha512-J9QX459mzqHLL9Y6FZ4wQPRZG4TOpMCyPOh6mkr/humxE1W2S3Bvf4i75yiMW9uyed2Kf5rxmLhTm/UK8vNkAw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.6.0': + resolution: {integrity: sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.6.1': + resolution: {integrity: sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.1.0': + resolution: {integrity: sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.6.0': + resolution: {integrity: sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.6.1': + resolution: {integrity: sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.6.1': + resolution: {integrity: sha512-Hh2i4FwHWRFhnO2Q/p6svMxy8MPsNCG0uuzUY3glqm0rwM0nQvbTO1dXSp9OqQoTKXcQzaz9q1f65fsurmOhNw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.40.0': + resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} + engines: {node: '>=14'} + + '@oxc-project/types@0.122.0': + resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + + '@rolldown/binding-android-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.12': + resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': + resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': + resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': + resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.12': + resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==} + + '@selderee/plugin-htmlparser2@0.11.0': + resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/types@4.13.1': + resolution: {integrity: sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@so-ric/colorspace@1.1.6': + resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tootallnate/once@1.1.2': + resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} + engines: {node: '>= 6'} + + '@tootallnate/once@2.0.0': + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + + '@ts-morph/common@0.28.1': + resolution: {integrity: sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/caseless@0.12.5': + resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/lodash@4.17.24': + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + + '@types/readable-stream@4.0.23': + resolution: {integrity: sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==} + + '@types/request@2.48.13': + resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==} + + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/triple-beam@1.3.5': + resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@typespec/ts-http-runtime@0.3.4': + resolution: {integrity: sha512-CI0NhTrz4EBaa0U+HaaUZrJhPoso8sG7ZFya8uQoBA57fjzrjRSv87ekCjLZOFExN+gXE/z0xuN2QfH4H2HrLQ==} + engines: {node: '>=20.0.0'} + + '@vitest/expect@4.1.2': + resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==} + + '@vitest/mocker@4.1.2': + resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.2': + resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==} + + '@vitest/runner@4.1.2': + resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==} + + '@vitest/snapshot@4.1.2': + resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==} + + '@vitest/spy@4.1.2': + resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==} + + '@vitest/utils@4.1.2': + resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} + + abbrev@1.1.1: + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aproba@2.1.0: + resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==} + + are-we-there-yet@3.0.1: + resolution: {integrity: sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + arrify@2.0.1: + resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==} + engines: {node: '>=8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + bl@6.1.6: + resolution: {integrity: sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==} + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + brace-expansion@1.1.13: + resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cacache@15.3.0: + resolution: {integrity: sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==} + engines: {node: '>= 10'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + + clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-convert@3.1.3: + resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} + engines: {node: '>=14.6'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-name@2.1.0: + resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} + engines: {node: '>=12.20'} + + color-string@2.1.4: + resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} + engines: {node: '>=18'} + + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + + color@5.0.3: + resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} + engines: {node: '>=18'} + + colorette@2.0.19: + resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + console-control-strings@1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + + console-table-printer@2.15.0: + resolution: {integrity: sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==} + + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + dataloader@2.2.3: + resolution: {integrity: sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==} + + debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dotenv@17.3.1: + resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + enabled@2.0.0: + resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + encoding@0.1.13: + resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + esm@3.2.25: + resolution: {integrity: sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==} + engines: {node: '>=6'} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + express-rate-limit@8.3.2: + resolution: {integrity: sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fast-xml-builder@1.1.4: + resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} + + fast-xml-parser@5.5.9: + resolution: {integrity: sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==} + hasBin: true + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fecha@4.2.3: + resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + fn.name@1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + + form-data@2.5.5: + resolution: {integrity: sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==} + engines: {node: '>= 0.12'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-extra@11.3.3: + resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} + engines: {node: '>=14.14'} + + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gauge@4.0.4: + resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + + gaxios@6.7.1: + resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} + engines: {node: '>=14'} + + gaxios@7.1.4: + resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} + engines: {node: '>=18'} + + gcp-metadata@6.1.1: + resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} + engines: {node: '>=14'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + getopts@2.3.0: + resolution: {integrity: sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + google-auth-library@10.6.2: + resolution: {integrity: sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==} + engines: {node: '>=18'} + + google-auth-library@9.15.1: + resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} + engines: {node: '>=14'} + + google-logging-utils@0.0.2: + resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} + engines: {node: '>=14'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + googleapis-common@7.2.0: + resolution: {integrity: sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==} + engines: {node: '>=14.0.0'} + + googleapis@137.1.0: + resolution: {integrity: sha512-2L7SzN0FLHyQtFmyIxrcXhgust77067pkkduqkbIpDuj9JzVnByxsRrcRfUMFQam3rQkWW2B0f1i40IwKDWIVQ==} + engines: {node: '>=14.0.0'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + gtoken@7.1.0: + resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} + engines: {node: '>=14.0.0'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + has-unicode@2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hono@4.12.9: + resolution: {integrity: sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==} + engines: {node: '>=16.9.0'} + + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + + html-to-text@9.0.5: + resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} + engines: {node: '>=14'} + + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@4.0.1: + resolution: {integrity: sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==} + engines: {node: '>= 6'} + + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + infer-owner@1.0.4: + resolution: {integrity: sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + interpret@2.2.0: + resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==} + engines: {node: '>= 0.10'} + + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-lambda@1.0.1: + resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} + + is-network-error@1.3.1: + resolution: {integrity: sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==} + engines: {node: '>=16'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + + js-md4@0.3.2: + resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==} + + js-tiktoken@1.0.21: + resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} + + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + knex@3.2.8: + resolution: {integrity: sha512-ElXXxu9Nq+5hWYdBUddYIWIT5yKKs5KNCsmKGbJSHPyaMpAABp3xs4L55GgdQoAs6QQ7dv72ai3M4pxYQ8utEg==} + engines: {node: '>=16'} + hasBin: true + peerDependencies: + better-sqlite3: '*' + mysql: '*' + mysql2: '*' + pg: '*' + pg-native: '*' + pg-query-stream: ^4.14.0 + sqlite3: '*' + tedious: '*' + peerDependenciesMeta: + better-sqlite3: + optional: true + mysql: + optional: true + mysql2: + optional: true + pg: + optional: true + pg-native: + optional: true + pg-query-stream: + optional: true + sqlite3: + optional: true + tedious: + optional: true + + kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + + langsmith@0.5.15: + resolution: {integrity: sha512-S20JnYmIgqGBjA/WEn12ZZJjqd03O5wd8K9KgGBvsKXQBn0bYuFrr1w20L37PpcMmX3/cftpgJ6g2y8KoEmHLw==} + peerDependencies: + '@opentelemetry/api': '*' + '@opentelemetry/exporter-trace-otlp-proto': '*' + '@opentelemetry/sdk-trace-base': '*' + openai: '*' + ws: '>=7' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/exporter-trace-otlp-proto': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + openai: + optional: true + ws: + optional: true + + leac@0.6.0: + resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + llamaindex@0.12.1: + resolution: {integrity: sha512-/tXXITk/iVGBycOFaDhev6dgTBIr6Ycu4FoPIt6A5JcEAiB6ujONjiV36flVXUR8JdqwMtS767XMjV+36nV4yQ==} + engines: {node: '>=18.0.0'} + + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + logform@2.7.0: + resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} + engines: {node: '>= 12.0.0'} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + + magic-bytes.js@1.13.0: + resolution: {integrity: sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + make-fetch-happen@9.1.0: + resolution: {integrity: sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==} + engines: {node: '>= 10'} + + mariadb@3.4.5: + resolution: {integrity: sha512-gThTYkhIS5rRqkVr+Y0cIdzr+GRqJ9sA2Q34e0yzmyhMCwyApf3OKAC1jnF23aSlIOqJuyaUFUcj7O1qZslmmQ==} + engines: {node: '>= 14'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mikro-orm@6.6.11: + resolution: {integrity: sha512-8z1pS5IfKGys0OR0m5bWDLbmCu7n86DXvozL9v7BYcqW6O3GbsioghmNobzl7PraOOIRy260rS+mO6Z1jLduDQ==} + engines: {node: '>= 18.12.0'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass-collect@1.0.2: + resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} + engines: {node: '>= 8'} + + minipass-fetch@1.4.1: + resolution: {integrity: sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==} + engines: {node: '>=8'} + + minipass-flush@1.0.7: + resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==} + engines: {node: '>= 8'} + + minipass-pipeline@1.2.4: + resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} + engines: {node: '>=8'} + + minipass-sized@1.0.3: + resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} + engines: {node: '>=8'} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mustache@4.2.0: + resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} + hasBin: true + + mysql2@3.20.0: + resolution: {integrity: sha512-eCLUs7BNbgA6nf/MZXsaBO1SfGs0LtLVrJD3WeWq+jPLDWkSufTD+aGMwykfUVPdZnblaUK1a8G/P63cl9FkKg==} + engines: {node: '>= 8.0'} + peerDependencies: + '@types/node': '>= 8' + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + native-duplexpair@1.0.0: + resolution: {integrity: sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-abi@3.89.0: + resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} + engines: {node: '>=10'} + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-addon-api@8.7.0: + resolution: {integrity: sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==} + engines: {node: ^18 || ^20 || >= 21} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + node-gyp@8.4.1: + resolution: {integrity: sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==} + engines: {node: '>= 10.12.0'} + hasBin: true + + nopt@5.0.0: + resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} + engines: {node: '>=6'} + hasBin: true + + npmlog@6.0.2: + resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + one-time@1.0.0: + resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + + openai@6.33.0: + resolution: {integrity: sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-queue@9.1.0: + resolution: {integrity: sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==} + engines: {node: '>=20'} + + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + + p-retry@7.1.1: + resolution: {integrity: sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==} + engines: {node: '>=20'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + + p-timeout@7.0.1: + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} + engines: {node: '>=20'} + + parseley@0.12.1: + resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-expression-matcher@1.2.0: + resolution: {integrity: sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==} + engines: {node: '>=14.0.0'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-to-regexp@8.4.1: + resolution: {integrity: sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + peberminta@0.9.0: + resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} + + pg-connection-string@2.6.2: + resolution: {integrity: sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-array@3.0.4: + resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} + engines: {node: '>=12'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-date@2.1.0: + resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==} + engines: {node: '>=12'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + postgres-interval@4.0.2: + resolution: {integrity: sha512-EMsphSQ1YkQqKZL2cuG0zHkmjCCzQqQ71l2GXITqRwjhRleCdv00bDk/ktaSi0LnlaPzAc3535KTrjXsTdtx7A==} + engines: {node: '>=12'} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + promise-inflight@1.0.1: + resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==} + peerDependencies: + bluebird: '*' + peerDependenciesMeta: + bluebird: + optional: true + + promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + + protobufjs@7.5.4: + resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + engines: {node: '>=12.0.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + rechoir@0.8.0: + resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} + engines: {node: '>= 10.13.0'} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + retry-request@7.0.2: + resolution: {integrity: sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==} + engines: {node: '>=14'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rolldown@1.0.0-rc.12: + resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + selderee@0.11.0: + resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + simple-wcswidth@1.1.2: + resolution: {integrity: sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@6.2.1: + resolution: {integrity: sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==} + engines: {node: '>= 10'} + + socks@2.8.7: + resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + + sql-escaper@1.3.3: + resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} + engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + + sqlite3@5.1.7: + resolution: {integrity: sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==} + + sqlstring-sqlite@0.1.1: + resolution: {integrity: sha512-9CAYUJ0lEUPYJrswqiqdINNSfq3jqWo/bFJ7tufdoNeSK0Fy+d1kFTxjqO9PIqza0Kri+ZtYMfPVf1aZaFOvrQ==} + engines: {node: '>= 0.6'} + + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + + ssri@8.0.1: + resolution: {integrity: sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==} + engines: {node: '>= 8'} + + stack-trace@0.0.10: + resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + + stream-events@1.0.5: + resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==} + + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strnum@2.2.2: + resolution: {integrity: sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==} + + stubs@3.0.0: + resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + tarn@3.0.2: + resolution: {integrity: sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==} + engines: {node: '>=8.0.0'} + + tedious@19.2.1: + resolution: {integrity: sha512-pk1Q16Yl62iocuQB+RWbg6rFUFkIyzqOFQ6NfysCltRvQqKwfurgj8v/f2X+CKvDhSL4IJ0cCOfCHDg9PWEEYA==} + engines: {node: '>=18.17'} + + teeny-request@9.0.0: + resolution: {integrity: sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==} + engines: {node: '>=14'} + + text-hex@1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + + tildify@2.0.0: + resolution: {integrity: sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==} + engines: {node: '>=8'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tree-sitter@0.22.4: + resolution: {integrity: sha512-usbHZP9/oxNsUY65MQUsduGRqDHQOou1cagUSwjhoSYAmSahjQDAVsh9s+SlZkn8X8+O1FULRGwHu7AFP3kjzg==} + + triple-beam@1.4.1: + resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} + engines: {node: '>= 14.0.0'} + + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + + ts-morph@27.0.2: + resolution: {integrity: sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsqlstring@1.0.1: + resolution: {integrity: sha512-6Nzj/SrVg1SF+egwP4OMAgEa83nLKXIE3EHn+6YKinMUeMj8bGIeLuDCkDC3Cc4OIM+xhw4CD0oXKxal8J/Y6A==} + engines: {node: '>= 8.0'} + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typescript@6.0.2: + resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + unique-filename@1.1.1: + resolution: {integrity: sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==} + + unique-slug@2.0.2: + resolution: {integrity: sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + url-template@2.0.8: + resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@8.0.3: + resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.2: + resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.2 + '@vitest/browser-preview': 4.1.2 + '@vitest/browser-webdriverio': 4.1.2 + '@vitest/ui': 4.1.2 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + web-tree-sitter@0.24.7: + resolution: {integrity: sha512-CdC/TqVFbXqR+C51v38hv6wOPatKEUGxa39scAeFSm98wIhZxAYonhRQPSMmfZ2w7JDI0zQDdzdmgtNk06/krQ==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wide-align@1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + + winston-transport@4.9.0: + resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} + engines: {node: '>= 12.0.0'} + + winston@3.19.0: + resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} + engines: {node: '>= 12.0.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + 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 + + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.1.8: + resolution: {integrity: sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==} + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + +snapshots: + + '@a2a-js/sdk@0.3.13(@grpc/grpc-js@1.14.3)(express@5.2.1)': + dependencies: + uuid: 11.1.0 + optionalDependencies: + '@grpc/grpc-js': 1.14.3 + express: 5.2.1 + + '@anthropic-ai/sdk@0.80.0(zod@4.3.6)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.3.6 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.6 + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.6 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/types@3.973.6': + dependencies: + '@smithy/types': 4.13.1 + tslib: 2.8.1 + + '@azure-rest/core-client@2.5.1': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-rest-pipeline': 1.23.0 + '@azure/core-tracing': 1.3.1 + '@typespec/ts-http-runtime': 0.3.4 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/abort-controller@2.1.2': + dependencies: + tslib: 2.8.1 + + '@azure/core-auth@1.10.1': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-util': 1.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-client@1.10.1': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-rest-pipeline': 1.23.0 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-http-compat@2.3.2(@azure/core-client@1.10.1)(@azure/core-rest-pipeline@1.23.0)': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-client': 1.10.1 + '@azure/core-rest-pipeline': 1.23.0 + + '@azure/core-lro@2.7.2': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-paging@1.6.2': + dependencies: + tslib: 2.8.1 + + '@azure/core-rest-pipeline@1.23.0': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + '@typespec/ts-http-runtime': 0.3.4 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-tracing@1.3.1': + dependencies: + tslib: 2.8.1 + + '@azure/core-util@1.13.1': + dependencies: + '@azure/abort-controller': 2.1.2 + '@typespec/ts-http-runtime': 0.3.4 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/identity@4.13.1': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-client': 1.10.1 + '@azure/core-rest-pipeline': 1.23.0 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + '@azure/msal-browser': 5.6.2 + '@azure/msal-node': 5.1.1 + open: 10.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/keyvault-common@2.0.0': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-client': 1.10.1 + '@azure/core-rest-pipeline': 1.23.0 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/keyvault-keys@4.10.0(@azure/core-client@1.10.1)': + dependencies: + '@azure-rest/core-client': 2.5.1 + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-http-compat': 2.3.2(@azure/core-client@1.10.1)(@azure/core-rest-pipeline@1.23.0) + '@azure/core-lro': 2.7.2 + '@azure/core-paging': 1.6.2 + '@azure/core-rest-pipeline': 1.23.0 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/keyvault-common': 2.0.0 + '@azure/logger': 1.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@azure/core-client' + - supports-color + + '@azure/logger@1.3.0': + dependencies: + '@typespec/ts-http-runtime': 0.3.4 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/msal-browser@5.6.2': + dependencies: + '@azure/msal-common': 16.4.0 + + '@azure/msal-common@16.4.0': {} + + '@azure/msal-node@5.1.1': + dependencies: + '@azure/msal-common': 16.4.0 + jsonwebtoken: 9.0.3 + uuid: 8.3.2 + + '@babel/runtime@7.29.2': {} + + '@cfworker/json-schema@4.1.1': {} + + '@colors/colors@1.6.0': {} + + '@dabh/diagnostics@2.0.8': + dependencies: + '@so-ric/colorspace': 1.1.6 + enabled: 2.0.0 + kuler: 2.0.0 + + '@emnapi/core@1.9.1': + dependencies: + '@emnapi/wasi-threads': 1.2.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.4': + optional: true + + '@esbuild/android-arm64@0.27.4': + optional: true + + '@esbuild/android-arm@0.27.4': + optional: true + + '@esbuild/android-x64@0.27.4': + optional: true + + '@esbuild/darwin-arm64@0.27.4': + optional: true + + '@esbuild/darwin-x64@0.27.4': + optional: true + + '@esbuild/freebsd-arm64@0.27.4': + optional: true + + '@esbuild/freebsd-x64@0.27.4': + optional: true + + '@esbuild/linux-arm64@0.27.4': + optional: true + + '@esbuild/linux-arm@0.27.4': + optional: true + + '@esbuild/linux-ia32@0.27.4': + optional: true + + '@esbuild/linux-loong64@0.27.4': + optional: true + + '@esbuild/linux-mips64el@0.27.4': + optional: true + + '@esbuild/linux-ppc64@0.27.4': + optional: true + + '@esbuild/linux-riscv64@0.27.4': + optional: true + + '@esbuild/linux-s390x@0.27.4': + optional: true + + '@esbuild/linux-x64@0.27.4': + optional: true + + '@esbuild/netbsd-arm64@0.27.4': + optional: true + + '@esbuild/netbsd-x64@0.27.4': + optional: true + + '@esbuild/openbsd-arm64@0.27.4': + optional: true + + '@esbuild/openbsd-x64@0.27.4': + optional: true + + '@esbuild/openharmony-arm64@0.27.4': + optional: true + + '@esbuild/sunos-x64@0.27.4': + optional: true + + '@esbuild/win32-arm64@0.27.4': + optional: true + + '@esbuild/win32-ia32@0.27.4': + optional: true + + '@esbuild/win32-x64@0.27.4': + optional: true + + '@finom/zod-to-json-schema@3.24.11(zod@4.3.6)': + dependencies: + zod: 4.3.6 + + '@gar/promisify@1.1.3': + optional: true + + '@google-cloud/opentelemetry-cloud-monitoring-exporter@0.21.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/resources@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.6.1(@opentelemetry/api@1.9.1))(encoding@0.1.13)': + dependencies: + '@google-cloud/opentelemetry-resource-util': 3.0.0(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/resources@2.6.1(@opentelemetry/api@1.9.1))(encoding@0.1.13) + '@google-cloud/precise-date': 4.0.0 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.1(@opentelemetry/api@1.9.1) + google-auth-library: 9.15.1(encoding@0.1.13) + googleapis: 137.1.0(encoding@0.1.13) + transitivePeerDependencies: + - encoding + - supports-color + + '@google-cloud/opentelemetry-cloud-trace-exporter@3.0.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/resources@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(encoding@0.1.13)': + dependencies: + '@google-cloud/opentelemetry-resource-util': 3.0.0(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/resources@2.6.1(@opentelemetry/api@1.9.1))(encoding@0.1.13) + '@grpc/grpc-js': 1.14.3 + '@grpc/proto-loader': 0.8.0 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.1(@opentelemetry/api@1.9.1) + google-auth-library: 9.15.1(encoding@0.1.13) + transitivePeerDependencies: + - encoding + - supports-color + + '@google-cloud/opentelemetry-resource-util@3.0.0(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/resources@2.6.1(@opentelemetry/api@1.9.1))(encoding@0.1.13)': + dependencies: + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + gcp-metadata: 6.1.1(encoding@0.1.13) + transitivePeerDependencies: + - encoding + - supports-color + + '@google-cloud/paginator@5.0.2': + dependencies: + arrify: 2.0.1 + extend: 3.0.2 + + '@google-cloud/precise-date@4.0.0': {} + + '@google-cloud/projectify@4.0.0': {} + + '@google-cloud/promisify@4.0.0': {} + + '@google-cloud/storage@7.19.0(encoding@0.1.13)': + dependencies: + '@google-cloud/paginator': 5.0.2 + '@google-cloud/projectify': 4.0.0 + '@google-cloud/promisify': 4.0.0 + abort-controller: 3.0.0 + async-retry: 1.3.3 + duplexify: 4.1.3 + fast-xml-parser: 5.5.9 + gaxios: 6.7.1(encoding@0.1.13) + google-auth-library: 9.15.1(encoding@0.1.13) + html-entities: 2.6.0 + mime: 3.0.0 + p-limit: 3.1.0 + retry-request: 7.0.2(encoding@0.1.13) + teeny-request: 9.0.0(encoding@0.1.13) + uuid: 8.3.2 + transitivePeerDependencies: + - encoding + - supports-color + + '@google/adk@0.5.0(ee6095569807c0f2faf9175cb5eca775)': + dependencies: + '@a2a-js/sdk': 0.3.13(@grpc/grpc-js@1.14.3)(express@5.2.1) + '@google-cloud/opentelemetry-cloud-monitoring-exporter': 0.21.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/resources@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.6.1(@opentelemetry/api@1.9.1))(encoding@0.1.13) + '@google-cloud/opentelemetry-cloud-trace-exporter': 3.0.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/resources@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(encoding@0.1.13) + '@google-cloud/storage': 7.19.0(encoding@0.1.13) + '@google/genai': 1.47.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)) + '@mikro-orm/core': 6.6.11 + '@mikro-orm/mariadb': 6.6.11(@mikro-orm/core@6.6.11)(pg@8.20.0) + '@mikro-orm/mssql': 6.6.11(@azure/core-client@1.10.1)(@mikro-orm/core@6.6.11)(mariadb@3.4.5)(pg@8.20.0) + '@mikro-orm/mysql': 6.6.11(@mikro-orm/core@6.6.11)(@types/node@24.12.0)(mariadb@3.4.5)(pg@8.20.0) + '@mikro-orm/postgresql': 6.6.11(@mikro-orm/core@6.6.11)(mariadb@3.4.5) + '@mikro-orm/reflection': 6.6.11(@mikro-orm/core@6.6.11) + '@mikro-orm/sqlite': 6.6.11(@mikro-orm/core@6.6.11)(mariadb@3.4.5)(pg@8.20.0) + '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/exporter-logs-otlp-http': 0.205.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-gcp': 0.40.3(@opentelemetry/api@1.9.1)(encoding@0.1.13) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 2.6.1(@opentelemetry/api@1.9.1) + google-auth-library: 10.6.2 + lodash-es: 4.17.23 + winston: 3.19.0 + zod: 4.3.6 + zod-to-json-schema: 3.25.2(zod@4.3.6) + transitivePeerDependencies: + - '@bufbuild/protobuf' + - '@cfworker/json-schema' + - '@grpc/grpc-js' + - bufferutil + - express + - supports-color + - utf-8-validate + + '@google/genai@1.47.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))': + dependencies: + google-auth-library: 10.6.2 + p-retry: 4.6.2 + protobufjs: 7.5.4 + ws: 8.20.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@grpc/grpc-js@1.14.3': + dependencies: + '@grpc/proto-loader': 0.8.0 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.0': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.4 + yargs: 17.7.2 + + '@hono/node-server@1.19.12(hono@4.12.9)': + dependencies: + hono: 4.12.9 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@js-joda/core@5.7.0': {} + + '@js-sdsl/ordered-map@4.4.2': {} + + '@langchain/core@1.1.38(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(openai@6.33.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0)': + dependencies: + '@cfworker/json-schema': 4.1.1 + '@standard-schema/spec': 1.1.0 + ansi-styles: 5.2.0 + camelcase: 6.3.0 + decamelize: 1.2.0 + js-tiktoken: 1.0.21 + langsmith: 0.5.15(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(openai@6.33.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) + mustache: 4.2.0 + p-queue: 6.6.2 + uuid: 11.1.0 + zod: 4.3.6 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' + - openai + - ws + + '@langchain/langgraph-checkpoint@1.0.1(@langchain/core@1.1.38(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(openai@6.33.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))': + dependencies: + '@langchain/core': 1.1.38(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(openai@6.33.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) + uuid: 10.0.0 + + '@langchain/langgraph-sdk@1.8.3(@langchain/core@1.1.38(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(openai@6.33.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))': + dependencies: + '@types/json-schema': 7.0.15 + p-queue: 9.1.0 + p-retry: 7.1.1 + uuid: 13.0.0 + optionalDependencies: + '@langchain/core': 1.1.38(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(openai@6.33.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) + + '@langchain/langgraph@1.2.6(@langchain/core@1.1.38(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(openai@6.33.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6)': + dependencies: + '@langchain/core': 1.1.38(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(openai@6.33.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0) + '@langchain/langgraph-checkpoint': 1.0.1(@langchain/core@1.1.38(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(openai@6.33.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0)) + '@langchain/langgraph-sdk': 1.8.3(@langchain/core@1.1.38(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(openai@6.33.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0)) + '@standard-schema/spec': 1.1.0 + uuid: 10.0.0 + zod: 4.3.6 + optionalDependencies: + zod-to-json-schema: 3.25.2(zod@4.3.6) + transitivePeerDependencies: + - react + - react-dom + - svelte + - vue + + '@llamaindex/core@0.6.22': + dependencies: + '@finom/zod-to-json-schema': 3.24.11(zod@4.3.6) + '@llamaindex/env': 0.1.30 + '@types/node': 24.12.0 + magic-bytes.js: 1.13.0 + zod: 4.3.6 + transitivePeerDependencies: + - '@huggingface/transformers' + - gpt-tokenizer + + '@llamaindex/env@0.1.30': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + js-tiktoken: 1.0.21 + pathe: 1.1.2 + + '@llamaindex/node-parser@2.0.22(@llamaindex/core@0.6.22)(@llamaindex/env@0.1.30)(tree-sitter@0.22.4)(web-tree-sitter@0.24.7)': + dependencies: + '@llamaindex/core': 0.6.22 + '@llamaindex/env': 0.1.30 + html-to-text: 9.0.5 + tree-sitter: 0.22.4 + web-tree-sitter: 0.24.7 + + '@llamaindex/workflow-core@1.3.4(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(hono@4.12.9)(zod@4.3.6)': + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) + hono: 4.12.9 + zod: 4.3.6 + + '@llamaindex/workflow@1.1.24(@llamaindex/core@0.6.22)(@llamaindex/env@0.1.30)(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(hono@4.12.9)(zod@4.3.6)': + dependencies: + '@llamaindex/core': 0.6.22 + '@llamaindex/env': 0.1.30 + '@llamaindex/workflow-core': 1.3.4(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(hono@4.12.9)(zod@4.3.6) + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - hono + - next + - p-retry + - rxjs + - zod + + '@mikro-orm/core@6.6.11': + dependencies: + dataloader: 2.2.3 + dotenv: 17.3.1 + esprima: 4.0.1 + fs-extra: 11.3.3 + globby: 11.1.0 + mikro-orm: 6.6.11 + reflect-metadata: 0.2.2 + + '@mikro-orm/knex@6.6.11(@mikro-orm/core@6.6.11)(mariadb@3.4.5)(mysql2@3.20.0(@types/node@24.12.0))(pg@8.20.0)': + dependencies: + '@mikro-orm/core': 6.6.11 + fs-extra: 11.3.3 + knex: 3.2.8(mysql2@3.20.0(@types/node@24.12.0))(pg@8.20.0) + sqlstring: 2.3.3 + optionalDependencies: + mariadb: 3.4.5 + transitivePeerDependencies: + - mysql + - mysql2 + - pg + - pg-native + - pg-query-stream + - sqlite3 + - supports-color + - tedious + + '@mikro-orm/knex@6.6.11(@mikro-orm/core@6.6.11)(mariadb@3.4.5)(pg@8.20.0)': + dependencies: + '@mikro-orm/core': 6.6.11 + fs-extra: 11.3.3 + knex: 3.2.8(pg@8.20.0) + sqlstring: 2.3.3 + optionalDependencies: + mariadb: 3.4.5 + transitivePeerDependencies: + - mysql + - mysql2 + - pg + - pg-native + - pg-query-stream + - sqlite3 + - supports-color + - tedious + + '@mikro-orm/knex@6.6.11(@mikro-orm/core@6.6.11)(mariadb@3.4.5)(pg@8.20.0)(sqlite3@5.1.7)': + dependencies: + '@mikro-orm/core': 6.6.11 + fs-extra: 11.3.3 + knex: 3.2.8(pg@8.20.0)(sqlite3@5.1.7) + sqlstring: 2.3.3 + optionalDependencies: + mariadb: 3.4.5 + transitivePeerDependencies: + - mysql + - mysql2 + - pg + - pg-native + - pg-query-stream + - sqlite3 + - supports-color + - tedious + + '@mikro-orm/knex@6.6.11(@mikro-orm/core@6.6.11)(mariadb@3.4.5)(pg@8.20.0)(tedious@19.2.1(@azure/core-client@1.10.1))': + dependencies: + '@mikro-orm/core': 6.6.11 + fs-extra: 11.3.3 + knex: 3.2.8(pg@8.20.0)(tedious@19.2.1(@azure/core-client@1.10.1)) + sqlstring: 2.3.3 + optionalDependencies: + mariadb: 3.4.5 + transitivePeerDependencies: + - mysql + - mysql2 + - pg + - pg-native + - pg-query-stream + - sqlite3 + - supports-color + - tedious + + '@mikro-orm/mariadb@6.6.11(@mikro-orm/core@6.6.11)(pg@8.20.0)': + dependencies: + '@mikro-orm/core': 6.6.11 + '@mikro-orm/knex': 6.6.11(@mikro-orm/core@6.6.11)(mariadb@3.4.5)(pg@8.20.0) + mariadb: 3.4.5 + transitivePeerDependencies: + - better-sqlite3 + - libsql + - mysql + - mysql2 + - pg + - pg-native + - pg-query-stream + - sqlite3 + - supports-color + - tedious + + '@mikro-orm/mssql@6.6.11(@azure/core-client@1.10.1)(@mikro-orm/core@6.6.11)(mariadb@3.4.5)(pg@8.20.0)': + dependencies: + '@mikro-orm/core': 6.6.11 + '@mikro-orm/knex': 6.6.11(@mikro-orm/core@6.6.11)(mariadb@3.4.5)(pg@8.20.0)(tedious@19.2.1(@azure/core-client@1.10.1)) + tedious: 19.2.1(@azure/core-client@1.10.1) + tsqlstring: 1.0.1 + transitivePeerDependencies: + - '@azure/core-client' + - better-sqlite3 + - libsql + - mariadb + - mysql + - mysql2 + - pg + - pg-native + - pg-query-stream + - sqlite3 + - supports-color + + '@mikro-orm/mysql@6.6.11(@mikro-orm/core@6.6.11)(@types/node@24.12.0)(mariadb@3.4.5)(pg@8.20.0)': + dependencies: + '@mikro-orm/core': 6.6.11 + '@mikro-orm/knex': 6.6.11(@mikro-orm/core@6.6.11)(mariadb@3.4.5)(mysql2@3.20.0(@types/node@24.12.0))(pg@8.20.0) + mysql2: 3.20.0(@types/node@24.12.0) + transitivePeerDependencies: + - '@types/node' + - better-sqlite3 + - libsql + - mariadb + - mysql + - pg + - pg-native + - pg-query-stream + - sqlite3 + - supports-color + - tedious + + '@mikro-orm/postgresql@6.6.11(@mikro-orm/core@6.6.11)(mariadb@3.4.5)': + dependencies: + '@mikro-orm/core': 6.6.11 + '@mikro-orm/knex': 6.6.11(@mikro-orm/core@6.6.11)(mariadb@3.4.5)(pg@8.20.0) + pg: 8.20.0 + postgres-array: 3.0.4 + postgres-date: 2.1.0 + postgres-interval: 4.0.2 + transitivePeerDependencies: + - better-sqlite3 + - libsql + - mariadb + - mysql + - mysql2 + - pg-native + - pg-query-stream + - sqlite3 + - supports-color + - tedious + + '@mikro-orm/reflection@6.6.11(@mikro-orm/core@6.6.11)': + dependencies: + '@mikro-orm/core': 6.6.11 + globby: 11.1.0 + ts-morph: 27.0.2 + + '@mikro-orm/sqlite@6.6.11(@mikro-orm/core@6.6.11)(mariadb@3.4.5)(pg@8.20.0)': + dependencies: + '@mikro-orm/core': 6.6.11 + '@mikro-orm/knex': 6.6.11(@mikro-orm/core@6.6.11)(mariadb@3.4.5)(pg@8.20.0)(sqlite3@5.1.7) + fs-extra: 11.3.3 + sqlite3: 5.1.7 + sqlstring-sqlite: 0.1.1 + transitivePeerDependencies: + - better-sqlite3 + - bluebird + - libsql + - mariadb + - mysql + - mysql2 + - pg + - pg-native + - pg-query-stream + - supports-color + - tedious + + '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.12(hono@4.12.9) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.3.2(express@5.2.1) + hono: 4.12.9 + jose: 6.2.2 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.2(zod@4.3.6) + optionalDependencies: + '@cfworker/json-schema': 4.1.1 + transitivePeerDependencies: + - supports-color + + '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': + dependencies: + '@emnapi/core': 1.9.1 + '@emnapi/runtime': 1.9.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@npmcli/fs@1.1.1': + dependencies: + '@gar/promisify': 1.1.3 + semver: 7.7.4 + optional: true + + '@npmcli/move-file@1.1.2': + dependencies: + mkdirp: 1.0.4 + rimraf: 3.0.2 + optional: true + + '@openai/agents-core@0.8.2(@cfworker/json-schema@4.1.1)(ws@8.20.0)(zod@4.3.6)': + dependencies: + debug: 4.4.3 + openai: 6.33.0(ws@8.20.0)(zod@4.3.6) + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) + zod: 4.3.6 + transitivePeerDependencies: + - '@cfworker/json-schema' + - supports-color + - ws + + '@openai/agents-openai@0.8.2(@cfworker/json-schema@4.1.1)(ws@8.20.0)(zod@4.3.6)': + dependencies: + '@openai/agents-core': 0.8.2(@cfworker/json-schema@4.1.1)(ws@8.20.0)(zod@4.3.6) + debug: 4.4.3 + openai: 6.33.0(ws@8.20.0)(zod@4.3.6) + zod: 4.3.6 + transitivePeerDependencies: + - '@cfworker/json-schema' + - supports-color + - ws + + '@openai/agents-realtime@0.8.2(@cfworker/json-schema@4.1.1)(zod@4.3.6)': + dependencies: + '@openai/agents-core': 0.8.2(@cfworker/json-schema@4.1.1)(ws@8.20.0)(zod@4.3.6) + '@types/ws': 8.18.1 + debug: 4.4.3 + ws: 8.20.0 + zod: 4.3.6 + transitivePeerDependencies: + - '@cfworker/json-schema' + - bufferutil + - supports-color + - utf-8-validate + + '@openai/agents@0.8.2(@cfworker/json-schema@4.1.1)(ws@8.20.0)(zod@4.3.6)': + dependencies: + '@openai/agents-core': 0.8.2(@cfworker/json-schema@4.1.1)(ws@8.20.0)(zod@4.3.6) + '@openai/agents-openai': 0.8.2(@cfworker/json-schema@4.1.1)(ws@8.20.0)(zod@4.3.6) + '@openai/agents-realtime': 0.8.2(@cfworker/json-schema@4.1.1)(zod@4.3.6) + debug: 4.4.3 + openai: 6.33.0(ws@8.20.0)(zod@4.3.6) + zod: 4.3.6 + transitivePeerDependencies: + - '@cfworker/json-schema' + - bufferutil + - supports-color + - utf-8-validate + - ws + + '@opencode-ai/plugin@1.3.13': + dependencies: + '@opencode-ai/sdk': 1.3.13 + zod: 4.1.8 + + '@opencode-ai/sdk@1.3.13': {} + + '@opentelemetry/api-logs@0.205.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api-logs@0.213.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/exporter-logs-otlp-http@0.205.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.205.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.205.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.205.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.205.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-grpc@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-http@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-grpc@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-http@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-exporter-base@0.205.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.205.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-exporter-base@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-grpc-exporter-base@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.205.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.205.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.1.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.205.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.1.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.1.0(@opentelemetry/api@1.9.1) + protobufjs: 7.5.4 + + '@opentelemetry/otlp-transformer@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) + protobufjs: 7.5.4 + + '@opentelemetry/resource-detector-gcp@0.40.3(@opentelemetry/api@1.9.1)(encoding@0.1.13)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + gcp-metadata: 6.1.1(encoding@0.1.13) + transitivePeerDependencies: + - encoding + - supports-color + + '@opentelemetry/resources@2.1.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/resources@2.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-logs@0.205.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.205.0 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.1.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-logs@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-metrics@2.1.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.1.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-metrics@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-metrics@2.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.1.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.1.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-trace-node@2.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/semantic-conventions@1.40.0': {} + + '@oxc-project/types@0.122.0': {} + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + + '@rolldown/binding-android-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.12': {} + + '@selderee/plugin-htmlparser2@0.11.0': + dependencies: + domhandler: 5.0.3 + selderee: 0.11.0 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/types@4.13.1': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@so-ric/colorspace@1.1.6': + dependencies: + color: 5.0.3 + text-hex: 1.0.0 + + '@standard-schema/spec@1.1.0': {} + + '@tootallnate/once@1.1.2': + optional: true + + '@tootallnate/once@2.0.0': {} + + '@ts-morph/common@0.28.1': + dependencies: + minimatch: 10.2.5 + path-browserify: 1.0.1 + tinyglobby: 0.2.15 + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/caseless@0.12.5': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/geojson@7946.0.16': {} + + '@types/json-schema@7.0.15': {} + + '@types/lodash@4.17.24': {} + + '@types/node@24.12.0': + dependencies: + undici-types: 7.16.0 + + '@types/readable-stream@4.0.23': + dependencies: + '@types/node': 24.12.0 + + '@types/request@2.48.13': + dependencies: + '@types/caseless': 0.12.5 + '@types/node': 24.12.0 + '@types/tough-cookie': 4.0.5 + form-data: 2.5.5 + + '@types/retry@0.12.0': {} + + '@types/tough-cookie@4.0.5': {} + + '@types/triple-beam@1.3.5': {} + + '@types/uuid@10.0.0': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.12.0 + + '@typespec/ts-http-runtime@0.3.4': + dependencies: + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@vitest/expect@4.1.2': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.4))': + dependencies: + '@vitest/spy': 4.1.2 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.4) + + '@vitest/pretty-format@4.1.2': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.2': + dependencies: + '@vitest/utils': 4.1.2 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.2': + dependencies: + '@vitest/pretty-format': 4.1.2 + '@vitest/utils': 4.1.2 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.2': {} + + '@vitest/utils@4.1.2': + dependencies: + '@vitest/pretty-format': 4.1.2 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + abbrev@1.1.1: + optional: true + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + optional: true + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + optional: true + + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + aproba@2.1.0: + optional: true + + are-we-there-yet@3.0.1: + dependencies: + delegates: 1.0.0 + readable-stream: 3.6.2 + optional: true + + array-union@2.1.0: {} + + arrify@2.0.1: {} + + assertion-error@2.0.1: {} + + async-retry@1.3.3: + dependencies: + retry: 0.13.1 + + async@3.2.6: {} + + asynckit@0.4.0: {} + + aws-ssl-profiles@1.1.2: {} + + balanced-match@1.0.2: + optional: true + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + bignumber.js@9.3.1: {} + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + bl@6.1.6: + dependencies: + '@types/readable-stream': 4.0.23 + buffer: 6.0.3 + inherits: 2.0.4 + readable-stream: 4.7.0 + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.13: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + optional: true + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + buffer-equal-constant-time@1.0.1: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + bytes@3.1.2: {} + + cacache@15.3.0: + dependencies: + '@npmcli/fs': 1.1.1 + '@npmcli/move-file': 1.1.2 + chownr: 2.0.0 + fs-minipass: 2.1.0 + glob: 7.2.3 + infer-owner: 1.0.4 + lru-cache: 6.0.0 + minipass: 3.3.6 + minipass-collect: 1.0.2 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + mkdirp: 1.0.4 + p-map: 4.0.0 + promise-inflight: 1.0.1 + rimraf: 3.0.2 + ssri: 8.0.1 + tar: 6.2.1 + unique-filename: 1.1.1 + transitivePeerDependencies: + - bluebird + optional: true + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + camelcase@6.3.0: {} + + chai@6.2.2: {} + + chalk@5.6.2: {} + + chownr@1.1.4: {} + + chownr@2.0.0: {} + + clean-stack@2.2.0: + optional: true + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + code-block-writer@13.0.3: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-convert@3.1.3: + dependencies: + color-name: 2.1.0 + + color-name@1.1.4: {} + + color-name@2.1.0: {} + + color-string@2.1.4: + dependencies: + color-name: 2.1.0 + + color-support@1.1.3: + optional: true + + color@5.0.3: + dependencies: + color-convert: 3.1.3 + color-string: 2.1.4 + + colorette@2.0.19: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@10.0.1: {} + + concat-map@0.0.1: + optional: true + + console-control-strings@1.1.0: + optional: true + + console-table-printer@2.15.0: + dependencies: + simple-wcswidth: 1.1.2 + + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + data-uri-to-buffer@4.0.1: {} + + dataloader@2.2.3: {} + + debug@4.3.4: + dependencies: + ms: 2.1.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decamelize@1.2.0: {} + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + + deepmerge@4.3.1: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + + delayed-stream@1.0.0: {} + + delegates@1.0.0: + optional: true + + denque@2.1.0: {} + + depd@2.0.0: {} + + detect-libc@2.1.2: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dotenv@17.3.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + + emoji-regex@8.0.0: {} + + enabled@2.0.0: {} + + encodeurl@2.0.0: {} + + encoding@0.1.13: + dependencies: + iconv-lite: 0.6.3 + optional: true + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + entities@4.5.0: {} + + env-paths@2.2.1: + optional: true + + err-code@2.0.3: + optional: true + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.0.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.27.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + esm@3.2.25: {} + + esprima@4.0.1: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + etag@1.8.1: {} + + event-target-shim@5.0.1: {} + + eventemitter3@4.0.7: {} + + eventemitter3@5.0.4: {} + + events@3.3.0: {} + + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + + expand-template@2.0.3: {} + + expect-type@1.3.0: {} + + express-rate-limit@8.3.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-uri@3.1.0: {} + + fast-xml-builder@1.1.4: + dependencies: + path-expression-matcher: 1.2.0 + + fast-xml-parser@5.5.9: + dependencies: + fast-xml-builder: 1.1.4 + path-expression-matcher: 1.2.0 + strnum: 2.2.2 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fecha@4.2.3: {} + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + file-uri-to-path@1.0.0: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + fn.name@1.1.0: {} + + form-data@2.5.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + safe-buffer: 5.2.1 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fs-constants@1.0.0: {} + + fs-extra@11.3.3: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + + fs.realpath@1.0.0: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gauge@4.0.4: + dependencies: + aproba: 2.1.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + signal-exit: 3.0.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + optional: true + + gaxios@6.7.1(encoding@0.1.13): + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + is-stream: 2.0.1 + node-fetch: 2.7.0(encoding@0.1.13) + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + gaxios@7.1.4: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@6.1.1(encoding@0.1.13): + dependencies: + gaxios: 6.7.1(encoding@0.1.13) + google-logging-utils: 0.0.2 + json-bigint: 1.0.0 + transitivePeerDependencies: + - encoding + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.4 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-package-type@0.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + getopts@2.3.0: {} + + github-from-package@0.0.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + optional: true + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + google-auth-library@10.6.2: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.4 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-auth-library@9.15.1(encoding@0.1.13): + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 6.7.1(encoding@0.1.13) + gcp-metadata: 6.1.1(encoding@0.1.13) + gtoken: 7.1.0(encoding@0.1.13) + jws: 4.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + google-logging-utils@0.0.2: {} + + google-logging-utils@1.1.3: {} + + googleapis-common@7.2.0(encoding@0.1.13): + dependencies: + extend: 3.0.2 + gaxios: 6.7.1(encoding@0.1.13) + google-auth-library: 9.15.1(encoding@0.1.13) + qs: 6.15.0 + url-template: 2.0.8 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + googleapis@137.1.0(encoding@0.1.13): + dependencies: + google-auth-library: 9.15.1(encoding@0.1.13) + googleapis-common: 7.2.0(encoding@0.1.13) + transitivePeerDependencies: + - encoding + - supports-color + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + gtoken@7.1.0(encoding@0.1.13): + dependencies: + gaxios: 6.7.1(encoding@0.1.13) + jws: 4.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + has-unicode@2.0.1: + optional: true + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hono@4.12.9: {} + + html-entities@2.6.0: {} + + html-to-text@9.0.5: + dependencies: + '@selderee/plugin-htmlparser2': 0.11.0 + deepmerge: 4.3.1 + dom-serializer: 2.0.0 + htmlparser2: 8.0.2 + selderee: 0.11.0 + + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + + http-cache-semantics@4.2.0: + optional: true + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@4.0.1: + dependencies: + '@tootallnate/once': 1.1.2 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + optional: true + + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + optional: true + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + imurmurhash@0.1.4: + optional: true + + indent-string@4.0.0: + optional: true + + infer-owner@1.0.4: + optional: true + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + optional: true + + inherits@2.0.4: {} + + ini@1.3.8: {} + + interpret@2.2.0: {} + + ip-address@10.1.0: {} + + ipaddr.js@1.9.1: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-lambda@1.0.1: + optional: true + + is-network-error@1.3.1: {} + + is-number@7.0.0: {} + + is-promise@4.0.0: {} + + is-property@1.0.2: {} + + is-stream@2.0.1: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isexe@2.0.0: {} + + jose@6.2.2: {} + + js-md4@0.3.2: {} + + js-tiktoken@1.0.21: + dependencies: + base64-js: 1.5.1 + + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.2 + ts-algebra: 2.0.0 + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.4 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + knex@3.2.8(mysql2@3.20.0(@types/node@24.12.0))(pg@8.20.0): + dependencies: + colorette: 2.0.19 + commander: 10.0.1 + debug: 4.3.4 + escalade: 3.2.0 + esm: 3.2.25 + get-package-type: 0.1.0 + getopts: 2.3.0 + interpret: 2.2.0 + lodash: 4.17.21 + pg-connection-string: 2.6.2 + rechoir: 0.8.0 + resolve-from: 5.0.0 + tarn: 3.0.2 + tildify: 2.0.0 + optionalDependencies: + mysql2: 3.20.0(@types/node@24.12.0) + pg: 8.20.0 + transitivePeerDependencies: + - supports-color + + knex@3.2.8(pg@8.20.0): + dependencies: + colorette: 2.0.19 + commander: 10.0.1 + debug: 4.3.4 + escalade: 3.2.0 + esm: 3.2.25 + get-package-type: 0.1.0 + getopts: 2.3.0 + interpret: 2.2.0 + lodash: 4.17.21 + pg-connection-string: 2.6.2 + rechoir: 0.8.0 + resolve-from: 5.0.0 + tarn: 3.0.2 + tildify: 2.0.0 + optionalDependencies: + pg: 8.20.0 + transitivePeerDependencies: + - supports-color + + knex@3.2.8(pg@8.20.0)(sqlite3@5.1.7): + dependencies: + colorette: 2.0.19 + commander: 10.0.1 + debug: 4.3.4 + escalade: 3.2.0 + esm: 3.2.25 + get-package-type: 0.1.0 + getopts: 2.3.0 + interpret: 2.2.0 + lodash: 4.17.21 + pg-connection-string: 2.6.2 + rechoir: 0.8.0 + resolve-from: 5.0.0 + tarn: 3.0.2 + tildify: 2.0.0 + optionalDependencies: + pg: 8.20.0 + sqlite3: 5.1.7 + transitivePeerDependencies: + - supports-color + + knex@3.2.8(pg@8.20.0)(tedious@19.2.1(@azure/core-client@1.10.1)): + dependencies: + colorette: 2.0.19 + commander: 10.0.1 + debug: 4.3.4 + escalade: 3.2.0 + esm: 3.2.25 + get-package-type: 0.1.0 + getopts: 2.3.0 + interpret: 2.2.0 + lodash: 4.17.21 + pg-connection-string: 2.6.2 + rechoir: 0.8.0 + resolve-from: 5.0.0 + tarn: 3.0.2 + tildify: 2.0.0 + optionalDependencies: + pg: 8.20.0 + tedious: 19.2.1(@azure/core-client@1.10.1) + transitivePeerDependencies: + - supports-color + + kuler@2.0.0: {} + + langsmith@0.5.15(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(openai@6.33.0(ws@8.20.0)(zod@4.3.6))(ws@8.20.0): + dependencies: + '@types/uuid': 10.0.0 + chalk: 5.6.2 + console-table-printer: 2.15.0 + p-queue: 6.6.2 + semver: 7.7.4 + uuid: 10.0.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/sdk-trace-base': 2.6.1(@opentelemetry/api@1.9.1) + openai: 6.33.0(ws@8.20.0)(zod@4.3.6) + ws: 8.20.0 + + leac@0.6.0: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + llamaindex@0.12.1(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(hono@4.12.9)(tree-sitter@0.22.4)(web-tree-sitter@0.24.7)(zod@4.3.6): + dependencies: + '@llamaindex/core': 0.6.22 + '@llamaindex/env': 0.1.30 + '@llamaindex/node-parser': 2.0.22(@llamaindex/core@0.6.22)(@llamaindex/env@0.1.30)(tree-sitter@0.22.4)(web-tree-sitter@0.24.7) + '@llamaindex/workflow': 1.1.24(@llamaindex/core@0.6.22)(@llamaindex/env@0.1.30)(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(hono@4.12.9)(zod@4.3.6) + '@types/lodash': 4.17.24 + '@types/node': 24.12.0 + lodash: 4.17.21 + magic-bytes.js: 1.13.0 + transitivePeerDependencies: + - '@huggingface/transformers' + - '@modelcontextprotocol/sdk' + - gpt-tokenizer + - hono + - next + - p-retry + - rxjs + - tree-sitter + - web-tree-sitter + - zod + + lodash-es@4.17.23: {} + + lodash.camelcase@4.3.0: {} + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + + lodash@4.17.21: {} + + logform@2.7.0: + dependencies: + '@colors/colors': 1.6.0 + '@types/triple-beam': 1.3.5 + fecha: 4.2.3 + ms: 2.1.3 + safe-stable-stringify: 2.5.0 + triple-beam: 1.4.1 + + long@5.3.2: {} + + lru-cache@10.4.3: {} + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + optional: true + + lru.min@1.1.4: {} + + magic-bytes.js@1.13.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + make-fetch-happen@9.1.0: + dependencies: + agentkeepalive: 4.6.0 + cacache: 15.3.0 + http-cache-semantics: 4.2.0 + http-proxy-agent: 4.0.1 + https-proxy-agent: 5.0.1 + is-lambda: 1.0.1 + lru-cache: 6.0.0 + minipass: 3.3.6 + minipass-collect: 1.0.2 + minipass-fetch: 1.4.1 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + negotiator: 0.6.4 + promise-retry: 2.0.1 + socks-proxy-agent: 6.2.1 + ssri: 8.0.1 + transitivePeerDependencies: + - bluebird + - supports-color + optional: true + + mariadb@3.4.5: + dependencies: + '@types/geojson': 7946.0.16 + '@types/node': 24.12.0 + denque: 2.1.0 + iconv-lite: 0.6.3 + lru-cache: 10.4.3 + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mikro-orm@6.6.11: {} + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@3.0.0: {} + + mimic-response@3.1.0: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.13 + optional: true + + minimist@1.2.8: {} + + minipass-collect@1.0.2: + dependencies: + minipass: 3.3.6 + optional: true + + minipass-fetch@1.4.1: + dependencies: + minipass: 3.3.6 + minipass-sized: 1.0.3 + minizlib: 2.1.2 + optionalDependencies: + encoding: 0.1.13 + optional: true + + minipass-flush@1.0.7: + dependencies: + minipass: 3.3.6 + optional: true + + minipass-pipeline@1.2.4: + dependencies: + minipass: 3.3.6 + optional: true + + minipass-sized@1.0.3: + dependencies: + minipass: 3.3.6 + optional: true + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + + mkdirp-classic@0.5.3: {} + + mkdirp@1.0.4: {} + + ms@2.1.2: {} + + ms@2.1.3: {} + + mustache@4.2.0: {} + + mysql2@3.20.0(@types/node@24.12.0): + dependencies: + '@types/node': 24.12.0 + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.2 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + sql-escaper: 1.3.3 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + + nanoid@3.3.11: {} + + napi-build-utils@2.0.0: {} + + native-duplexpair@1.0.0: {} + + negotiator@0.6.4: + optional: true + + negotiator@1.0.0: {} + + node-abi@3.89.0: + dependencies: + semver: 7.7.4 + + node-addon-api@7.1.1: {} + + node-addon-api@8.7.0: {} + + node-domexception@1.0.0: {} + + node-fetch@2.7.0(encoding@0.1.13): + dependencies: + whatwg-url: 5.0.0 + optionalDependencies: + encoding: 0.1.13 + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-gyp-build@4.8.4: {} + + node-gyp@8.4.1: + dependencies: + env-paths: 2.2.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + make-fetch-happen: 9.1.0 + nopt: 5.0.0 + npmlog: 6.0.2 + rimraf: 3.0.2 + semver: 7.7.4 + tar: 6.2.1 + which: 2.0.2 + transitivePeerDependencies: + - bluebird + - supports-color + optional: true + + nopt@5.0.0: + dependencies: + abbrev: 1.1.1 + optional: true + + npmlog@6.0.2: + dependencies: + are-we-there-yet: 3.0.1 + console-control-strings: 1.1.0 + gauge: 4.0.4 + set-blocking: 2.0.0 + optional: true + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + obug@2.1.1: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + one-time@1.0.0: + dependencies: + fn.name: 1.1.0 + + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + + openai@6.33.0(ws@8.20.0)(zod@4.3.6): + optionalDependencies: + ws: 8.20.0 + zod: 4.3.6 + + p-finally@1.0.0: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-map@4.0.0: + dependencies: + aggregate-error: 3.1.0 + optional: true + + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-queue@9.1.0: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.1 + + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + + p-retry@7.1.1: + dependencies: + is-network-error: 1.3.1 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + + p-timeout@7.0.1: {} + + parseley@0.12.1: + dependencies: + leac: 0.6.0 + peberminta: 0.9.0 + + parseurl@1.3.3: {} + + path-browserify@1.0.1: {} + + path-expression-matcher@1.2.0: {} + + path-is-absolute@1.0.1: + optional: true + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-to-regexp@8.4.1: {} + + path-type@4.0.0: {} + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + peberminta@0.9.0: {} + + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.12.0: {} + + pg-connection-string@2.6.2: {} + + pg-int8@1.0.1: {} + + pg-pool@3.13.0(pg@8.20.0): + dependencies: + pg: 8.20.0 + + pg-protocol@1.13.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.20.0: + dependencies: + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.20.0) + pg-protocol: 1.13.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pkce-challenge@5.0.1: {} + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: {} + + postgres-array@3.0.4: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-date@2.1.0: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + postgres-interval@4.0.2: {} + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.89.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + + process@0.11.10: {} + + promise-inflight@1.0.1: + optional: true + + promise-retry@2.0.1: + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + optional: true + + protobufjs@7.5.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 24.12.0 + long: 5.3.2 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + + queue-microtask@1.2.3: {} + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + rechoir@0.8.0: + dependencies: + resolve: 1.22.11 + + reflect-metadata@0.2.2: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve-from@5.0.0: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + retry-request@7.0.2(encoding@0.1.13): + dependencies: + '@types/request': 2.48.13 + extend: 3.0.2 + teeny-request: 9.0.0(encoding@0.1.13) + transitivePeerDependencies: + - encoding + - supports-color + + retry@0.12.0: + optional: true + + retry@0.13.1: {} + + reusify@1.1.0: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + optional: true + + rolldown@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): + dependencies: + '@oxc-project/types': 0.122.0 + '@rolldown/pluginutils': 1.0.0-rc.12 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-x64': 1.0.0-rc.12 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.1 + transitivePeerDependencies: + - supports-color + + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-buffer@5.2.1: {} + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + selderee@0.11.0: + dependencies: + parseley: 0.12.1 + + semver@7.7.4: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + set-blocking@2.0.0: + optional: true + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@3.0.7: + optional: true + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + simple-wcswidth@1.1.2: {} + + slash@3.0.0: {} + + smart-buffer@4.2.0: + optional: true + + socks-proxy-agent@6.2.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + socks: 2.8.7 + transitivePeerDependencies: + - supports-color + optional: true + + socks@2.8.7: + dependencies: + ip-address: 10.1.0 + smart-buffer: 4.2.0 + optional: true + + source-map-js@1.2.1: {} + + split2@4.2.0: {} + + sprintf-js@1.1.3: {} + + sql-escaper@1.3.3: {} + + sqlite3@5.1.7: + dependencies: + bindings: 1.5.0 + node-addon-api: 7.1.1 + prebuild-install: 7.1.3 + tar: 6.2.1 + optionalDependencies: + node-gyp: 8.4.1 + transitivePeerDependencies: + - bluebird + - supports-color + + sqlstring-sqlite@0.1.1: {} + + sqlstring@2.3.3: {} + + ssri@8.0.1: + dependencies: + minipass: 3.3.6 + optional: true + + stack-trace@0.0.10: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@4.0.0: {} + + stream-events@1.0.5: + dependencies: + stubs: 3.0.0 + + stream-shift@1.0.3: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-json-comments@2.0.1: {} + + strnum@2.2.2: {} + + stubs@3.0.0: {} + + supports-preserve-symlinks-flag@1.0.0: {} + + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + + tarn@3.0.2: {} + + tedious@19.2.1(@azure/core-client@1.10.1): + dependencies: + '@azure/core-auth': 1.10.1 + '@azure/identity': 4.13.1 + '@azure/keyvault-keys': 4.10.0(@azure/core-client@1.10.1) + '@js-joda/core': 5.7.0 + '@types/node': 24.12.0 + bl: 6.1.6 + iconv-lite: 0.7.2 + js-md4: 0.3.2 + native-duplexpair: 1.0.0 + sprintf-js: 1.1.3 + transitivePeerDependencies: + - '@azure/core-client' + - supports-color + + teeny-request@9.0.0(encoding@0.1.13): + dependencies: + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + node-fetch: 2.7.0(encoding@0.1.13) + stream-events: 1.0.5 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + text-hex@1.0.0: {} + + tildify@2.0.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.0.4: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + tr46@0.0.3: {} + + tree-sitter@0.22.4: + dependencies: + node-addon-api: 8.7.0 + node-gyp-build: 4.8.4 + + triple-beam@1.4.1: {} + + ts-algebra@2.0.0: {} + + ts-morph@27.0.2: + dependencies: + '@ts-morph/common': 0.28.1 + code-block-writer: 13.0.3 + + tslib@2.8.1: {} + + tsqlstring@1.0.1: {} + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typescript@6.0.2: {} + + undici-types@7.16.0: {} + + unique-filename@1.1.1: + dependencies: + unique-slug: 2.0.2 + optional: true + + unique-slug@2.0.2: + dependencies: + imurmurhash: 0.1.4 + optional: true + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + url-template@2.0.8: {} + + util-deprecate@1.0.2: {} + + uuid@10.0.0: {} + + uuid@11.1.0: {} + + uuid@13.0.0: {} + + uuid@8.3.2: {} + + uuid@9.0.1: {} + + vary@1.1.2: {} + + vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.4): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.8 + rolldown: 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.12.0 + esbuild: 0.27.4 + fsevents: 2.3.3 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.4)): + dependencies: + '@vitest/expect': 4.1.2 + '@vitest/mocker': 4.1.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.4)) + '@vitest/pretty-format': 4.1.2 + '@vitest/runner': 4.1.2 + '@vitest/snapshot': 4.1.2 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(esbuild@0.27.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.12.0 + transitivePeerDependencies: + - msw + + web-streams-polyfill@3.3.3: {} + + web-tree-sitter@0.24.7: {} + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wide-align@1.1.5: + dependencies: + string-width: 4.2.3 + optional: true + + winston-transport@4.9.0: + dependencies: + logform: 2.7.0 + readable-stream: 3.6.2 + triple-beam: 1.4.1 + + winston@3.19.0: + dependencies: + '@colors/colors': 1.6.0 + '@dabh/diagnostics': 2.0.8 + async: 3.2.6 + is-stream: 2.0.1 + logform: 2.7.0 + one-time: 1.0.0 + readable-stream: 3.6.2 + safe-stable-stringify: 2.5.0 + stack-trace: 0.0.10 + triple-beam: 1.4.1 + winston-transport: 4.9.0 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + ws@8.20.0: {} + + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yallist@4.0.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + zod-to-json-schema@3.25.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod@4.1.8: {} + + zod@4.3.6: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..029f546 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - 'js' + - 'plugins/*' diff --git a/proto/sigil/v1/generation_ingest.proto b/proto/sigil/v1/generation_ingest.proto index 733a27f..fd7355d 100644 --- a/proto/sigil/v1/generation_ingest.proto +++ b/proto/sigil/v1/generation_ingest.proto @@ -83,6 +83,7 @@ message ToolDefinition { string description = 2; string type = 3; bytes input_schema_json = 4; + bool deferred = 5; } message TokenUsage { @@ -92,6 +93,7 @@ message TokenUsage { int64 cache_read_input_tokens = 4; int64 cache_write_input_tokens = 5; int64 reasoning_tokens = 6; + int64 cache_creation_input_tokens = 7; } enum ArtifactKind { diff --git a/python-frameworks/google-adk/LICENSE b/python-frameworks/google-adk/LICENSE index ae8c60c..626a3ab 100644 --- a/python-frameworks/google-adk/LICENSE +++ b/python-frameworks/google-adk/LICENSE @@ -1,3 +1,3 @@ SPDX-License-Identifier: Apache-2.0 -See /sdks/LICENSE at repository root for full license text. +See /LICENSE at repository root for full license text. diff --git a/python-frameworks/google-adk/pyproject.toml b/python-frameworks/google-adk/pyproject.toml index 6dcf6f2..ba7a5a4 100644 --- a/python-frameworks/google-adk/pyproject.toml +++ b/python-frameworks/google-adk/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "sigil-sdk-google-adk" -version = "0.1.0" +version = "0.1.2" description = "Google ADK callback handlers for Sigil Python SDK" readme = "README.md" license = { file = "LICENSE" } requires-python = ">=3.10" dependencies = [ - "sigil-sdk>=0.1.0", + "sigil-sdk>=0.1.2", "google-adk>=1.0.0", ] diff --git a/python-frameworks/google-adk/tests/test_sigil_sdk_google_adk.py b/python-frameworks/google-adk/tests/test_sigil_sdk_google_adk.py index 460420e..9f91ba5 100644 --- a/python-frameworks/google-adk/tests/test_sigil_sdk_google_adk.py +++ b/python-frameworks/google-adk/tests/test_sigil_sdk_google_adk.py @@ -7,6 +7,9 @@ from uuid import UUID, uuid4 from google.adk.plugins import BasePlugin +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter from sigil_sdk import Client, ClientConfig, GenerationExportConfig from sigil_sdk.models import ExportGenerationResult, ExportGenerationsResponse from sigil_sdk_google_adk import ( @@ -38,9 +41,10 @@ def shutdown(self) -> None: return -def _new_client(exporter: _CapturingExporter) -> Client: +def _new_client(exporter: _CapturingExporter, tracer=None) -> Client: return Client( ClientConfig( + tracer=tracer, generation_export=GenerationExportConfig(batch_size=10, flush_interval=timedelta(seconds=60)), generation_exporter=exporter, ) @@ -183,6 +187,47 @@ def test_sigil_sdk_google_adk_stream_mode_uses_chunks_when_output_missing() -> N client.shutdown() +def test_sigil_sdk_google_adk_generation_span_tracks_active_parent_span_and_export_lineage() -> None: + exporter = _CapturingExporter() + span_exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(span_exporter)) + tracer = provider.get_tracer("sigil-framework-test") + client = _new_client(exporter, tracer=tracer) + + try: + run_id = uuid4() + with tracer.start_as_current_span("framework.request"): + handler = SigilGoogleAdkHandler(client=client, provider_resolver="auto") + handler.on_chat_model_start( + {"name": "ChatModel"}, + [[{"type": "human", "content": "hello"}]], + run_id=run_id, + parent_run_id=uuid4(), + invocation_params={"model": "gpt-5"}, + metadata={"conversation_id": "framework-conversation-lineage-42", "thread_id": "framework-thread-lineage-42"}, + ) + handler.on_llm_end( + {"generations": [[{"text": "world"}]], "llm_output": {"model_name": "gpt-5", "finish_reason": "stop"}}, + run_id=run_id, + ) + + client.flush() + generation = exporter.requests[0].generations[0] + spans = span_exporter.get_finished_spans() + parent_span = next(span for span in spans if span.name == "framework.request") + generation_span = next(span for span in spans if span.attributes.get("gen_ai.operation.name") == "generateText") + + assert generation_span.parent is not None + assert generation_span.parent.span_id == parent_span.context.span_id + assert generation_span.context.trace_id == parent_span.context.trace_id + assert generation.trace_id == generation_span.context.trace_id.to_bytes(16, "big").hex() + assert generation.span_id == generation_span.context.span_id.to_bytes(8, "big").hex() + finally: + client.shutdown() + provider.shutdown() + + def test_sigil_sdk_google_adk_normalizes_extra_metadata() -> None: exporter = _CapturingExporter() client = _new_client(exporter) @@ -333,6 +378,18 @@ async def _run() -> None: client.shutdown() +def test_sigil_sdk_google_adk_handler_explicitly_has_no_embedding_lifecycle() -> None: + exporter = _CapturingExporter() + client = _new_client(exporter) + try: + handler = SigilGoogleAdkHandler(client=client) + assert not hasattr(handler, "on_embedding_start") + assert not hasattr(handler, "on_embedding_end") + assert not hasattr(handler, "on_embedding_error") + finally: + client.shutdown() + + def test_sigil_sdk_google_adk_callbacks_close_tool_runs_without_function_call_id() -> None: class _CapturingHandler: def __init__(self) -> None: diff --git a/python-frameworks/langchain/LICENSE b/python-frameworks/langchain/LICENSE index ae8c60c..626a3ab 100644 --- a/python-frameworks/langchain/LICENSE +++ b/python-frameworks/langchain/LICENSE @@ -1,3 +1,3 @@ SPDX-License-Identifier: Apache-2.0 -See /sdks/LICENSE at repository root for full license text. +See /LICENSE at repository root for full license text. diff --git a/python-frameworks/langchain/pyproject.toml b/python-frameworks/langchain/pyproject.toml index 110f282..be11da9 100644 --- a/python-frameworks/langchain/pyproject.toml +++ b/python-frameworks/langchain/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "sigil-sdk-langchain" -version = "0.1.0" +version = "0.1.2" description = "LangChain callback handlers for Sigil Python SDK" readme = "README.md" license = { file = "LICENSE" } requires-python = ">=3.10" dependencies = [ - "sigil-sdk>=0.1.0", + "sigil-sdk>=0.1.2", "langchain-core>=0.3.0", ] diff --git a/python-frameworks/langchain/sigil_sdk_langchain/handler.py b/python-frameworks/langchain/sigil_sdk_langchain/handler.py index d7f0eba..8bbd867 100644 --- a/python-frameworks/langchain/sigil_sdk_langchain/handler.py +++ b/python-frameworks/langchain/sigil_sdk_langchain/handler.py @@ -18,6 +18,23 @@ class AsyncCallbackHandler: # type: ignore[no-redef] """Fallback async base class when langchain-core is unavailable.""" +def _extract_tool_output(output: Any) -> Any: + """Extract serializable content from LangChain tool output. + + LangChain's on_tool_end receives the raw output which may be a + ToolMessage or other BaseMessage subclass that isn't directly JSON + serializable. Extract the .content string when available. + """ + if output is None: + return output + if isinstance(output, str): + return output + # Handle LangChain BaseMessage subclasses (ToolMessage, AIMessage, etc.) + if hasattr(output, "content"): + return output.content + return output + + _framework_name = "langchain" _framework_source = "handler" _framework_language = "python" @@ -149,7 +166,7 @@ def on_tool_start( ) def on_tool_end(self, output: Any, *, run_id: UUID, **_kwargs: Any) -> None: - self._on_tool_end(output=output, run_id=run_id) + self._on_tool_end(output=_extract_tool_output(output), run_id=run_id) def on_tool_error(self, error: BaseException, *, run_id: UUID, **_kwargs: Any) -> None: self._on_tool_error(error=error, run_id=run_id) @@ -284,7 +301,7 @@ async def on_tool_start( ) async def on_tool_end(self, output: Any, *, run_id: UUID, **_kwargs: Any) -> None: - self._on_tool_end(output=output, run_id=run_id) + self._on_tool_end(output=_extract_tool_output(output), run_id=run_id) async def on_tool_error(self, error: BaseException, *, run_id: UUID, **_kwargs: Any) -> None: self._on_tool_error(error=error, run_id=run_id) diff --git a/python-frameworks/langchain/tests/test_langchain_handler.py b/python-frameworks/langchain/tests/test_langchain_handler.py index 04bae49..5af3b89 100644 --- a/python-frameworks/langchain/tests/test_langchain_handler.py +++ b/python-frameworks/langchain/tests/test_langchain_handler.py @@ -18,6 +18,7 @@ create_sigil_langchain_handler, with_sigil_langchain_callbacks, ) +from sigil_sdk_langchain.handler import _extract_tool_output class _CapturingExporter: @@ -115,6 +116,56 @@ def test_langchain_sync_lifecycle_sets_framework_tags_and_metadata() -> None: client.shutdown() +def test_langchain_sync_lifecycle_extracts_anthropic_style_usage_and_stop_reason() -> None: + """ChatAnthropic puts token usage under 'usage' (not 'token_usage') and + stop reason under 'stop_reason' (not 'finish_reason').""" + exporter = _CapturingExporter() + client = _new_client(exporter) + + try: + run_id = uuid4() + handler = SigilLangChainHandler( + client=client, + agent_name="agent-langchain", + agent_version="v1", + provider_resolver="auto", + ) + + handler.on_chat_model_start( + {"name": "ChatAnthropic"}, + [[{"type": "human", "content": "hello"}]], + run_id=run_id, + invocation_params={"model": "claude-haiku-4-5-20251001"}, + ) + handler.on_llm_end( + { + "generations": [[{"text": "world"}]], + "llm_output": { + "id": "msg_01ABC", + "model": "claude-haiku-4-5-20251001", + "model_name": "claude-haiku-4-5-20251001", + "stop_reason": "end_turn", + "usage": { + "input_tokens": 42, + "output_tokens": 17, + }, + }, + }, + run_id=run_id, + ) + + client.flush() + generation = exporter.requests[0].generations[0] + assert generation.model.provider == "anthropic" + assert generation.model.name == "claude-haiku-4-5-20251001" + assert generation.usage.input_tokens == 42 + assert generation.usage.output_tokens == 17 + assert generation.usage.total_tokens == 59 + assert generation.stop_reason == "end_turn" + finally: + client.shutdown() + + def test_langchain_stream_lifecycle_uses_stream_mode_and_chunk_fallback() -> None: exporter = _CapturingExporter() client = _new_client(exporter) @@ -175,6 +226,47 @@ def _tracking_set_first_token_at(self, first_token_at): client.shutdown() +def test_langchain_generation_span_tracks_active_parent_span_and_export_lineage() -> None: + exporter = _CapturingExporter() + span_exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(span_exporter)) + tracer = provider.get_tracer("sigil-framework-test") + client = _new_client(exporter, tracer=tracer) + + try: + run_id = uuid4() + with tracer.start_as_current_span("framework.request"): + handler = SigilLangChainHandler(client=client) + handler.on_chat_model_start( + {"name": "ChatOpenAI"}, + [[{"type": "human", "content": "hello"}]], + run_id=run_id, + parent_run_id=uuid4(), + invocation_params={"model": "gpt-5"}, + metadata={"thread_id": "chain-thread-lineage-42"}, + ) + handler.on_llm_end( + {"generations": [[{"text": "world"}]], "llm_output": {"model_name": "gpt-5", "finish_reason": "stop"}}, + run_id=run_id, + ) + + client.flush() + generation = exporter.requests[0].generations[0] + spans = span_exporter.get_finished_spans() + parent_span = next(span for span in spans if span.name == "framework.request") + generation_span = next(span for span in spans if span.attributes.get("gen_ai.operation.name") == "generateText") + + assert generation_span.parent is not None + assert generation_span.parent.span_id == parent_span.context.span_id + assert generation_span.context.trace_id == parent_span.context.trace_id + assert generation.trace_id == generation_span.context.trace_id.to_bytes(16, "big").hex() + assert generation.span_id == generation_span.context.span_id.to_bytes(8, "big").hex() + finally: + client.shutdown() + provider.shutdown() + + def test_langchain_provider_resolution_supports_known_models_and_fallback() -> None: exporter = _CapturingExporter() client = _new_client(exporter) @@ -244,6 +336,45 @@ async def _run() -> None: client.shutdown() +def test_extract_tool_output_unwraps_message_content_and_preserves_plain_values() -> None: + class _FakeToolMessage: + def __init__(self, content): + self.content = content + + payload = {"temp_c": 18} + + assert _extract_tool_output(_FakeToolMessage("tool result text")) == "tool result text" + assert _extract_tool_output("plain string") == "plain string" + assert _extract_tool_output(None) is None + assert _extract_tool_output(payload) is payload + + +def test_langchain_tool_end_extracts_message_content_before_recording() -> None: + exporter = _CapturingExporter() + client = _new_client(exporter) + + class _FakeToolMessage: + def __init__(self, content): + self.content = content + + try: + handler = SigilLangChainHandler(client=client) + captured: dict[str, object] = {} + + def _capture_tool_end(*, output, run_id) -> None: + captured["output"] = output + captured["run_id"] = run_id + + handler._on_tool_end = _capture_tool_end # type: ignore[method-assign] + + run_id = uuid4() + handler.on_tool_end(_FakeToolMessage("tool result text"), run_id=run_id) + + assert captured == {"output": "tool result text", "run_id": run_id} + finally: + client.shutdown() + + def test_langchain_tool_chain_and_retriever_callbacks_emit_spans() -> None: exporter = _CapturingExporter() span_exporter = InMemorySpanExporter() @@ -333,6 +464,18 @@ def test_langchain_attach_helpers_preserve_existing_callbacks() -> None: client.shutdown() +def test_langchain_handler_explicitly_has_no_embedding_lifecycle() -> None: + exporter = _CapturingExporter() + client = _new_client(exporter) + try: + handler = SigilLangChainHandler(client=client) + assert not hasattr(handler, "on_embedding_start") + assert not hasattr(handler, "on_embedding_end") + assert not hasattr(handler, "on_embedding_error") + finally: + client.shutdown() + + def test_langchain_attach_helpers_do_not_duplicate_existing_sigil_handler() -> None: exporter = _CapturingExporter() client = _new_client(exporter) diff --git a/python-frameworks/langgraph/LICENSE b/python-frameworks/langgraph/LICENSE index ae8c60c..626a3ab 100644 --- a/python-frameworks/langgraph/LICENSE +++ b/python-frameworks/langgraph/LICENSE @@ -1,3 +1,3 @@ SPDX-License-Identifier: Apache-2.0 -See /sdks/LICENSE at repository root for full license text. +See /LICENSE at repository root for full license text. diff --git a/python-frameworks/langgraph/pyproject.toml b/python-frameworks/langgraph/pyproject.toml index a47c530..45e0b25 100644 --- a/python-frameworks/langgraph/pyproject.toml +++ b/python-frameworks/langgraph/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "sigil-sdk-langgraph" -version = "0.1.0" +version = "0.1.2" description = "LangGraph callback handlers for Sigil Python SDK" readme = "README.md" license = { file = "LICENSE" } requires-python = ">=3.10" dependencies = [ - "sigil-sdk>=0.1.0", + "sigil-sdk>=0.1.2", "langchain-core>=0.3.0", "langgraph>=0.2.0", ] diff --git a/python-frameworks/langgraph/tests/test_langgraph_handler.py b/python-frameworks/langgraph/tests/test_langgraph_handler.py index ba8d793..fb1c703 100644 --- a/python-frameworks/langgraph/tests/test_langgraph_handler.py +++ b/python-frameworks/langgraph/tests/test_langgraph_handler.py @@ -142,6 +142,47 @@ def test_langgraph_stream_lifecycle_uses_stream_mode_and_chunk_fallback() -> Non client.shutdown() +def test_langgraph_generation_span_tracks_active_parent_span_and_export_lineage() -> None: + exporter = _CapturingExporter() + span_exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(span_exporter)) + tracer = provider.get_tracer("sigil-framework-test") + client = _new_client(exporter, tracer=tracer) + + try: + run_id = uuid4() + with tracer.start_as_current_span("framework.request"): + handler = SigilLangGraphHandler(client=client) + handler.on_chat_model_start( + {"name": "ChatOpenAI"}, + [[{"type": "human", "content": "hello"}]], + run_id=run_id, + parent_run_id=uuid4(), + invocation_params={"model": "gpt-5"}, + metadata={"thread_id": "graph-thread-lineage-42", "langgraph_node": "answer_node"}, + ) + handler.on_llm_end( + {"generations": [[{"text": "world"}]], "llm_output": {"model_name": "gpt-5", "finish_reason": "stop"}}, + run_id=run_id, + ) + + client.flush() + generation = exporter.requests[0].generations[0] + spans = span_exporter.get_finished_spans() + parent_span = next(span for span in spans if span.name == "framework.request") + generation_span = next(span for span in spans if span.attributes.get("gen_ai.operation.name") == "generateText") + + assert generation_span.parent is not None + assert generation_span.parent.span_id == parent_span.context.span_id + assert generation_span.context.trace_id == parent_span.context.trace_id + assert generation.trace_id == generation_span.context.trace_id.to_bytes(16, "big").hex() + assert generation.span_id == generation_span.context.span_id.to_bytes(8, "big").hex() + finally: + client.shutdown() + provider.shutdown() + + def test_langgraph_provider_resolution_supports_known_models_and_fallback() -> None: exporter = _CapturingExporter() client = _new_client(exporter) @@ -299,3 +340,15 @@ def test_langgraph_attach_helpers_preserve_existing_callbacks() -> None: assert isinstance(callbacks[1], SigilLangGraphHandler) finally: client.shutdown() + + +def test_langgraph_handler_explicitly_has_no_embedding_lifecycle() -> None: + exporter = _CapturingExporter() + client = _new_client(exporter) + try: + handler = SigilLangGraphHandler(client=client) + assert not hasattr(handler, "on_embedding_start") + assert not hasattr(handler, "on_embedding_end") + assert not hasattr(handler, "on_embedding_error") + finally: + client.shutdown() diff --git a/python-frameworks/llamaindex/LICENSE b/python-frameworks/llamaindex/LICENSE index ae8c60c..626a3ab 100644 --- a/python-frameworks/llamaindex/LICENSE +++ b/python-frameworks/llamaindex/LICENSE @@ -1,3 +1,3 @@ SPDX-License-Identifier: Apache-2.0 -See /sdks/LICENSE at repository root for full license text. +See /LICENSE at repository root for full license text. diff --git a/python-frameworks/llamaindex/pyproject.toml b/python-frameworks/llamaindex/pyproject.toml index ed38814..066bd64 100644 --- a/python-frameworks/llamaindex/pyproject.toml +++ b/python-frameworks/llamaindex/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "sigil-sdk-llamaindex" -version = "0.1.0" +version = "0.1.2" description = "LlamaIndex callback handlers for Sigil Python SDK" readme = "README.md" license = { file = "LICENSE" } requires-python = ">=3.10" dependencies = [ - "sigil-sdk>=0.1.0", + "sigil-sdk>=0.1.2", "llama-index>=0.14.0", ] diff --git a/python-frameworks/llamaindex/tests/test_sigil_sdk_llamaindex.py b/python-frameworks/llamaindex/tests/test_sigil_sdk_llamaindex.py index 350fea8..6fccab0 100644 --- a/python-frameworks/llamaindex/tests/test_sigil_sdk_llamaindex.py +++ b/python-frameworks/llamaindex/tests/test_sigil_sdk_llamaindex.py @@ -7,6 +7,9 @@ from uuid import uuid4 from llama_index.core.callbacks.base_handler import BaseCallbackHandler +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter from sigil_sdk import Client, ClientConfig, GenerationExportConfig from sigil_sdk.models import ExportGenerationResult, ExportGenerationsResponse from sigil_sdk_llamaindex import ( @@ -35,9 +38,10 @@ def shutdown(self) -> None: return -def _new_client(exporter: _CapturingExporter) -> Client: +def _new_client(exporter: _CapturingExporter, tracer=None) -> Client: return Client( ClientConfig( + tracer=tracer, generation_export=GenerationExportConfig(batch_size=10, flush_interval=timedelta(seconds=60)), generation_exporter=exporter, ) @@ -180,6 +184,47 @@ def test_sigil_sdk_llamaindex_stream_mode_uses_chunks_when_output_missing() -> N client.shutdown() +def test_sigil_sdk_llamaindex_generation_span_tracks_active_parent_span_and_export_lineage() -> None: + exporter = _CapturingExporter() + span_exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(span_exporter)) + tracer = provider.get_tracer("sigil-framework-test") + client = _new_client(exporter, tracer=tracer) + + try: + run_id = uuid4() + with tracer.start_as_current_span("framework.request"): + handler = SigilLlamaIndexHandler(client=client, provider_resolver="auto") + handler.on_chat_model_start( + {"name": "ChatModel"}, + [[{"type": "human", "content": "hello"}]], + run_id=run_id, + parent_run_id=uuid4(), + invocation_params={"model": "gpt-5"}, + metadata={"conversation_id": "framework-conversation-lineage-42", "thread_id": "framework-thread-lineage-42"}, + ) + handler.on_llm_end( + {"generations": [[{"text": "world"}]], "llm_output": {"model_name": "gpt-5", "finish_reason": "stop"}}, + run_id=run_id, + ) + + client.flush() + generation = exporter.requests[0].generations[0] + spans = span_exporter.get_finished_spans() + parent_span = next(span for span in spans if span.name == "framework.request") + generation_span = next(span for span in spans if span.attributes.get("gen_ai.operation.name") == "generateText") + + assert generation_span.parent is not None + assert generation_span.parent.span_id == parent_span.context.span_id + assert generation_span.context.trace_id == parent_span.context.trace_id + assert generation.trace_id == generation_span.context.trace_id.to_bytes(16, "big").hex() + assert generation.span_id == generation_span.context.span_id.to_bytes(8, "big").hex() + finally: + client.shutdown() + provider.shutdown() + + def test_sigil_sdk_llamaindex_normalizes_extra_metadata() -> None: exporter = _CapturingExporter() client = _new_client(exporter) @@ -306,3 +351,15 @@ def end_trace(self, *_args, **_kwargs) -> None: assert generation.stop_reason == "stop" finally: client.shutdown() + + +def test_sigil_sdk_llamaindex_handler_explicitly_has_no_embedding_lifecycle() -> None: + exporter = _CapturingExporter() + client = _new_client(exporter) + try: + handler = SigilLlamaIndexHandler(client=client) + assert not hasattr(handler, "on_embedding_start") + assert not hasattr(handler, "on_embedding_end") + assert not hasattr(handler, "on_embedding_error") + finally: + client.shutdown() diff --git a/python-frameworks/openai-agents/LICENSE b/python-frameworks/openai-agents/LICENSE index ae8c60c..626a3ab 100644 --- a/python-frameworks/openai-agents/LICENSE +++ b/python-frameworks/openai-agents/LICENSE @@ -1,3 +1,3 @@ SPDX-License-Identifier: Apache-2.0 -See /sdks/LICENSE at repository root for full license text. +See /LICENSE at repository root for full license text. diff --git a/python-frameworks/openai-agents/pyproject.toml b/python-frameworks/openai-agents/pyproject.toml index c322874..51f6163 100644 --- a/python-frameworks/openai-agents/pyproject.toml +++ b/python-frameworks/openai-agents/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "sigil-sdk-openai-agents" -version = "0.1.0" +version = "0.1.2" description = "OpenAI Agents callback handlers for Sigil Python SDK" readme = "README.md" license = { file = "LICENSE" } requires-python = ">=3.10" dependencies = [ - "sigil-sdk>=0.1.0", + "sigil-sdk>=0.1.2", "openai-agents>=0.9.0", ] diff --git a/python-frameworks/openai-agents/tests/test_sigil_sdk_openai_agents.py b/python-frameworks/openai-agents/tests/test_sigil_sdk_openai_agents.py index 54e285d..5754f41 100644 --- a/python-frameworks/openai-agents/tests/test_sigil_sdk_openai_agents.py +++ b/python-frameworks/openai-agents/tests/test_sigil_sdk_openai_agents.py @@ -7,6 +7,9 @@ from uuid import uuid4 import pytest +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter from agents import RunHooks from sigil_sdk import Client, ClientConfig, GenerationExportConfig from sigil_sdk.models import ExportGenerationResult, ExportGenerationsResponse @@ -36,9 +39,10 @@ def shutdown(self) -> None: return -def _new_client(exporter: _CapturingExporter) -> Client: +def _new_client(exporter: _CapturingExporter, tracer=None) -> Client: return Client( ClientConfig( + tracer=tracer, generation_export=GenerationExportConfig(batch_size=10, flush_interval=timedelta(seconds=60)), generation_exporter=exporter, ) @@ -181,6 +185,47 @@ def test_sigil_sdk_openai_agents_stream_mode_uses_chunks_when_output_missing() - client.shutdown() +def test_sigil_sdk_openai_agents_generation_span_tracks_active_parent_span_and_export_lineage() -> None: + exporter = _CapturingExporter() + span_exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(span_exporter)) + tracer = provider.get_tracer("sigil-framework-test") + client = _new_client(exporter, tracer=tracer) + + try: + run_id = uuid4() + with tracer.start_as_current_span("framework.request"): + handler = SigilOpenAIAgentsHandler(client=client, provider_resolver="auto") + handler.on_chat_model_start( + {"name": "ChatModel"}, + [[{"type": "human", "content": "hello"}]], + run_id=run_id, + parent_run_id=uuid4(), + invocation_params={"model": "gpt-5"}, + metadata={"conversation_id": "framework-conversation-lineage-42", "thread_id": "framework-thread-lineage-42"}, + ) + handler.on_llm_end( + {"generations": [[{"text": "world"}]], "llm_output": {"model_name": "gpt-5", "finish_reason": "stop"}}, + run_id=run_id, + ) + + client.flush() + generation = exporter.requests[0].generations[0] + spans = span_exporter.get_finished_spans() + parent_span = next(span for span in spans if span.name == "framework.request") + generation_span = next(span for span in spans if span.attributes.get("gen_ai.operation.name") == "generateText") + + assert generation_span.parent is not None + assert generation_span.parent.span_id == parent_span.context.span_id + assert generation_span.context.trace_id == parent_span.context.trace_id + assert generation.trace_id == generation_span.context.trace_id.to_bytes(16, "big").hex() + assert generation.span_id == generation_span.context.span_id.to_bytes(8, "big").hex() + finally: + client.shutdown() + provider.shutdown() + + def test_sigil_sdk_openai_agents_normalizes_extra_metadata() -> None: exporter = _CapturingExporter() client = _new_client(exporter) @@ -299,3 +344,15 @@ async def _run() -> None: with_sigil_openai_agents_hooks({"hooks": [existing]}, client=client) finally: client.shutdown() + + +def test_sigil_sdk_openai_agents_handler_explicitly_has_no_embedding_lifecycle() -> None: + exporter = _CapturingExporter() + client = _new_client(exporter) + try: + handler = SigilOpenAIAgentsHandler(client=client) + assert not hasattr(handler, "on_embedding_start") + assert not hasattr(handler, "on_embedding_end") + assert not hasattr(handler, "on_embedding_error") + finally: + client.shutdown() diff --git a/python-providers/LICENSE b/python-providers/LICENSE index ae8c60c..626a3ab 100644 --- a/python-providers/LICENSE +++ b/python-providers/LICENSE @@ -1,3 +1,3 @@ SPDX-License-Identifier: Apache-2.0 -See /sdks/LICENSE at repository root for full license text. +See /LICENSE at repository root for full license text. diff --git a/python-providers/anthropic/LICENSE b/python-providers/anthropic/LICENSE index ae8c60c..626a3ab 100644 --- a/python-providers/anthropic/LICENSE +++ b/python-providers/anthropic/LICENSE @@ -1,3 +1,3 @@ SPDX-License-Identifier: Apache-2.0 -See /sdks/LICENSE at repository root for full license text. +See /LICENSE at repository root for full license text. diff --git a/python-providers/anthropic/pyproject.toml b/python-providers/anthropic/pyproject.toml index a5e1997..088470e 100644 --- a/python-providers/anthropic/pyproject.toml +++ b/python-providers/anthropic/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "sigil-sdk-anthropic" -version = "0.1.0" +version = "0.1.2" description = "Anthropic helper wrappers for Sigil Python SDK" readme = "README.md" license = { file = "LICENSE" } requires-python = ">=3.10" dependencies = [ - "sigil-sdk>=0.1.0", + "sigil-sdk>=0.1.2", "anthropic>=0.79.0,<1", ] diff --git a/python-providers/anthropic/tests/test_anthropic_provider.py b/python-providers/anthropic/tests/test_anthropic_provider.py index d3b5fb2..59477cf 100644 --- a/python-providers/anthropic/tests/test_anthropic_provider.py +++ b/python-providers/anthropic/tests/test_anthropic_provider.py @@ -8,6 +8,7 @@ from sigil_sdk import Client, ClientConfig, GenerationExportConfig from sigil_sdk.models import ExportGenerationResult, ExportGenerationsResponse +import sigil_sdk_anthropic from sigil_sdk_anthropic import AnthropicOptions, AnthropicStreamSummary, messages @@ -145,6 +146,45 @@ def test_anthropic_wrapper_propagates_provider_error_and_sets_call_error() -> No client.shutdown() +def test_anthropic_wrappers_tolerate_missing_provider_payload_fields() -> None: + exporter = _CapturingExporter() + client = _new_client(exporter) + try: + messages.create( + client, + _request(), + lambda _request: { + "id": "resp-malformed", + "model": "claude-sonnet-4-5-20260210", + "role": "assistant", + "content": [], + }, + ) + messages.stream( + client, + _request(), + lambda _request: AnthropicStreamSummary(events=[{"type": "content_block_delta", "delta": {"type": "text_delta"}}]), + ) + + client.flush() + generations = exporter.requests[0].generations + assert len(generations) == 2 + + sync_generation = generations[0] + assert sync_generation.mode.value == "SYNC" + assert sync_generation.response_id == "resp-malformed" + assert sync_generation.response_model == "claude-sonnet-4-5-20260210" + assert sync_generation.output == [] + assert sync_generation.stop_reason == "" + + stream_generation = generations[1] + assert stream_generation.mode.value == "STREAM" + assert stream_generation.response_model == "claude-sonnet-4-5" + assert stream_generation.output == [] + finally: + client.shutdown() + + def test_anthropic_mappers_use_strict_payloads_and_support_raw_artifacts() -> None: request = _request() response = _response() @@ -200,3 +240,9 @@ def test_anthropic_mapper_maps_thinking_disabled() -> None: mapped = messages.from_request_response(request, response) assert mapped.thinking_enabled is False + + +def test_anthropic_provider_explicitly_has_no_embeddings_surface() -> None: + assert "messages" in sigil_sdk_anthropic.__all__ + assert "embeddings" not in sigil_sdk_anthropic.__all__ + assert not hasattr(sigil_sdk_anthropic, "embeddings") diff --git a/python-providers/gemini/LICENSE b/python-providers/gemini/LICENSE index ae8c60c..626a3ab 100644 --- a/python-providers/gemini/LICENSE +++ b/python-providers/gemini/LICENSE @@ -1,3 +1,3 @@ SPDX-License-Identifier: Apache-2.0 -See /sdks/LICENSE at repository root for full license text. +See /LICENSE at repository root for full license text. diff --git a/python-providers/gemini/README.md b/python-providers/gemini/README.md index d1c8714..a1790af 100644 --- a/python-providers/gemini/README.md +++ b/python-providers/gemini/README.md @@ -8,6 +8,20 @@ pip install sigil-sdk sigil-sdk-gemini google-genai ``` +## Public API + +- Wrappers: + - `models.generate_content(...)` + - `models.generate_content_async(...)` + - `models.generate_content_stream(...)` + - `models.generate_content_stream_async(...)` + - `models.embed_content(...)` + - `models.embed_content_async(...)` +- Mappers: + - `models.from_request_response(...)` + - `models.from_stream(...)` + - `models.embedding_from_response(...)` + ## Wrapper Mode (Sync) ```python @@ -62,6 +76,22 @@ generation = models.from_request_response(model, contents, config, response) stream_generation = models.from_stream(model, contents, config, summary) ``` +## Embedding example + +```python +embedding_response = models.embed_content( + client, + "gemini-embedding-001", + contents, + None, + lambda req_model, req_contents, req_config: gemini_client.models.embed_content( + model=req_model, + contents=req_contents, + config=req_config, + ), +) +``` + ## Raw Provider Artifacts (Opt-In) ```python diff --git a/python-providers/gemini/pyproject.toml b/python-providers/gemini/pyproject.toml index b5f21b7..ab65cf0 100644 --- a/python-providers/gemini/pyproject.toml +++ b/python-providers/gemini/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "sigil-sdk-gemini" -version = "0.1.0" +version = "0.1.2" description = "Gemini helper wrappers for Sigil Python SDK" readme = "README.md" license = { file = "LICENSE" } requires-python = ">=3.10" dependencies = [ - "sigil-sdk>=0.1.0", + "sigil-sdk>=0.1.2", "google-genai>=1.63.0,<2", ] diff --git a/python-providers/gemini/tests/test_gemini_provider.py b/python-providers/gemini/tests/test_gemini_provider.py index f878346..7113cf4 100644 --- a/python-providers/gemini/tests/test_gemini_provider.py +++ b/python-providers/gemini/tests/test_gemini_provider.py @@ -195,6 +195,48 @@ def test_gemini_wrapper_propagates_provider_error_and_sets_call_error() -> None: client.shutdown() +def test_gemini_wrappers_tolerate_missing_provider_payload_fields() -> None: + exporter = _CapturingExporter() + client = _new_client(exporter) + try: + models.generate_content( + client, + "gemini-2.5-pro", + _contents(), + _config(), + lambda _model, _contents, _config: { + "response_id": "resp-malformed", + "model_version": "gemini-2.5-pro-001", + "candidates": [], + }, + ) + models.generate_content_stream( + client, + "gemini-2.5-pro", + _contents(), + _config(), + lambda _model, _contents, _config: GeminiStreamSummary(responses=[{"model_version": "gemini-2.5-pro-001"}]), + ) + + client.flush() + generations = exporter.requests[0].generations + assert len(generations) == 2 + + sync_generation = generations[0] + assert sync_generation.mode.value == "SYNC" + assert sync_generation.response_id == "resp-malformed" + assert sync_generation.response_model == "gemini-2.5-pro-001" + assert sync_generation.output == [] + assert sync_generation.stop_reason == "" + + stream_generation = generations[1] + assert stream_generation.mode.value == "STREAM" + assert stream_generation.response_model == "gemini-2.5-pro-001" + assert stream_generation.output == [] + finally: + client.shutdown() + + def test_gemini_embeddings_wrapper_records_span_and_skips_generation_export() -> None: exporter = _CapturingExporter() span_exporter = InMemorySpanExporter() diff --git a/python-providers/openai/LICENSE b/python-providers/openai/LICENSE index ae8c60c..626a3ab 100644 --- a/python-providers/openai/LICENSE +++ b/python-providers/openai/LICENSE @@ -1,3 +1,3 @@ SPDX-License-Identifier: Apache-2.0 -See /sdks/LICENSE at repository root for full license text. +See /LICENSE at repository root for full license text. diff --git a/python-providers/openai/README.md b/python-providers/openai/README.md index a8c1801..b845875 100644 --- a/python-providers/openai/README.md +++ b/python-providers/openai/README.md @@ -26,6 +26,11 @@ pip install sigil-sdk sigil-sdk-openai - `responses.from_request_response(...)` - `responses.from_stream(...)` +- Embeddings namespace: + - `embeddings.create(...)` + - `embeddings.create_async(...)` + - `embeddings.from_request_response(...)` + ## Integration styles - Strict wrappers: call OpenAI and record in one step. @@ -70,6 +75,21 @@ summary = chat.completions.stream( ) ``` +## Embeddings example + +```python +from sigil_sdk_openai import embeddings + +embedding_response = embeddings.create( + sigil, + { + "model": "text-embedding-3-small", + "input": ["hello", "world"], + }, + lambda request: provider.embeddings.create(**request), +) +``` + ## Manual instrumentation example (strict mapper) ```python diff --git a/python-providers/openai/pyproject.toml b/python-providers/openai/pyproject.toml index ec6756f..7009cc6 100644 --- a/python-providers/openai/pyproject.toml +++ b/python-providers/openai/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "sigil-sdk-openai" -version = "0.1.0" +version = "0.1.2" description = "OpenAI helper wrappers for Sigil Python SDK" readme = "README.md" license = { file = "LICENSE" } requires-python = ">=3.10" dependencies = [ - "sigil-sdk>=0.1.0", + "sigil-sdk>=0.1.2", "openai>=2.20.0,<3", ] diff --git a/python-providers/openai/sigil_sdk_openai/provider.py b/python-providers/openai/sigil_sdk_openai/provider.py index 16e3808..3f407bf 100644 --- a/python-providers/openai/sigil_sdk_openai/provider.py +++ b/python-providers/openai/sigil_sdk_openai/provider.py @@ -22,6 +22,7 @@ TokenUsage, ToolCall, ToolDefinition, + ToolResult, ) if TYPE_CHECKING: @@ -662,9 +663,20 @@ def _map_chat_request_messages(request: ChatCreateRequest | ChatStreamRequest) - mapped_role = MessageRole.TOOL parts: list[Part] = [] - if content: + if mapped_role != MessageRole.TOOL and content: parts.append(Part(kind=PartKind.TEXT, text=content)) + if mapped_role == MessageRole.TOOL: + tool_message = _tool_result_message( + _read(message, "content"), + tool_call_id=_as_str(_read(message, "tool_call_id")) or _as_str(_read(message, "toolCallId")) or _as_str(_read(message, "id")), + name=_as_str(_read(message, "name")), + is_error=_read(message, "is_error"), + ) + if tool_message is not None: + out.append(tool_message) + continue + if mapped_role == MessageRole.ASSISTANT: for part in _map_chat_tool_call_parts(_read(message, "tool_calls")): parts.append(part) @@ -831,11 +843,14 @@ def _map_responses_request(request: ResponsesCreateRequest | ResponsesStreamRequ continue if item_type == "function_call_output": - output_text = _extract_text(_read(item, "output")) or _json_text(_read(item, "output")) - if output_text: - input_messages.append( - Message(role=MessageRole.TOOL, parts=[Part(kind=PartKind.TEXT, text=output_text)]) - ) + tool_message = _tool_result_message( + _read(item, "output"), + tool_call_id=_as_str(_read(item, "call_id")) or _as_str(_read(item, "callId")), + name=_as_str(_read(item, "name")), + is_error=_read(item, "is_error"), + ) + if tool_message is not None: + input_messages.append(tool_message) continue if item_type == "message" or role: @@ -925,9 +940,14 @@ def _map_responses_output_items(value: Any) -> list[Message]: continue if item_type == "function_call_output": - output_text = _extract_text(_read(item, "output")) or _json_text(_read(item, "output")) - if output_text: - out.append(Message(role=MessageRole.TOOL, parts=[Part(kind=PartKind.TEXT, text=output_text)])) + tool_message = _tool_result_message( + _read(item, "output"), + tool_call_id=_as_str(_read(item, "call_id")) or _as_str(_read(item, "callId")), + name=_as_str(_read(item, "name")), + is_error=_read(item, "is_error"), + ) + if tool_message is not None: + out.append(tool_message) continue fallback = _extract_text(item) @@ -937,6 +957,27 @@ def _map_responses_output_items(value: Any) -> list[Message]: return out +def _tool_result_message(value: Any, *, tool_call_id: str, name: str, is_error: Any) -> Message | None: + content = _extract_text(value) + content_json = _json_bytes(value) + rendered_content = content or content_json.decode("utf-8") + if not rendered_content: + return None + + part = Part( + kind=PartKind.TOOL_RESULT, + tool_result=ToolResult( + tool_call_id=tool_call_id, + name=name, + content=rendered_content, + content_json=content_json, + is_error=is_error if isinstance(is_error, bool) else None, + ), + ) + part.metadata.provider_type = "tool_result" + return Message(role=MessageRole.TOOL, parts=[part]) + + def _map_responses_usage(value: Any) -> TokenUsage: usage = TokenUsage( input_tokens=_as_int(_read(value, "input_tokens")), diff --git a/python-providers/openai/tests/test_openai_provider.py b/python-providers/openai/tests/test_openai_provider.py index e6c50e1..0ee6d9b 100644 --- a/python-providers/openai/tests/test_openai_provider.py +++ b/python-providers/openai/tests/test_openai_provider.py @@ -279,6 +279,47 @@ def test_openai_wrappers_propagate_provider_error_and_set_call_error() -> None: client.shutdown() +def test_openai_wrappers_tolerate_missing_provider_payload_fields() -> None: + exporter = _CapturingExporter() + client = _new_client(exporter) + + try: + chat.completions.create( + client, + {"model": "gpt-5", "messages": [{"role": "user", "content": "hello"}]}, + lambda _request: { + "id": "resp-chat-malformed", + "model": "gpt-5", + "object": "chat.completion", + "created": 0, + "choices": [], + }, + ) + responses.stream( + client, + {"model": "gpt-5", "stream": True, "input": "stream this"}, + lambda _request: ResponsesStreamSummary(events=[{"type": "response.output_text.delta", "delta": 42}]), + ) + + client.flush() + generations = exporter.requests[0].generations + assert len(generations) == 2 + + chat_generation = generations[0] + assert chat_generation.mode.value == "SYNC" + assert chat_generation.response_id == "resp-chat-malformed" + assert chat_generation.response_model == "gpt-5" + assert chat_generation.output == [] + assert chat_generation.stop_reason == "" + + stream_generation = generations[1] + assert stream_generation.mode.value == "STREAM" + assert stream_generation.response_model == "gpt-5" + assert stream_generation.output[0].parts[0].text == "42" + finally: + client.shutdown() + + def test_embeddings_wrapper_records_span_and_skips_generation_export() -> None: exporter = _CapturingExporter() span_exporter = InMemorySpanExporter() @@ -391,7 +432,7 @@ def test_chat_mapper_filters_system_messages_and_supports_raw_artifacts() -> Non {"role": "system", "content": "system"}, {"role": "developer", "content": "developer"}, {"role": "user", "content": "hello"}, - {"role": "tool", "content": '{"ok":true}', "name": "tool-weather"}, + {"role": "tool", "tool_call_id": "call_weather", "content": '{"ok":true}', "name": "tool-weather"}, ], "tools": [ { @@ -433,6 +474,10 @@ def test_chat_mapper_filters_system_messages_and_supports_raw_artifacts() -> Non assert len(mapped_default.input) == 2 assert mapped_default.input[0].role.value == "user" assert mapped_default.input[1].role.value == "tool" + assert mapped_default.input[1].parts[0].kind.value == "tool_result" + assert mapped_default.input[1].parts[0].tool_result.tool_call_id == "call_weather" + assert mapped_default.input[1].parts[0].tool_result.name == "tool-weather" + assert mapped_default.input[1].parts[0].tool_result.content == '{"ok":true}' assert mapped_default.max_tokens == 320 assert mapped_default.temperature == 0.2 assert mapped_default.top_p == 0.85 @@ -457,7 +502,13 @@ def test_responses_mapper_maps_output_and_stream_fallback() -> None: "type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}], - } + }, + { + "type": "function_call_output", + "call_id": "call_weather", + "name": "weather", + "output": {"temp_c": 18}, + }, ], "max_output_tokens": 300, "reasoning": {"effort": "medium", "max_output_tokens": 640}, @@ -482,6 +533,13 @@ def test_responses_mapper_maps_output_and_stream_fallback() -> None: "name": "weather", "arguments": '{"city":"Paris"}', }, + { + "id": "result-1", + "type": "function_call_output", + "call_id": "call_weather", + "name": "weather", + "output": {"temp_c": 18}, + }, ], "parallel_tool_calls": False, "temperature": 1, @@ -502,12 +560,21 @@ def test_responses_mapper_maps_output_and_stream_fallback() -> None: mapped = responses.from_request_response(request, response) assert mapped.response_model == "gpt-5" + assert len(mapped.input) == 2 + assert mapped.input[1].role.value == "tool" + assert mapped.input[1].parts[0].kind.value == "tool_result" + assert mapped.input[1].parts[0].tool_result.tool_call_id == "call_weather" + assert mapped.input[1].parts[0].tool_result.content_json == b'{"temp_c":18}' assert mapped.max_tokens == 300 assert mapped.stop_reason == "stop" assert mapped.thinking_enabled is True assert mapped.metadata["sigil.gen_ai.request.thinking.budget_tokens"] == 640 assert mapped.usage.total_tokens == 100 - assert mapped.output + assert len(mapped.output) == 3 + assert mapped.output[2].role.value == "tool" + assert mapped.output[2].parts[0].kind.value == "tool_result" + assert mapped.output[2].parts[0].tool_result.tool_call_id == "call_weather" + assert mapped.output[2].parts[0].tool_result.content_json == b'{"temp_c":18}' streamed = responses.from_stream( {**request, "stream": True}, diff --git a/python/LICENSE b/python/LICENSE index ae8c60c..626a3ab 100644 --- a/python/LICENSE +++ b/python/LICENSE @@ -1,3 +1,3 @@ SPDX-License-Identifier: Apache-2.0 -See /sdks/LICENSE at repository root for full license text. +See /LICENSE at repository root for full license text. diff --git a/python/README.md b/python/README.md index 0faf8a9..3caa9ae 100644 --- a/python/README.md +++ b/python/README.md @@ -14,6 +14,20 @@ Use this package when you want: pip install sigil-sdk ``` +## Validation + +Run the shared core conformance suite for the Python SDK from the repo root: + +```bash +mise run test:py:sdk-conformance +``` + +Run the cross-language aggregate core conformance suite from the repo root: + +```bash +mise run sdk:conformance +``` + Optional provider helper packages: ```bash @@ -284,6 +298,7 @@ Auth is resolved for `generation_export`. - `mode="none"` - `mode="tenant"` (requires `tenant_id`, injects `X-Scope-OrgID`) - `mode="bearer"` (requires `bearer_token`, injects `Authorization: Bearer `) +- `mode="basic"` (requires `basic_password` + `basic_user` or `tenant_id`, injects `Authorization: Basic `; also injects `X-Scope-OrgID` when `tenant_id` is set — for self-hosted multi-tenancy only, not needed for Grafana Cloud) Invalid mode/field combinations fail fast in `resolve_config(...)`. @@ -302,6 +317,38 @@ cfg = ClientConfig( ) ``` +### Grafana Cloud auth (basic) + +For Grafana Cloud, use `basic` auth mode. The username is your Grafana Cloud instance/tenant ID and the password is your Grafana Cloud API key: + +```python +import os +from sigil_sdk import AuthConfig, ClientConfig, GenerationExportConfig + +cfg = ClientConfig( + generation_export=GenerationExportConfig( + protocol="http", + endpoint="https://.grafana.net/api/v1/generations:export", + auth=AuthConfig( + mode="basic", + tenant_id=os.environ["GRAFANA_CLOUD_INSTANCE_ID"], + basic_password=os.environ["GRAFANA_CLOUD_API_KEY"], + ), + ), +) +``` + +If your deployment requires a distinct username, set `basic_user` explicitly: + +```python +auth=AuthConfig( + mode="basic", + tenant_id=os.environ["GRAFANA_CLOUD_INSTANCE_ID"], + basic_user=os.environ["GRAFANA_CLOUD_INSTANCE_ID"], + basic_password=os.environ["GRAFANA_CLOUD_API_KEY"], +) +``` + ## Env-secret wiring example The SDK does not auto-load env vars. Resolve env values in your application and pass them into config explicitly. @@ -319,7 +366,8 @@ if gen_token: Common topology: -- Generations direct to Sigil: generation `tenant` mode. +- Grafana Cloud: generation `basic` mode with instance ID and API key. +- Self-hosted direct to Sigil: generation `tenant` mode. - Traces/metrics via OTEL Collector/Alloy: configure exporters in your app OTEL SDK setup. - Enterprise proxy: generation `bearer` mode to proxy; proxy authenticates and forwards tenant header upstream. diff --git a/python/pyproject.toml b/python/pyproject.toml index efb4b30..fc5144f 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sigil-sdk" -version = "0.1.0" +version = "0.1.2" description = "Grafana Sigil Python SDK" readme = "README.md" license = { file = "LICENSE" } diff --git a/python/scripts/generate_proto.sh b/python/scripts/generate_proto.sh index 7bb246d..ecabe0f 100755 --- a/python/scripts/generate_proto.sh +++ b/python/scripts/generate_proto.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" -SDK_DIR="${ROOT_DIR}/sdks/python" +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SDK_DIR="${ROOT_DIR}/python" OUT_DIR="${SDK_DIR}/sigil_sdk/internal/gen" PYTHON_BIN="${PYTHON_BIN:-python3}" @@ -20,11 +20,11 @@ fi PROTO_INCLUDE="$(${PYTHON_BIN} -c 'import pathlib, grpc_tools; print(pathlib.Path(grpc_tools.__file__).parent / "_proto")')" "${PYTHON_BIN}" -m grpc_tools.protoc \ - -I"${ROOT_DIR}/sigil/proto" \ + -I"${ROOT_DIR}/proto" \ -I"${PROTO_INCLUDE}" \ --python_out="${OUT_DIR}" \ --grpc_python_out="${OUT_DIR}" \ - "${ROOT_DIR}/sigil/proto/sigil/v1/generation_ingest.proto" + "${ROOT_DIR}/proto/sigil/v1/generation_ingest.proto" # The grpc plugin emits absolute import paths; normalize to relative package import. TMP_FILE="$(mktemp)" diff --git a/python/setup.py b/python/setup.py new file mode 100644 index 0000000..b024da8 --- /dev/null +++ b/python/setup.py @@ -0,0 +1,4 @@ +from setuptools import setup + + +setup() diff --git a/python/sigil_sdk/__init__.py b/python/sigil_sdk/__init__.py index 4468e0f..b50baa1 100644 --- a/python/sigil_sdk/__init__.py +++ b/python/sigil_sdk/__init__.py @@ -3,12 +3,16 @@ from .client import Client from .config import ApiConfig, AuthConfig, ClientConfig, EmbeddingCaptureConfig, GenerationExportConfig, default_config from .context import ( + conversation_title_from_context, conversation_id_from_context, agent_name_from_context, agent_version_from_context, + user_id_from_context, with_agent_name, with_agent_version, with_conversation_id, + with_conversation_title, + with_user_id, ) from .errors import ( ClientShutdownError, @@ -98,15 +102,19 @@ "agent_version_from_context", "assistant_text_message", "conversation_id_from_context", + "conversation_title_from_context", "text_part", "thinking_part", "tool_call_part", "tool_result_message", "tool_result_part", + "user_id_from_context", "user_text_message", "with_agent_name", "with_agent_version", "with_conversation_id", + "with_conversation_title", + "with_user_id", "default_config", "validate_embedding_result", "validate_embedding_start", diff --git a/python/sigil_sdk/client.py b/python/sigil_sdk/client.py index 8e0e8f0..b17aab0 100644 --- a/python/sigil_sdk/client.py +++ b/python/sigil_sdk/client.py @@ -19,7 +19,13 @@ from opentelemetry.trace import Span, SpanKind, Status, StatusCode from .config import ClientConfig, resolve_config -from .context import agent_name_from_context, agent_version_from_context, conversation_id_from_context +from .context import ( + agent_name_from_context, + agent_version_from_context, + conversation_id_from_context, + conversation_title_from_context, + user_id_from_context, +) from .errors import ( ClientShutdownError, EnqueueError, @@ -61,6 +67,8 @@ _span_attr_framework_langgraph_node = "sigil.framework.langgraph.node" _span_attr_framework_event_id = "sigil.framework.event_id" _span_attr_conversation_id = "gen_ai.conversation.id" +_span_attr_conversation_title = "sigil.conversation.title" +_span_attr_user_id = "user.id" _span_attr_agent_name = "gen_ai.agent.name" _span_attr_agent_version = "gen_ai.agent.version" _span_attr_error_type = "error.type" @@ -117,6 +125,8 @@ _instrumentation_name = "github.com/grafana/sigil/sdks/python" _sdk_name = "sdk-python" _default_embedding_operation_name = "embeddings" +_metadata_user_id_key = "sigil.user.id" +_metadata_legacy_user_id_key = "user.id" class Client: @@ -226,9 +236,13 @@ def start_tool_execution(self, start: ToolExecutionStart) -> "ToolExecutionRecor if seed.tool_name == "": return NoopToolExecutionRecorder() + seed.conversation_title = seed.conversation_title.strip() if seed.conversation_id == "": conversation_id = conversation_id_from_context() or "" seed.conversation_id = conversation_id + if seed.conversation_title == "": + conversation_title = conversation_title_from_context() or "" + seed.conversation_title = conversation_title.strip() if seed.agent_name == "": agent_name = agent_name_from_context() or "" seed.agent_name = agent_name @@ -379,8 +393,14 @@ def _start_generation(self, start: GenerationStart, default_mode: GenerationMode if seed.operation_name == "": seed.operation_name = _default_operation_name(seed.mode) + seed.conversation_title = seed.conversation_title.strip() + seed.user_id = seed.user_id.strip() if seed.conversation_id == "": seed.conversation_id = conversation_id_from_context() or "" + if seed.conversation_title == "": + seed.conversation_title = (conversation_title_from_context() or "").strip() + if seed.user_id == "": + seed.user_id = (user_id_from_context() or "").strip() if seed.agent_name == "": seed.agent_name = agent_name_from_context() or "" if seed.agent_version == "": @@ -399,6 +419,8 @@ def _start_generation(self, start: GenerationStart, default_mode: GenerationMode Generation( id=seed.id, conversation_id=seed.conversation_id, + conversation_title=seed.conversation_title, + user_id=seed.user_id, agent_name=seed.agent_name, agent_version=seed.agent_version, mode=seed.mode, @@ -631,8 +653,9 @@ def _record_tool_execution_metrics( duration_seconds, attributes={ _span_attr_operation_name: "execute_tool", - _span_attr_provider_name: "", - _span_attr_request_model: seed.tool_name, + _span_attr_provider_name: seed.request_provider.strip() if seed.request_provider else "", + _span_attr_request_model: seed.request_model.strip() if seed.request_model else "", + _span_attr_tool_name: seed.tool_name.strip(), _span_attr_agent_name: seed.agent_name, _span_attr_error_type: error_type, _span_attr_error_category: error_category, @@ -792,6 +815,10 @@ def _normalize_generation(self, raw: Generation, completed_at: datetime, call_er if generation.conversation_id == "": generation.conversation_id = self.seed.conversation_id + if generation.conversation_title == "": + generation.conversation_title = self.seed.conversation_title + if generation.user_id == "": + generation.user_id = self.seed.user_id if generation.agent_name == "": generation.agent_name = self.seed.agent_name if generation.agent_version == "": @@ -837,6 +864,22 @@ def _normalize_generation(self, raw: Generation, completed_at: datetime, call_er merged_metadata.update(generation.metadata) generation.metadata = merged_metadata + conversation_title = generation.conversation_title.strip() + if conversation_title == "": + conversation_title = _metadata_string_value(generation.metadata, _span_attr_conversation_title) or "" + generation.conversation_title = conversation_title + if conversation_title != "": + generation.metadata[_span_attr_conversation_title] = conversation_title + + user_id = generation.user_id.strip() + if user_id == "": + user_id = _metadata_string_value(generation.metadata, _metadata_user_id_key) or "" + if user_id == "": + user_id = _metadata_string_value(generation.metadata, _metadata_legacy_user_id_key) or "" + generation.user_id = user_id + if user_id != "": + generation.metadata[_metadata_user_id_key] = user_id + generation.started_at = _to_utc(generation.started_at) if generation.started_at is not None else self.started_at generation.completed_at = _to_utc(generation.completed_at) if generation.completed_at is not None else completed_at @@ -1128,6 +1171,10 @@ def _set_generation_span_attributes(span: Span, generation: Generation) -> None: span.set_attribute(_span_attr_generation_id, generation.id) if generation.conversation_id: span.set_attribute(_span_attr_conversation_id, generation.conversation_id) + if generation.conversation_title: + span.set_attribute(_span_attr_conversation_title, generation.conversation_title) + if generation.user_id: + span.set_attribute(_span_attr_user_id, generation.user_id) if generation.agent_name: span.set_attribute(_span_attr_agent_name, generation.agent_name) if generation.agent_version: @@ -1252,10 +1299,16 @@ def _set_tool_span_attributes(span: Span, start: ToolExecutionStart) -> None: span.set_attribute(_span_attr_tool_description, start.tool_description) if start.conversation_id: span.set_attribute(_span_attr_conversation_id, start.conversation_id) + if start.conversation_title: + span.set_attribute(_span_attr_conversation_title, start.conversation_title) if start.agent_name: span.set_attribute(_span_attr_agent_name, start.agent_name) if start.agent_version: span.set_attribute(_span_attr_agent_version, start.agent_version) + if start.request_provider: + span.set_attribute(_span_attr_provider_name, start.request_provider) + if start.request_model: + span.set_attribute(_span_attr_request_model, start.request_model) def _thinking_budget_from_metadata(metadata: dict[str, Any]) -> int | None: diff --git a/python/sigil_sdk/config.py b/python/sigil_sdk/config.py index fc1dd66..d60395a 100644 --- a/python/sigil_sdk/config.py +++ b/python/sigil_sdk/config.py @@ -2,6 +2,7 @@ from __future__ import annotations +import base64 from dataclasses import dataclass, field from datetime import datetime, timedelta import logging @@ -25,6 +26,8 @@ class AuthConfig: mode: str = "none" tenant_id: str = "" bearer_token: str = "" + basic_user: str = "" + basic_password: str = "" @dataclass(slots=True) @@ -139,8 +142,10 @@ def _resolve_export_headers(headers: dict[str, str], auth: AuthConfig, label: st out = dict(headers) if mode == "none": - if tenant_id or bearer_token: - raise ValueError(f"{label} auth mode 'none' does not allow tenant_id or bearer_token") + basic_user = auth.basic_user.strip() + basic_password = auth.basic_password.strip() + if tenant_id or bearer_token or basic_user or basic_password: + raise ValueError(f"{label} auth mode 'none' does not allow credentials") return out if mode == "tenant": if not tenant_id: @@ -158,6 +163,21 @@ def _resolve_export_headers(headers: dict[str, str], auth: AuthConfig, label: st if not _has_header(out, AUTHORIZATION_HEADER): out[AUTHORIZATION_HEADER] = _format_bearer_token(bearer_token) return out + if mode == "basic": + password = auth.basic_password.strip() + if not password: + raise ValueError(f"{label} auth mode 'basic' requires basic_password") + user = auth.basic_user.strip() + if not user: + user = tenant_id + if not user: + raise ValueError(f"{label} auth mode 'basic' requires basic_user or tenant_id") + if not _has_header(out, AUTHORIZATION_HEADER): + creds = base64.b64encode(f"{user}:{password}".encode()).decode() + out[AUTHORIZATION_HEADER] = f"Basic {creds}" + if tenant_id and not _has_header(out, TENANT_HEADER): + out[TENANT_HEADER] = tenant_id + return out raise ValueError(f"unsupported {label} auth mode {auth.mode!r}") diff --git a/python/sigil_sdk/context.py b/python/sigil_sdk/context.py index f4a03d5..285a9ee 100644 --- a/python/sigil_sdk/context.py +++ b/python/sigil_sdk/context.py @@ -8,6 +8,8 @@ _conversation_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("sigil_conversation_id", default=None) +_conversation_title: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("sigil_conversation_title", default=None) +_user_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("sigil_user_id", default=None) _agent_name: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("sigil_agent_name", default=None) _agent_version: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("sigil_agent_version", default=None) @@ -23,6 +25,28 @@ def with_conversation_id(conversation_id: str) -> Iterator[None]: _conversation_id.reset(token) +@contextmanager +def with_conversation_title(conversation_title: str) -> Iterator[None]: + """Sets conversation title within a context block.""" + + token = _conversation_title.set(conversation_title) + try: + yield + finally: + _conversation_title.reset(token) + + +@contextmanager +def with_user_id(user_id: str) -> Iterator[None]: + """Sets user id within a context block.""" + + token = _user_id.set(user_id) + try: + yield + finally: + _user_id.reset(token) + + @contextmanager def with_agent_name(agent_name: str) -> Iterator[None]: """Sets agent name within a context block.""" @@ -61,3 +85,15 @@ def agent_version_from_context() -> Optional[str]: """Returns the current agent version from context variables.""" return _agent_version.get() + + +def conversation_title_from_context() -> Optional[str]: + """Returns the current conversation title from context variables.""" + + return _conversation_title.get() + + +def user_id_from_context() -> Optional[str]: + """Returns the current user id from context variables.""" + + return _user_id.get() diff --git a/python/sigil_sdk/exporters/grpc.py b/python/sigil_sdk/exporters/grpc.py index 6eefbe3..d8cea91 100644 --- a/python/sigil_sdk/exporters/grpc.py +++ b/python/sigil_sdk/exporters/grpc.py @@ -17,7 +17,7 @@ class GRPCGenerationExporter: def __init__(self, endpoint: str, headers: dict[str, str] | None = None, insecure: bool = False) -> None: host, implicit_insecure = _parse_endpoint(endpoint) - self._headers = list((headers or {}).items()) + self._headers = [(k.lower(), v) for k, v in (headers or {}).items()] self._channel = grpc.insecure_channel(host) if (insecure or implicit_insecure) else grpc.secure_channel(host, grpc.ssl_channel_credentials()) self._stub = sigil_pb2_grpc.GenerationIngestServiceStub(self._channel) diff --git a/python/sigil_sdk/framework_handler.py b/python/sigil_sdk/framework_handler.py index 5270b5a..c3d935e 100644 --- a/python/sigil_sdk/framework_handler.py +++ b/python/sigil_sdk/framework_handler.py @@ -304,9 +304,11 @@ def _on_llm_end(self, *, response: Any, run_id: UUID) -> None: return try: - usage = _map_usage(_read(_read(response, "llm_output"), "token_usage")) - response_model = _as_str(_read(_read(response, "llm_output"), "model_name")) - stop_reason = _as_str(_read(_read(response, "llm_output"), "finish_reason")) + llm_output = _read(response, "llm_output") + raw_usage = _read(llm_output, "token_usage") or _read(llm_output, "usage") + usage = _map_usage(raw_usage) + response_model = _as_str(_read(llm_output, "model_name")) + stop_reason = _as_str(_read(llm_output, "finish_reason") or _read(llm_output, "stop_reason")) output_messages: list[Message] = [] if run_state.capture_outputs: diff --git a/python/sigil_sdk/internal/gen/sigil/v1/generation_ingest_pb2.py b/python/sigil_sdk/internal/gen/sigil/v1/generation_ingest_pb2.py index 376dd4b..6d6539f 100644 --- a/python/sigil_sdk/internal/gen/sigil/v1/generation_ingest_pb2.py +++ b/python/sigil_sdk/internal/gen/sigil/v1/generation_ingest_pb2.py @@ -26,7 +26,7 @@ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n sigil/v1/generation_ingest.proto\x12\x08sigil.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"E\n\x18\x45xportGenerationsRequest\x12)\n\x0bgenerations\x18\x01 \x03(\x0b\x32\x14.sigil.v1.Generation\"N\n\x19\x45xportGenerationsResponse\x12\x31\n\x07results\x18\x01 \x03(\x0b\x32 .sigil.v1.ExportGenerationResult\"P\n\x16\x45xportGenerationResult\x12\x15\n\rgeneration_id\x18\x01 \x01(\t\x12\x10\n\x08\x61\x63\x63\x65pted\x18\x02 \x01(\x08\x12\r\n\x05\x65rror\x18\x03 \x01(\t\"*\n\x08ModelRef\x12\x10\n\x08provider\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\"%\n\x0cPartMetadata\x12\x15\n\rprovider_type\x18\x01 \x01(\t\"8\n\x08ToolCall\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x12\n\ninput_json\x18\x03 \x01(\x0c\"i\n\nToolResult\x12\x14\n\x0ctool_call_id\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0f\n\x07\x63ontent\x18\x03 \x01(\t\x12\x14\n\x0c\x63ontent_json\x18\x04 \x01(\x0c\x12\x10\n\x08is_error\x18\x05 \x01(\x08\"\xb5\x01\n\x04Part\x12(\n\x08metadata\x18\x01 \x01(\x0b\x32\x16.sigil.v1.PartMetadata\x12\x0e\n\x04text\x18\x02 \x01(\tH\x00\x12\x12\n\x08thinking\x18\x03 \x01(\tH\x00\x12\'\n\ttool_call\x18\x04 \x01(\x0b\x32\x12.sigil.v1.ToolCallH\x00\x12+\n\x0btool_result\x18\x05 \x01(\x0b\x32\x14.sigil.v1.ToolResultH\x00\x42\t\n\x07payload\"[\n\x07Message\x12#\n\x04role\x18\x01 \x01(\x0e\x32\x15.sigil.v1.MessageRole\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x1d\n\x05parts\x18\x03 \x03(\x0b\x32\x0e.sigil.v1.Part\"\\\n\x0eToolDefinition\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x0c\n\x04type\x18\x03 \x01(\t\x12\x19\n\x11input_schema_json\x18\x04 \x01(\x0c\"\xac\x01\n\nTokenUsage\x12\x14\n\x0cinput_tokens\x18\x01 \x01(\x03\x12\x15\n\routput_tokens\x18\x02 \x01(\x03\x12\x14\n\x0ctotal_tokens\x18\x03 \x01(\x03\x12\x1f\n\x17\x63\x61\x63he_read_input_tokens\x18\x04 \x01(\x03\x12 \n\x18\x63\x61\x63he_write_input_tokens\x18\x05 \x01(\x03\x12\x18\n\x10reasoning_tokens\x18\x06 \x01(\x03\"\x85\x01\n\x08\x41rtifact\x12$\n\x04kind\x18\x01 \x01(\x0e\x32\x16.sigil.v1.ArtifactKind\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x14\n\x0c\x63ontent_type\x18\x03 \x01(\t\x12\x0f\n\x07payload\x18\x04 \x01(\x0c\x12\x11\n\trecord_id\x18\x05 \x01(\t\x12\x0b\n\x03uri\x18\x06 \x01(\t\"\xc3\x07\n\nGeneration\x12\n\n\x02id\x18\x01 \x01(\t\x12\x17\n\x0f\x63onversation_id\x18\x02 \x01(\t\x12\x16\n\x0eoperation_name\x18\x03 \x01(\t\x12&\n\x04mode\x18\x04 \x01(\x0e\x32\x18.sigil.v1.GenerationMode\x12\x10\n\x08trace_id\x18\x05 \x01(\t\x12\x0f\n\x07span_id\x18\x06 \x01(\t\x12!\n\x05model\x18\x07 \x01(\x0b\x32\x12.sigil.v1.ModelRef\x12\x13\n\x0bresponse_id\x18\x08 \x01(\t\x12\x16\n\x0eresponse_model\x18\t \x01(\t\x12\x15\n\rsystem_prompt\x18\n \x01(\t\x12 \n\x05input\x18\x0b \x03(\x0b\x32\x11.sigil.v1.Message\x12!\n\x06output\x18\x0c \x03(\x0b\x32\x11.sigil.v1.Message\x12\'\n\x05tools\x18\r \x03(\x0b\x32\x18.sigil.v1.ToolDefinition\x12#\n\x05usage\x18\x0e \x01(\x0b\x32\x14.sigil.v1.TokenUsage\x12\x13\n\x0bstop_reason\x18\x0f \x01(\t\x12.\n\nstarted_at\x18\x10 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x30\n\x0c\x63ompleted_at\x18\x11 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12,\n\x04tags\x18\x12 \x03(\x0b\x32\x1e.sigil.v1.Generation.TagsEntry\x12)\n\x08metadata\x18\x13 \x01(\x0b\x32\x17.google.protobuf.Struct\x12)\n\rraw_artifacts\x18\x14 \x03(\x0b\x32\x12.sigil.v1.Artifact\x12\x12\n\ncall_error\x18\x15 \x01(\t\x12\x12\n\nagent_name\x18\x16 \x01(\t\x12\x15\n\ragent_version\x18\x17 \x01(\t\x12\x17\n\nmax_tokens\x18\x18 \x01(\x03H\x00\x88\x01\x01\x12\x18\n\x0btemperature\x18\x19 \x01(\x01H\x01\x88\x01\x01\x12\x12\n\x05top_p\x18\x1a \x01(\x01H\x02\x88\x01\x01\x12\x18\n\x0btool_choice\x18\x1b \x01(\tH\x03\x88\x01\x01\x12\x1d\n\x10thinking_enabled\x18\x1c \x01(\x08H\x04\x88\x01\x01\x1a+\n\tTagsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\r\n\x0b_max_tokensB\x0e\n\x0c_temperatureB\x08\n\x06_top_pB\x0e\n\x0c_tool_choiceB\x13\n\x11_thinking_enabled*g\n\x0eGenerationMode\x12\x1f\n\x1bGENERATION_MODE_UNSPECIFIED\x10\x00\x12\x18\n\x14GENERATION_MODE_SYNC\x10\x01\x12\x1a\n\x16GENERATION_MODE_STREAM\x10\x02*u\n\x0bMessageRole\x12\x1c\n\x18MESSAGE_ROLE_UNSPECIFIED\x10\x00\x12\x15\n\x11MESSAGE_ROLE_USER\x10\x01\x12\x1a\n\x16MESSAGE_ROLE_ASSISTANT\x10\x02\x12\x15\n\x11MESSAGE_ROLE_TOOL\x10\x03*\x9f\x01\n\x0c\x41rtifactKind\x12\x1d\n\x19\x41RTIFACT_KIND_UNSPECIFIED\x10\x00\x12\x19\n\x15\x41RTIFACT_KIND_REQUEST\x10\x01\x12\x1a\n\x16\x41RTIFACT_KIND_RESPONSE\x10\x02\x12\x17\n\x13\x41RTIFACT_KIND_TOOLS\x10\x03\x12 \n\x1c\x41RTIFACT_KIND_PROVIDER_EVENT\x10\x04\x32w\n\x17GenerationIngestService\x12\\\n\x11\x45xportGenerations\x12\".sigil.v1.ExportGenerationsRequest\x1a#.sigil.v1.ExportGenerationsResponseB>ZZ None: assert cfg.generation_export.headers["x-scope-orgid"] == "override-tenant" +def test_resolve_config_basic_auth_with_tenant_id() -> None: + cfg = resolve_config( + ClientConfig( + generation_export=GenerationExportConfig( + auth=AuthConfig(mode="basic", tenant_id="42", basic_password="secret"), + ), + ) + ) + + expected = "Basic " + base64.b64encode(b"42:secret").decode() + assert cfg.generation_export.headers["Authorization"] == expected + assert cfg.generation_export.headers["X-Scope-OrgID"] == "42" + + +def test_resolve_config_basic_auth_with_explicit_user() -> None: + cfg = resolve_config( + ClientConfig( + generation_export=GenerationExportConfig( + auth=AuthConfig( + mode="basic", + tenant_id="42", + basic_user="probe-user", + basic_password="secret", + ), + ), + ) + ) + + expected = "Basic " + base64.b64encode(b"probe-user:secret").decode() + assert cfg.generation_export.headers["Authorization"] == expected + assert cfg.generation_export.headers["X-Scope-OrgID"] == "42" + + +def test_resolve_config_basic_auth_explicit_header_wins() -> None: + cfg = resolve_config( + ClientConfig( + generation_export=GenerationExportConfig( + headers={ + "Authorization": "Basic override", + "X-Scope-OrgID": "override-tenant", + }, + auth=AuthConfig(mode="basic", tenant_id="42", basic_password="secret"), + ), + ) + ) + + assert cfg.generation_export.headers["Authorization"] == "Basic override" + assert cfg.generation_export.headers["X-Scope-OrgID"] == "override-tenant" + + @pytest.mark.parametrize( "auth", [ @@ -40,9 +92,13 @@ def test_resolve_config_keeps_explicit_headers() -> None: AuthConfig(mode="bearer"), AuthConfig(mode="none", tenant_id="tenant-a"), AuthConfig(mode="none", bearer_token="token"), + AuthConfig(mode="none", basic_user="user"), + AuthConfig(mode="none", basic_password="secret"), AuthConfig(mode="tenant", tenant_id="tenant-a", bearer_token="token"), AuthConfig(mode="bearer", tenant_id="tenant-a", bearer_token="token"), AuthConfig(mode="unknown", tenant_id="tenant-a"), + AuthConfig(mode="basic"), + AuthConfig(mode="basic", basic_password="secret"), ], ) def test_resolve_config_rejects_invalid_auth_combinations(auth: AuthConfig) -> None: diff --git a/python/tests/test_conformance.py b/python/tests/test_conformance.py new file mode 100644 index 0000000..bec1b0a --- /dev/null +++ b/python/tests/test_conformance.py @@ -0,0 +1,669 @@ +"""Core conformance suite for the Sigil Python SDK.""" + +from __future__ import annotations + +import concurrent.futures +import copy +from contextlib import nullcontext +import json +from http.server import BaseHTTPRequestHandler, HTTPServer +import socket +import threading +from datetime import datetime, timedelta, timezone +from typing import Any + +import grpc +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import InMemoryMetricReader +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + +from sigil_sdk import ( + ApiConfig, + Client, + ClientConfig, + ConversationRatingInput, + ConversationRatingValue, + EmbeddingResult, + EmbeddingStart, + Generation, + GenerationExportConfig, + GenerationMode, + GenerationStart, + Message, + MessageRole, + ModelRef, + Part, + PartKind, + TokenUsage, + ToolCall, + ToolDefinition, + ToolExecutionEnd, + ToolExecutionStart, + ToolResult, + with_agent_name, + with_agent_version, + with_conversation_title, + with_user_id, +) +from sigil_sdk.internal.gen.sigil.v1 import generation_ingest_pb2 as sigil_pb2 +from sigil_sdk.internal.gen.sigil.v1 import generation_ingest_pb2_grpc as sigil_pb2_grpc + + +_metadata_conversation_title = "sigil.conversation.title" +_metadata_user_id = "sigil.user.id" +_metadata_legacy_user_id = "user.id" +_span_attr_conversation_title = "sigil.conversation.title" +_span_attr_user_id = "user.id" + + +class _CapturingGenerationServicer(sigil_pb2_grpc.GenerationIngestServiceServicer): + def __init__(self) -> None: + self.requests: list[sigil_pb2.ExportGenerationsRequest] = [] + self._lock = threading.Lock() + + def ExportGenerations(self, request, _context): # noqa: N802 + with self._lock: + self.requests.append(copy.deepcopy(request)) + return sigil_pb2.ExportGenerationsResponse( + results=[ + sigil_pb2.ExportGenerationResult(generation_id=generation.id, accepted=True) + for generation in request.generations + ] + ) + + def single_generation(self) -> sigil_pb2.Generation: + assert len(self.requests) == 1 + assert len(self.requests[0].generations) == 1 + return self.requests[0].generations[0] + + +class _RatingCaptureServer: + def __init__(self) -> None: + self.requests: list[dict[str, Any]] = [] + + class _Handler(BaseHTTPRequestHandler): + def do_POST(handler): # noqa: N802 + length = int(handler.headers.get("Content-Length", "0")) + body = handler.rfile.read(length) + self.requests.append( + { + "path": handler.path, + "headers": {k.lower(): v for k, v in handler.headers.items()}, + "payload": json.loads(body.decode("utf-8")), + } + ) + encoded = json.dumps( + { + "rating": { + "rating_id": "rat-1", + "conversation_id": "conv-rating", + "rating": "CONVERSATION_RATING_VALUE_BAD", + "created_at": "2026-03-12T09:00:00Z", + }, + "summary": { + "total_count": 1, + "good_count": 0, + "bad_count": 1, + "latest_rating": "CONVERSATION_RATING_VALUE_BAD", + "latest_rated_at": "2026-03-12T09:00:00Z", + "has_bad_rating": True, + }, + } + ).encode("utf-8") + handler.send_response(200) + handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(encoded))) + handler.end_headers() + handler.wfile.write(encoded) + + def log_message(self, _format, *_args): # noqa: A003 + return + + self.server = HTTPServer(("127.0.0.1", 0), _Handler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + + @property + def endpoint(self) -> str: + return f"http://127.0.0.1:{self.server.server_address[1]}" + + def close(self) -> None: + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=2) + + +class _ConformanceEnv: + def __init__(self, *, batch_size: int = 1, flush_interval: timedelta | None = None) -> None: + self.servicer = _CapturingGenerationServicer() + self.grpc_server = grpc.server(concurrent.futures.ThreadPoolExecutor(max_workers=2)) + sigil_pb2_grpc.add_GenerationIngestServiceServicer_to_server(self.servicer, self.grpc_server) + + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + + self.grpc_server.add_insecure_port(f"127.0.0.1:{port}") + self.grpc_server.start() + + self.rating_server = _RatingCaptureServer() + self.span_exporter = InMemorySpanExporter() + self.tracer_provider = TracerProvider() + self.tracer_provider.add_span_processor(SimpleSpanProcessor(self.span_exporter)) + self.metric_reader = InMemoryMetricReader() + self.meter_provider = MeterProvider(metric_readers=[self.metric_reader]) + + export_config = GenerationExportConfig( + protocol="grpc", + endpoint=f"127.0.0.1:{port}", + insecure=True, + batch_size=batch_size, + flush_interval=flush_interval or timedelta(hours=1), + max_retries=1, + initial_backoff=timedelta(milliseconds=1), + max_backoff=timedelta(milliseconds=2), + ) + self.client = Client( + ClientConfig( + tracer=self.tracer_provider.get_tracer("sigil-conformance-test"), + meter=self.meter_provider.get_meter("sigil-conformance-test"), + generation_export=export_config, + api=ApiConfig(endpoint=self.rating_server.endpoint), + ) + ) + self._closed = False + + def shutdown(self) -> None: + if self._closed: + return + self._closed = True + self.client.shutdown() + self.tracer_provider.shutdown() + self.meter_provider.shutdown() + self.grpc_server.stop(grace=0) + self.rating_server.close() + + def generation_span(self): + spans = [ + span + for span in self.span_exporter.get_finished_spans() + if span.attributes.get("gen_ai.operation.name") in {"generateText", "streamText"} + ] + assert spans + return spans[-1] + + def latest_span(self, operation_name: str): + spans = [ + span + for span in self.span_exporter.get_finished_spans() + if span.attributes.get("gen_ai.operation.name") == operation_name + ] + assert spans + return spans[-1] + + def metrics(self) -> dict[str, Any]: + metrics = {} + data = self.metric_reader.get_metrics_data() + for resource_metric in data.resource_metrics: + for scope_metric in resource_metric.scope_metrics: + for metric in scope_metric.metrics: + metrics[metric.name] = metric.data + return metrics + + +def test_conformance_sync_roundtrip_semantics() -> None: + env = _ConformanceEnv() + try: + recorder = env.client.start_generation( + GenerationStart( + id="gen-roundtrip", + conversation_id="conv-roundtrip", + conversation_title="Roundtrip conversation", + user_id="user-roundtrip", + agent_name="agent-roundtrip", + agent_version="v-roundtrip", + model=ModelRef(provider="openai", name="gpt-5"), + max_tokens=256, + temperature=0.2, + top_p=0.9, + tool_choice="required", + thinking_enabled=False, + tools=[ToolDefinition(name="weather", description="Get weather", type="function")], + tags={"tenant": "dev"}, + metadata={"trace": "roundtrip"}, + ) + ) + recorder.set_result( + Generation( + response_id="resp-roundtrip", + response_model="gpt-5-2026", + input=[ + Message( + role=MessageRole.USER, + parts=[Part(kind=PartKind.TEXT, text="hello")], + ) + ], + output=[ + Message( + role=MessageRole.ASSISTANT, + parts=[ + Part(kind=PartKind.THINKING, thinking="reasoning"), + Part( + kind=PartKind.TOOL_CALL, + tool_call=ToolCall(id="call-1", name="weather", input_json=b'{"city":"Paris"}'), + ), + ], + ), + Message( + role=MessageRole.TOOL, + parts=[ + Part( + kind=PartKind.TOOL_RESULT, + tool_result=ToolResult( + tool_call_id="call-1", + name="weather", + content="sunny", + content_json=b'{"temp_c":18}', + ), + ) + ], + ), + ], + usage=TokenUsage( + input_tokens=12, + output_tokens=7, + total_tokens=19, + cache_read_input_tokens=2, + cache_write_input_tokens=1, + cache_creation_input_tokens=3, + reasoning_tokens=4, + ), + stop_reason="stop", + tags={"region": "eu"}, + metadata={"result": "ok"}, + ) + ) + recorder.end() + env.shutdown() + + generation = env.servicer.single_generation() + span = env.generation_span() + metrics = env.metrics() + + assert generation.mode == sigil_pb2.GENERATION_MODE_SYNC + assert generation.operation_name == "generateText" + assert generation.conversation_id == "conv-roundtrip" + assert generation.agent_name == "agent-roundtrip" + assert generation.agent_version == "v-roundtrip" + assert generation.trace_id == span.context.trace_id.to_bytes(16, "big").hex() + assert generation.span_id == span.context.span_id.to_bytes(8, "big").hex() + assert generation.metadata.fields[_metadata_conversation_title].string_value == "Roundtrip conversation" + assert generation.metadata.fields[_metadata_user_id].string_value == "user-roundtrip" + assert generation.input[0].parts[0].text == "hello" + assert generation.output[0].parts[0].thinking == "reasoning" + assert generation.output[0].parts[1].tool_call.name == "weather" + assert generation.output[1].parts[0].tool_result.content == "sunny" + assert generation.max_tokens == 256 + assert generation.temperature == 0.2 + assert generation.top_p == 0.9 + assert generation.tool_choice == "required" + assert generation.thinking_enabled is False + assert generation.usage.input_tokens == 12 + assert generation.usage.output_tokens == 7 + assert generation.usage.total_tokens == 19 + assert generation.usage.cache_read_input_tokens == 2 + assert generation.usage.cache_write_input_tokens == 1 + assert generation.usage.reasoning_tokens == 4 + assert generation.stop_reason == "stop" + assert generation.tags["tenant"] == "dev" + assert generation.tags["region"] == "eu" + + assert span.attributes["gen_ai.operation.name"] == "generateText" + assert span.attributes[_span_attr_conversation_title] == "Roundtrip conversation" + assert span.attributes[_span_attr_user_id] == "user-roundtrip" + assert "gen_ai.client.operation.duration" in metrics + assert "gen_ai.client.token.usage" in metrics + assert "gen_ai.client.time_to_first_token" not in metrics + finally: + env.shutdown() + + +def test_conformance_conversation_title_semantics() -> None: + cases = [ + ("explicit wins", "Explicit", "Context", "Meta", "Explicit"), + ("context fallback", "", "Context", "", "Context"), + ("metadata fallback", "", "", "Meta", "Meta"), + ("whitespace trimmed", " Padded ", "", "", "Padded"), + ("whitespace omitted", " ", "", "", ""), + ] + + for _, start_title, context_title, metadata_title, want_title in cases: + env = _ConformanceEnv() + try: + context = with_conversation_title(context_title) if context_title else nullcontext() + with context: + start = GenerationStart( + model=ModelRef(provider="openai", name="gpt-5"), + conversation_title=start_title, + metadata={_metadata_conversation_title: metadata_title} if metadata_title else {}, + ) + recorder = env.client.start_generation(start) + recorder.set_result(Generation()) + recorder.end() + env.shutdown() + + generation = env.servicer.single_generation() + span = env.generation_span() + field = generation.metadata.fields.get(_metadata_conversation_title) + if want_title == "": + assert field is None + assert _span_attr_conversation_title not in span.attributes + else: + assert field is not None + assert field.string_value == want_title + assert span.attributes[_span_attr_conversation_title] == want_title + finally: + env.shutdown() + + +def test_conformance_user_id_semantics() -> None: + cases = [ + ("explicit wins", "explicit", "ctx", "canonical", "legacy", "explicit"), + ("context fallback", "", "ctx", "", "", "ctx"), + ("canonical metadata", "", "", "canonical", "", "canonical"), + ("legacy metadata", "", "", "", "legacy", "legacy"), + ("canonical beats legacy", "", "", "canonical", "legacy", "canonical"), + ("whitespace trimmed", " padded ", "", "", "", "padded"), + ] + + for _, start_user_id, context_user_id, canonical_user_id, legacy_user_id, want_user_id in cases: + env = _ConformanceEnv() + try: + metadata = {} + if canonical_user_id: + metadata[_metadata_user_id] = canonical_user_id + if legacy_user_id: + metadata[_metadata_legacy_user_id] = legacy_user_id + + context = with_user_id(context_user_id) if context_user_id else nullcontext() + with context: + recorder = env.client.start_generation( + GenerationStart( + model=ModelRef(provider="openai", name="gpt-5"), + user_id=start_user_id, + metadata=metadata, + ) + ) + recorder.set_result(Generation()) + recorder.end() + env.shutdown() + + generation = env.servicer.single_generation() + span = env.generation_span() + assert generation.metadata.fields[_metadata_user_id].string_value == want_user_id + assert span.attributes[_span_attr_user_id] == want_user_id + finally: + env.shutdown() + + +def test_conformance_agent_identity_semantics() -> None: + cases = [ + ("explicit fields", "agent-explicit", "v1.2.3", "", "", "", "", "agent-explicit", "v1.2.3"), + ("context fallback", "", "", "agent-context", "v-context", "", "", "agent-context", "v-context"), + ("result-time override", "agent-seed", "v-seed", "", "", "agent-result", "v-result", "agent-result", "v-result"), + ("empty omission", "", "", "", "", "", "", "", ""), + ] + + for _, start_name, start_version, context_name, context_version, result_name, result_version, want_name, want_version in cases: + env = _ConformanceEnv() + try: + with with_agent_name(context_name) if context_name else nullcontext(): + with with_agent_version(context_version) if context_version else nullcontext(): + recorder = env.client.start_generation( + GenerationStart( + model=ModelRef(provider="openai", name="gpt-5"), + agent_name=start_name, + agent_version=start_version, + ) + ) + recorder.set_result( + Generation( + agent_name=result_name, + agent_version=result_version, + ) + ) + recorder.end() + env.shutdown() + + generation = env.servicer.single_generation() + span = env.generation_span() + assert generation.agent_name == want_name + assert generation.agent_version == want_version + if want_name: + assert span.attributes["gen_ai.agent.name"] == want_name + else: + assert "gen_ai.agent.name" not in span.attributes + if want_version: + assert span.attributes["gen_ai.agent.version"] == want_version + else: + assert "gen_ai.agent.version" not in span.attributes + finally: + env.shutdown() + + +def test_conformance_streaming_telemetry_semantics() -> None: + env = _ConformanceEnv() + try: + start = GenerationStart(model=ModelRef(provider="openai", name="gpt-5")) + recorder = env.client.start_streaming_generation(start) + recorder.set_first_token_at(datetime(2026, 3, 12, 9, 0, 0, 250000, tzinfo=timezone.utc)) + recorder.set_result( + Generation( + output=[ + Message( + role=MessageRole.ASSISTANT, + parts=[Part(kind=PartKind.TEXT, text="Hello world")], + ) + ], + usage=TokenUsage(input_tokens=4, output_tokens=3, total_tokens=7), + started_at=datetime(2026, 3, 12, 9, 0, 0, tzinfo=timezone.utc), + completed_at=datetime(2026, 3, 12, 9, 0, 1, tzinfo=timezone.utc), + ) + ) + recorder.end() + env.shutdown() + + generation = env.servicer.single_generation() + span = env.generation_span() + metrics = env.metrics() + + assert generation.mode == sigil_pb2.GENERATION_MODE_STREAM + assert generation.operation_name == "streamText" + assert generation.output[0].parts[0].text == "Hello world" + assert span.name == "streamText gpt-5" + assert "gen_ai.client.operation.duration" in metrics + assert "gen_ai.client.time_to_first_token" in metrics + finally: + env.shutdown() + + +def test_conformance_tool_execution_semantics() -> None: + env = _ConformanceEnv() + try: + with with_conversation_title("Context title"): + with with_agent_name("agent-context"): + with with_agent_version("v-context"): + recorder = env.client.start_tool_execution( + ToolExecutionStart( + tool_name="weather", + tool_call_id="call-weather-1", + tool_type="function", + include_content=True, + ) + ) + recorder.set_result( + ToolExecutionEnd( + arguments={"city": "Paris"}, + result={"forecast": "sunny"}, + ) + ) + recorder.end() + env.shutdown() + + span = env.latest_span("execute_tool") + metrics = env.metrics() + + assert env.servicer.requests == [] + assert span.name == "execute_tool weather" + assert span.attributes["gen_ai.operation.name"] == "execute_tool" + assert span.attributes["gen_ai.tool.name"] == "weather" + assert span.attributes["gen_ai.tool.call.id"] == "call-weather-1" + assert span.attributes["gen_ai.tool.type"] == "function" + assert "Paris" in str(span.attributes["gen_ai.tool.call.arguments"]) + assert "sunny" in str(span.attributes["gen_ai.tool.call.result"]) + assert span.attributes[_span_attr_conversation_title] == "Context title" + assert span.attributes["gen_ai.agent.name"] == "agent-context" + assert span.attributes["gen_ai.agent.version"] == "v-context" + assert "gen_ai.client.operation.duration" in metrics + assert "gen_ai.client.time_to_first_token" not in metrics + finally: + env.shutdown() + + +def test_conformance_embedding_semantics() -> None: + env = _ConformanceEnv() + try: + with with_agent_name("agent-context"): + with with_agent_version("v-context"): + recorder = env.client.start_embedding( + EmbeddingStart( + model=ModelRef(provider="openai", name="text-embedding-3-small"), + dimensions=512, + ) + ) + recorder.set_result( + EmbeddingResult( + input_count=2, + input_tokens=8, + input_texts=["hello", "world"], + response_model="text-embedding-3-small", + dimensions=512, + ) + ) + recorder.end() + env.shutdown() + + span = env.latest_span("embeddings") + metrics = env.metrics() + + assert env.servicer.requests == [] + assert span.name == "embeddings text-embedding-3-small" + assert span.attributes["gen_ai.operation.name"] == "embeddings" + assert span.attributes["gen_ai.agent.name"] == "agent-context" + assert span.attributes["gen_ai.agent.version"] == "v-context" + assert span.attributes["gen_ai.embeddings.input_count"] == 2 + assert span.attributes["gen_ai.embeddings.dimension.count"] == 512 + assert span.attributes["gen_ai.response.model"] == "text-embedding-3-small" + assert "gen_ai.client.operation.duration" in metrics + assert "gen_ai.client.token.usage" in metrics + assert "gen_ai.client.time_to_first_token" not in metrics + assert "gen_ai.client.tool_calls_per_operation" not in metrics + finally: + env.shutdown() + + +def test_conformance_validation_and_error_semantics() -> None: + env = _ConformanceEnv() + try: + invalid = env.client.start_generation( + GenerationStart(model=ModelRef(provider="anthropic", name="claude-sonnet-4-5")) + ) + invalid.set_result( + Generation( + input=[ + Message( + role=MessageRole.USER, + parts=[Part(kind=PartKind.TOOL_CALL, tool_call=ToolCall(name="weather"))], + ) + ] + ) + ) + invalid.end() + + assert invalid.err() is not None + assert env.servicer.requests == [] + assert env.generation_span().attributes["error.type"] == "validation_error" + + call_error = env.client.start_generation( + GenerationStart(model=ModelRef(provider="openai", name="gpt-5")) + ) + call_error.set_call_error(RuntimeError("provider unavailable")) + call_error.set_result(Generation()) + call_error.end() + env.shutdown() + + generation = env.servicer.single_generation() + spans = env.span_exporter.get_finished_spans() + assert call_error.err() is None + assert generation.call_error == "provider unavailable" + assert generation.metadata.fields["call_error"].string_value == "provider unavailable" + assert spans[-1].attributes["error.type"] == "provider_call_error" + finally: + env.shutdown() + + +def test_conformance_rating_submission_semantics() -> None: + env = _ConformanceEnv() + try: + response = env.client.submit_conversation_rating( + "conv-rating", + ConversationRatingInput( + rating_id="rat-1", + rating=ConversationRatingValue.BAD, + comment="wrong answer", + metadata={"channel": "assistant"}, + ), + ) + env.shutdown() + + assert len(env.rating_server.requests) == 1 + request = env.rating_server.requests[0] + assert request["path"] == "/api/v1/conversations/conv-rating/ratings" + assert request["payload"] == { + "rating_id": "rat-1", + "rating": "CONVERSATION_RATING_VALUE_BAD", + "comment": "wrong answer", + "metadata": {"channel": "assistant"}, + } + assert response.rating.conversation_id == "conv-rating" + assert response.summary.bad_count == 1 + finally: + env.shutdown() + + +def test_conformance_shutdown_flush_semantics() -> None: + env = _ConformanceEnv(batch_size=10) + try: + recorder = env.client.start_generation( + GenerationStart( + conversation_id="conv-shutdown", + agent_name="agent-shutdown", + agent_version="v-shutdown", + model=ModelRef(provider="openai", name="gpt-5"), + ) + ) + recorder.set_result(Generation()) + recorder.end() + + assert env.servicer.requests == [] + env.shutdown() + + generation = env.servicer.single_generation() + assert generation.conversation_id == "conv-shutdown" + assert generation.agent_name == "agent-shutdown" + assert generation.agent_version == "v-shutdown" + finally: + env.shutdown() diff --git a/python/tests/test_runtime.py b/python/tests/test_runtime.py index 8cc5236..ca58a20 100644 --- a/python/tests/test_runtime.py +++ b/python/tests/test_runtime.py @@ -573,6 +573,8 @@ def test_tool_execution_attributes_and_content_capture() -> None: conversation_id="conv-tool", agent_name="agent-tools", agent_version="2026.02.12", + request_provider="openai", + request_model="gpt-5", include_content=True, ) ) as rec: @@ -585,6 +587,8 @@ def test_tool_execution_attributes_and_content_capture() -> None: assert span.attributes.get("gen_ai.tool.call.id") == "call_weather" assert span.attributes.get("gen_ai.tool.call.arguments") is not None assert span.attributes.get("gen_ai.tool.call.result") is not None + assert span.attributes.get("gen_ai.provider.name") == "openai" + assert span.attributes.get("gen_ai.request.model") == "gpt-5" assert span.attributes.get("sigil.sdk.name") == "sdk-python" finally: client.shutdown() diff --git a/python/tests/test_transport.py b/python/tests/test_transport.py index d1857dd..5307f7c 100644 --- a/python/tests/test_transport.py +++ b/python/tests/test_transport.py @@ -265,6 +265,50 @@ def test_sdk_generation_auth_bearer_over_grpc_with_header_override() -> None: grpc_server.stop(grace=0) +def test_grpc_metadata_keys_are_lowercased() -> None: + """Mixed-case header keys must be lowercased for gRPC metadata (grpcio rejects uppercase).""" + servicer = _CapturingGenerationServicer() + grpc_server = grpc.server(thread_pool=__import__("concurrent.futures").futures.ThreadPoolExecutor(max_workers=2)) + sigil_pb2_grpc.add_GenerationIngestServiceServicer_to_server(servicer, grpc_server) + + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + + grpc_server.add_insecure_port(f"127.0.0.1:{port}") + grpc_server.start() + + client = _new_client( + GenerationExportConfig( + protocol="grpc", + endpoint=f"127.0.0.1:{port}", + insecure=True, + auth=AuthConfig(mode="tenant", tenant_id="12345"), + batch_size=1, + flush_interval=timedelta(seconds=1), + max_retries=1, + initial_backoff=timedelta(milliseconds=1), + max_backoff=timedelta(milliseconds=10), + ) + ) + + try: + start, result = _payload_fixture() + rec = client.start_generation(start) + rec.set_result(result) + rec.end() + assert rec.err() is None + client.shutdown() + + assert len(servicer.metadata) == 1 + meta = servicer.metadata[0] + assert meta.get("x-scope-orgid") == "12345" + assert not any(k != k.lower() for k in meta) + finally: + grpc_server.stop(grace=0) + + def _assert_generation_json_payload(generation: dict[str, Any]) -> None: assert generation["id"] == "gen-fixture-1" assert generation["conversation_id"] == "conv-fixture-1" diff --git a/python/uv.lock b/python/uv.lock new file mode 100644 index 0000000..b5119bd --- /dev/null +++ b/python/uv.lock @@ -0,0 +1,592 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/21/a2b1505639008ba2e6ef03733a81fc6cfd6a07ea6139a2b76421230b8dad/charset_normalizer-3.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765", size = 283319, upload-time = "2026-03-06T06:00:26.433Z" }, + { url = "https://files.pythonhosted.org/packages/70/67/df234c29b68f4e1e095885c9db1cb4b69b8aba49cf94fac041db4aaf1267/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990", size = 189974, upload-time = "2026-03-06T06:00:28.222Z" }, + { url = "https://files.pythonhosted.org/packages/df/7f/fc66af802961c6be42e2c7b69c58f95cbd1f39b0e81b3365d8efe2a02a04/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2", size = 207866, upload-time = "2026-03-06T06:00:29.769Z" }, + { url = "https://files.pythonhosted.org/packages/c9/23/404eb36fac4e95b833c50e305bba9a241086d427bb2167a42eac7c4f7da4/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765", size = 203239, upload-time = "2026-03-06T06:00:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2f/8a1d989bfadd120c90114ab33e0d2a0cbde05278c1fc15e83e62d570f50a/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d", size = 196529, upload-time = "2026-03-06T06:00:32.608Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0c/c75f85ff7ca1f051958bb518cd43922d86f576c03947a050fbedfdfb4f15/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8", size = 184152, upload-time = "2026-03-06T06:00:33.93Z" }, + { url = "https://files.pythonhosted.org/packages/f9/20/4ed37f6199af5dde94d4aeaf577f3813a5ec6635834cda1d957013a09c76/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412", size = 195226, upload-time = "2026-03-06T06:00:35.469Z" }, + { url = "https://files.pythonhosted.org/packages/28/31/7ba1102178cba7c34dcc050f43d427172f389729e356038f0726253dd914/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2", size = 192933, upload-time = "2026-03-06T06:00:36.83Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/f86443ab3921e6a60b33b93f4a1161222231f6c69bc24fb18f3bee7b8518/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1", size = 185647, upload-time = "2026-03-06T06:00:38.367Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/08b8be891760f1f5a6d23ce11d6d50c92981603e6eb740b4f72eea9424e2/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4", size = 209533, upload-time = "2026-03-06T06:00:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/df114f23406199f8af711ddccfbf409ffbc5b7cdc18fa19644997ff0c9bb/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f", size = 195901, upload-time = "2026-03-06T06:00:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/07/83/71ef34a76fe8aa05ff8f840244bda2d61e043c2ef6f30d200450b9f6a1be/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550", size = 204950, upload-time = "2026-03-06T06:00:45.202Z" }, + { url = "https://files.pythonhosted.org/packages/58/40/0253be623995365137d7dc68e45245036207ab2227251e69a3d93ce43183/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2", size = 198546, upload-time = "2026-03-06T06:00:46.481Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5c/5f3cb5b259a130895ef5ae16b38eaf141430fa3f7af50cd06c5d67e4f7b2/charset_normalizer-3.4.5-cp310-cp310-win32.whl", hash = "sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475", size = 132516, upload-time = "2026-03-06T06:00:47.924Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c3/84fb174e7770f2df2e1a2115090771bfbc2227fb39a765c6d00568d1aab4/charset_normalizer-3.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05", size = 142906, upload-time = "2026-03-06T06:00:49.389Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b2/6f852f8b969f2cbd0d4092d2e60139ab1af95af9bb651337cae89ec0f684/charset_normalizer-3.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064", size = 133258, upload-time = "2026-03-06T06:00:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9e/bcec3b22c64ecec47d39bf5167c2613efd41898c019dccd4183f6aa5d6a7/charset_normalizer-3.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694", size = 279531, upload-time = "2026-03-06T06:00:52.252Z" }, + { url = "https://files.pythonhosted.org/packages/58/12/81fd25f7e7078ab5d1eedbb0fac44be4904ae3370a3bf4533c8f2d159acd/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5", size = 188006, upload-time = "2026-03-06T06:00:53.8Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6e/f2d30e8c27c1b0736a6520311982cf5286cfc7f6cac77d7bc1325e3a23f2/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281", size = 205085, upload-time = "2026-03-06T06:00:55.311Z" }, + { url = "https://files.pythonhosted.org/packages/d0/90/d12cefcb53b5931e2cf792a33718d7126efb116a320eaa0742c7059a95e4/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923", size = 200545, upload-time = "2026-03-06T06:00:56.532Z" }, + { url = "https://files.pythonhosted.org/packages/03/f4/44d3b830a20e89ff82a3134912d9a1cf6084d64f3b95dcad40f74449a654/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81", size = 193863, upload-time = "2026-03-06T06:00:57.823Z" }, + { url = "https://files.pythonhosted.org/packages/25/4b/f212119c18a6320a9d4a730d1b4057875cdeabf21b3614f76549042ef8a8/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497", size = 181827, upload-time = "2026-03-06T06:00:59.323Z" }, + { url = "https://files.pythonhosted.org/packages/74/00/b26158e48b425a202a92965f8069e8a63d9af1481dfa206825d7f74d2a3c/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c", size = 191085, upload-time = "2026-03-06T06:01:00.546Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1c1737bf6fd40335fe53d28fe49afd99ee4143cc57a845e99635ce0b9b6d/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e", size = 190688, upload-time = "2026-03-06T06:01:02.479Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3d/abb5c22dc2ef493cd56522f811246a63c5427c08f3e3e50ab663de27fcf4/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f", size = 183077, upload-time = "2026-03-06T06:01:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/44/33/5298ad4d419a58e25b3508e87f2758d1442ff00c2471f8e0403dab8edad5/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e", size = 206706, upload-time = "2026-03-06T06:01:05.773Z" }, + { url = "https://files.pythonhosted.org/packages/7b/17/51e7895ac0f87c3b91d276a449ef09f5532a7529818f59646d7a55089432/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af", size = 191665, upload-time = "2026-03-06T06:01:07.473Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/cce9adf1883e98906dbae380d769b4852bb0fa0004bc7d7a2243418d3ea8/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85", size = 201950, upload-time = "2026-03-06T06:01:08.973Z" }, + { url = "https://files.pythonhosted.org/packages/08/ca/bce99cd5c397a52919e2769d126723f27a4c037130374c051c00470bcd38/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f", size = 195830, upload-time = "2026-03-06T06:01:10.155Z" }, + { url = "https://files.pythonhosted.org/packages/87/4f/2e3d023a06911f1281f97b8f036edc9872167036ca6f55cc874a0be6c12c/charset_normalizer-3.4.5-cp311-cp311-win32.whl", hash = "sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4", size = 132029, upload-time = "2026-03-06T06:01:11.706Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/a853b73d386521fd44b7f67ded6b17b7b2367067d9106a5c4b44f9a34274/charset_normalizer-3.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a", size = 142404, upload-time = "2026-03-06T06:01:12.865Z" }, + { url = "https://files.pythonhosted.org/packages/b4/10/dba36f76b71c38e9d391abe0fd8a5b818790e053c431adecfc98c35cd2a9/charset_normalizer-3.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c", size = 132796, upload-time = "2026-03-06T06:01:14.106Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976, upload-time = "2026-03-06T06:01:15.276Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356, upload-time = "2026-03-06T06:01:16.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369, upload-time = "2026-03-06T06:01:17.853Z" }, + { url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285, upload-time = "2026-03-06T06:01:19.473Z" }, + { url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274, upload-time = "2026-03-06T06:01:20.728Z" }, + { url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715, upload-time = "2026-03-06T06:01:22.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426, upload-time = "2026-03-06T06:01:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780, upload-time = "2026-03-06T06:01:25.053Z" }, + { url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805, upload-time = "2026-03-06T06:01:26.294Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342, upload-time = "2026-03-06T06:01:27.55Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661, upload-time = "2026-03-06T06:01:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819, upload-time = "2026-03-06T06:01:30.298Z" }, + { url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080, upload-time = "2026-03-06T06:01:31.478Z" }, + { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630, upload-time = "2026-03-06T06:01:33.056Z" }, + { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856, upload-time = "2026-03-06T06:01:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982, upload-time = "2026-03-06T06:01:35.641Z" }, + { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" }, + { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" }, + { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" }, + { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" }, + { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" }, + { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" }, + { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" }, + { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" }, + { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" }, + { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" }, + { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" }, + { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" }, + { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" }, + { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" }, + { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" }, + { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" }, + { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.73.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/96/a0205167fa0154f4a542fd6925bdc63d039d88dab3588b875078107e6f06/googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a", size = 147323, upload-time = "2026-03-06T21:53:09.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8", size = 297578, upload-time = "2026-03-06T21:52:33.933Z" }, +] + +[[package]] +name = "grpcio" +version = "1.78.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/a8/690a085b4d1fe066130de97a87de32c45062cf2ecd218df9675add895550/grpcio-1.78.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:7cc47943d524ee0096f973e1081cb8f4f17a4615f2116882a5f1416e4cfe92b5", size = 5946986, upload-time = "2026-02-06T09:54:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1b/e5213c5c0ced9d2d92778d30529ad5bb2dcfb6c48c4e2d01b1f302d33d64/grpcio-1.78.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c3f293fdc675ccba4db5a561048cca627b5e7bd1c8a6973ffedabe7d116e22e2", size = 11816533, upload-time = "2026-02-06T09:54:37.04Z" }, + { url = "https://files.pythonhosted.org/packages/18/37/1ba32dccf0a324cc5ace744c44331e300b000a924bf14840f948c559ede7/grpcio-1.78.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10a9a644b5dd5aec3b82b5b0b90d41c0fa94c85ef42cb42cf78a23291ddb5e7d", size = 6519964, upload-time = "2026-02-06T09:54:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f5/c0e178721b818072f2e8b6fde13faaba942406c634009caf065121ce246b/grpcio-1.78.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4c5533d03a6cbd7f56acfc9cfb44ea64f63d29091e40e44010d34178d392d7eb", size = 7198058, upload-time = "2026-02-06T09:54:42.389Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b2/40d43c91ae9cd667edc960135f9f08e58faa1576dc95af29f66ec912985f/grpcio-1.78.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff870aebe9a93a85283837801d35cd5f8814fe2ad01e606861a7fb47c762a2b7", size = 6727212, upload-time = "2026-02-06T09:54:44.91Z" }, + { url = "https://files.pythonhosted.org/packages/ed/88/9da42eed498f0efcfcd9156e48ae63c0cde3bea398a16c99fb5198c885b6/grpcio-1.78.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:391e93548644e6b2726f1bb84ed60048d4bcc424ce5e4af0843d28ca0b754fec", size = 7300845, upload-time = "2026-02-06T09:54:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/23/3f/1c66b7b1b19a8828890e37868411a6e6925df5a9030bfa87ab318f34095d/grpcio-1.78.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:df2c8f3141f7cbd112a6ebbd760290b5849cda01884554f7c67acc14e7b1758a", size = 8284605, upload-time = "2026-02-06T09:54:50.475Z" }, + { url = "https://files.pythonhosted.org/packages/94/c4/ca1bd87394f7b033e88525384b4d1e269e8424ab441ea2fba1a0c5b50986/grpcio-1.78.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bd8cb8026e5f5b50498a3c4f196f57f9db344dad829ffae16b82e4fdbaea2813", size = 7726672, upload-time = "2026-02-06T09:54:53.11Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/f16e487d4cc65ccaf670f6ebdd1a17566b965c74fc3d93999d3b2821e052/grpcio-1.78.0-cp310-cp310-win32.whl", hash = "sha256:f8dff3d9777e5d2703a962ee5c286c239bf0ba173877cc68dc02c17d042e29de", size = 4076715, upload-time = "2026-02-06T09:54:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/2a/32/4ce60d94e242725fd3bcc5673c04502c82a8e87b21ea411a63992dc39f8f/grpcio-1.78.0-cp310-cp310-win_amd64.whl", hash = "sha256:94f95cf5d532d0e717eed4fc1810e8e6eded04621342ec54c89a7c2f14b581bf", size = 4799157, upload-time = "2026-02-06T09:54:59.838Z" }, + { url = "https://files.pythonhosted.org/packages/86/c7/d0b780a29b0837bf4ca9580904dfb275c1fc321ded7897d620af7047ec57/grpcio-1.78.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2777b783f6c13b92bd7b716667452c329eefd646bfb3f2e9dabea2e05dbd34f6", size = 5951525, upload-time = "2026-02-06T09:55:01.989Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/96920bf2ee61df85a9503cb6f733fe711c0ff321a5a697d791b075673281/grpcio-1.78.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:9dca934f24c732750389ce49d638069c3892ad065df86cb465b3fa3012b70c9e", size = 11830418, upload-time = "2026-02-06T09:55:04.462Z" }, + { url = "https://files.pythonhosted.org/packages/83/0c/7c1528f098aeb75a97de2bae18c530f56959fb7ad6c882db45d9884d6edc/grpcio-1.78.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:459ab414b35f4496138d0ecd735fed26f1318af5e52cb1efbc82a09f0d5aa911", size = 6524477, upload-time = "2026-02-06T09:55:07.111Z" }, + { url = "https://files.pythonhosted.org/packages/8d/52/e7c1f3688f949058e19a011c4e0dec973da3d0ae5e033909677f967ae1f4/grpcio-1.78.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:082653eecbdf290e6e3e2c276ab2c54b9e7c299e07f4221872380312d8cf395e", size = 7198266, upload-time = "2026-02-06T09:55:10.016Z" }, + { url = "https://files.pythonhosted.org/packages/e5/61/8ac32517c1e856677282c34f2e7812d6c328fa02b8f4067ab80e77fdc9c9/grpcio-1.78.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85f93781028ec63f383f6bc90db785a016319c561cc11151fbb7b34e0d012303", size = 6730552, upload-time = "2026-02-06T09:55:12.207Z" }, + { url = "https://files.pythonhosted.org/packages/bd/98/b8ee0158199250220734f620b12e4a345955ac7329cfd908d0bf0fda77f0/grpcio-1.78.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f12857d24d98441af6a1d5c87442d624411db486f7ba12550b07788f74b67b04", size = 7304296, upload-time = "2026-02-06T09:55:15.044Z" }, + { url = "https://files.pythonhosted.org/packages/bd/0f/7b72762e0d8840b58032a56fdbd02b78fc645b9fa993d71abf04edbc54f4/grpcio-1.78.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5397fff416b79e4b284959642a4e95ac4b0f1ece82c9993658e0e477d40551ec", size = 8288298, upload-time = "2026-02-06T09:55:17.276Z" }, + { url = "https://files.pythonhosted.org/packages/24/ae/ae4ce56bc5bb5caa3a486d60f5f6083ac3469228faa734362487176c15c5/grpcio-1.78.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fbe6e89c7ffb48518384068321621b2a69cab509f58e40e4399fdd378fa6d074", size = 7730953, upload-time = "2026-02-06T09:55:19.545Z" }, + { url = "https://files.pythonhosted.org/packages/b5/6e/8052e3a28eb6a820c372b2eb4b5e32d195c661e137d3eca94d534a4cfd8a/grpcio-1.78.0-cp311-cp311-win32.whl", hash = "sha256:6092beabe1966a3229f599d7088b38dfc8ffa1608b5b5cdda31e591e6500f856", size = 4076503, upload-time = "2026-02-06T09:55:21.521Z" }, + { url = "https://files.pythonhosted.org/packages/08/62/f22c98c5265dfad327251fa2f840b591b1df5f5e15d88b19c18c86965b27/grpcio-1.78.0-cp311-cp311-win_amd64.whl", hash = "sha256:1afa62af6e23f88629f2b29ec9e52ec7c65a7176c1e0a83292b93c76ca882558", size = 4799767, upload-time = "2026-02-06T09:55:24.107Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f4/7384ed0178203d6074446b3c4f46c90a22ddf7ae0b3aee521627f54cfc2a/grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", size = 5913985, upload-time = "2026-02-06T09:55:26.832Z" }, + { url = "https://files.pythonhosted.org/packages/81/ed/be1caa25f06594463f685b3790b320f18aea49b33166f4141bfdc2bfb236/grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", size = 11811853, upload-time = "2026-02-06T09:55:29.224Z" }, + { url = "https://files.pythonhosted.org/packages/24/a7/f06d151afc4e64b7e3cc3e872d331d011c279aaab02831e40a81c691fb65/grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", size = 6475766, upload-time = "2026-02-06T09:55:31.825Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a8/4482922da832ec0082d0f2cc3a10976d84a7424707f25780b82814aafc0a/grpcio-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7", size = 7170027, upload-time = "2026-02-06T09:55:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/54/bf/f4a3b9693e35d25b24b0b39fa46d7d8a3c439e0a3036c3451764678fec20/grpcio-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9", size = 6690766, upload-time = "2026-02-06T09:55:36.902Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/521875265cc99fe5ad4c5a17010018085cae2810a928bf15ebe7d8bcd9cc/grpcio-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383", size = 7266161, upload-time = "2026-02-06T09:55:39.824Z" }, + { url = "https://files.pythonhosted.org/packages/05/86/296a82844fd40a4ad4a95f100b55044b4f817dece732bf686aea1a284147/grpcio-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6", size = 8253303, upload-time = "2026-02-06T09:55:42.353Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e4/ea3c0caf5468537f27ad5aab92b681ed7cc0ef5f8c9196d3fd42c8c2286b/grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", size = 7698222, upload-time = "2026-02-06T09:55:44.629Z" }, + { url = "https://files.pythonhosted.org/packages/d7/47/7f05f81e4bb6b831e93271fb12fd52ba7b319b5402cbc101d588f435df00/grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", size = 4066123, upload-time = "2026-02-06T09:55:47.644Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e7/d6914822c88aa2974dbbd10903d801a28a19ce9cd8bad7e694cbbcf61528/grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", size = 4797657, upload-time = "2026-02-06T09:55:49.86Z" }, + { url = "https://files.pythonhosted.org/packages/05/a9/8f75894993895f361ed8636cd9237f4ab39ef87fd30db17467235ed1c045/grpcio-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b", size = 5920143, upload-time = "2026-02-06T09:55:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/55/06/0b78408e938ac424100100fd081189451b472236e8a3a1f6500390dc4954/grpcio-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a", size = 11803926, upload-time = "2026-02-06T09:55:55.494Z" }, + { url = "https://files.pythonhosted.org/packages/88/93/b59fe7832ff6ae3c78b813ea43dac60e295fa03606d14d89d2e0ec29f4f3/grpcio-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84", size = 6478628, upload-time = "2026-02-06T09:55:58.533Z" }, + { url = "https://files.pythonhosted.org/packages/ed/df/e67e3734527f9926b7d9c0dde6cd998d1d26850c3ed8eeec81297967ac67/grpcio-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb", size = 7173574, upload-time = "2026-02-06T09:56:01.786Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/cc03fffb07bfba982a9ec097b164e8835546980aec25ecfa5f9c1a47e022/grpcio-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5", size = 6692639, upload-time = "2026-02-06T09:56:04.529Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/289c32e301b85bdb67d7ec68b752155e674ee3ba2173a1858f118e399ef3/grpcio-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9", size = 7268838, upload-time = "2026-02-06T09:56:08.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/79/1be93f32add280461fa4773880196572563e9c8510861ac2da0ea0f892b6/grpcio-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702", size = 8251878, upload-time = "2026-02-06T09:56:10.914Z" }, + { url = "https://files.pythonhosted.org/packages/65/65/793f8e95296ab92e4164593674ae6291b204bb5f67f9d4a711489cd30ffa/grpcio-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20", size = 7695412, upload-time = "2026-02-06T09:56:13.593Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/1e233fe697ecc82845942c2822ed06bb522e70d6771c28d5528e4c50f6a4/grpcio-1.78.0-cp313-cp313-win32.whl", hash = "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670", size = 4064899, upload-time = "2026-02-06T09:56:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/4d/27/d86b89e36de8a951501fb06a0f38df19853210f341d0b28f83f4aa0ffa08/grpcio-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4", size = 4797393, upload-time = "2026-02-06T09:56:17.882Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/b56e43e3c968bfe822fa6ce5bca10d5c723aa40875b48791ce1029bb78c7/grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", size = 5920591, upload-time = "2026-02-06T09:56:20.758Z" }, + { url = "https://files.pythonhosted.org/packages/5d/81/1f3b65bd30c334167bfa8b0d23300a44e2725ce39bba5b76a2460d85f745/grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", size = 11813685, upload-time = "2026-02-06T09:56:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1c/bbe2f8216a5bd3036119c544d63c2e592bdf4a8ec6e4a1867592f4586b26/grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", size = 6487803, upload-time = "2026-02-06T09:56:27.367Z" }, + { url = "https://files.pythonhosted.org/packages/16/5c/a6b2419723ea7ddce6308259a55e8e7593d88464ce8db9f4aa857aba96fa/grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", size = 7173206, upload-time = "2026-02-06T09:56:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/df/1e/b8801345629a415ea7e26c83d75eb5dbe91b07ffe5210cc517348a8d4218/grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", size = 6693826, upload-time = "2026-02-06T09:56:32.305Z" }, + { url = "https://files.pythonhosted.org/packages/34/84/0de28eac0377742679a510784f049738a80424b17287739fc47d63c2439e/grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", size = 7277897, upload-time = "2026-02-06T09:56:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9c/ad8685cfe20559a9edb66f735afdcb2b7d3de69b13666fdfc542e1916ebd/grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", size = 8252404, upload-time = "2026-02-06T09:56:37.553Z" }, + { url = "https://files.pythonhosted.org/packages/3c/05/33a7a4985586f27e1de4803887c417ec7ced145ebd069bc38a9607059e2b/grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", size = 7696837, upload-time = "2026-02-06T09:56:40.173Z" }, + { url = "https://files.pythonhosted.org/packages/73/77/7382241caf88729b106e49e7d18e3116216c778e6a7e833826eb96de22f7/grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", size = 4142439, upload-time = "2026-02-06T09:56:43.258Z" }, + { url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852, upload-time = "2026-02-06T09:56:45.885Z" }, +] + +[[package]] +name = "grpcio-tools" +version = "1.78.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "protobuf" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/d1/cbefe328653f746fd319c4377836a25ba64226e41c6a1d7d5cdbc87a459f/grpcio_tools-1.78.0.tar.gz", hash = "sha256:4b0dd86560274316e155d925158276f8564508193088bc43e20d3f5dff956b2b", size = 5393026, upload-time = "2026-02-06T09:59:59.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/70/2118a814a62ab205c905d221064bc09021db83fceeb84764d35c00f0f633/grpcio_tools-1.78.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:ea64e38d1caa2b8468b08cb193f5a091d169b6dbfe1c7dac37d746651ab9d84e", size = 2545568, upload-time = "2026-02-06T09:57:30.308Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a9/68134839dd1a00f964185ead103646d6dd6a396b92ed264eaf521431b793/grpcio_tools-1.78.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:4003fcd5cbb5d578b06176fd45883a72a8f9203152149b7c680ce28653ad9e3a", size = 5708704, upload-time = "2026-02-06T09:57:33.512Z" }, + { url = "https://files.pythonhosted.org/packages/36/1b/b6135aa9534e22051c53e5b9c0853d18024a41c50aaff464b7b47c1ed379/grpcio_tools-1.78.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe6b0081775394c61ec633c9ff5dbc18337100eabb2e946b5c83967fe43b2748", size = 2591905, upload-time = "2026-02-06T09:57:35.338Z" }, + { url = "https://files.pythonhosted.org/packages/41/2b/6380df1390d62b1d18ae18d4d790115abf4997fa29498aa50ba644ecb9d8/grpcio_tools-1.78.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:7e989ad2cd93db52d7f1a643ecaa156ac55bf0484f1007b485979ce8aef62022", size = 2905271, upload-time = "2026-02-06T09:57:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/3a/07/9b369f37c8f4956b68778c044d57390a8f0f3b1cca590018809e75a4fce2/grpcio_tools-1.78.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b874991797e96c41a37e563236c3317ed41b915eff25b292b202d6277d30da85", size = 2656234, upload-time = "2026-02-06T09:57:41.157Z" }, + { url = "https://files.pythonhosted.org/packages/51/61/40eee40e7a54f775a0d4117536532713606b6b177fff5e327f33ad18746e/grpcio_tools-1.78.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:daa8c288b728228377aaf758925692fc6068939d9fa32f92ca13dedcbeb41f33", size = 3105770, upload-time = "2026-02-06T09:57:43.373Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ac/81ee4b728e70e8ba66a589f86469925ead02ed6f8973434e4a52e3576148/grpcio_tools-1.78.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:87e648759b06133199f4bc0c0053e3819f4ec3b900dc399e1097b6065db998b5", size = 3654896, upload-time = "2026-02-06T09:57:45.402Z" }, + { url = "https://files.pythonhosted.org/packages/be/b9/facb3430ee427c800bb1e39588c85685677ea649491d6e0874bd9f3a1c0e/grpcio_tools-1.78.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f3d3ced52bfe39eba3d24f5a8fab4e12d071959384861b41f0c52ca5399d6920", size = 3322529, upload-time = "2026-02-06T09:57:47.292Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/d7a011df9abfed8c30f0d2077b0562a6e3edc57cb3e5514718e2a81f370a/grpcio_tools-1.78.0-cp310-cp310-win32.whl", hash = "sha256:4bb6ed690d417b821808796221bde079377dff98fdc850ac157ad2f26cda7a36", size = 993518, upload-time = "2026-02-06T09:57:48.836Z" }, + { url = "https://files.pythonhosted.org/packages/c8/5e/f7f60c3ae2281c6b438c3a8455f4a5d5d2e677cf20207864cbee3763da22/grpcio_tools-1.78.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c676d8342fd53bd85a5d5f0d070cd785f93bc040510014708ede6fcb32fada1", size = 1158505, upload-time = "2026-02-06T09:57:50.633Z" }, + { url = "https://files.pythonhosted.org/packages/75/78/280184d19242ed6762bf453c47a70b869b3c5c72a24dc5bf2bf43909faa3/grpcio_tools-1.78.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:6a8b8b7b49f319d29dbcf507f62984fa382d1d10437d75c3f26db5f09c4ac0af", size = 2545904, upload-time = "2026-02-06T09:57:52.769Z" }, + { url = "https://files.pythonhosted.org/packages/5b/51/3c46dea5113f68fe879961cae62d34bb7a3c308a774301b45d614952ee98/grpcio_tools-1.78.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:d62cf3b68372b0c6d722a6165db41b976869811abeabc19c8522182978d8db10", size = 5709078, upload-time = "2026-02-06T09:57:56.389Z" }, + { url = "https://files.pythonhosted.org/packages/e0/2c/dc1ae9ec53182c96d56dfcbf3bcd3e55a8952ad508b188c75bf5fc8993d4/grpcio_tools-1.78.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fa9056742efeaf89d5fe14198af71e5cbc4fbf155d547b89507e19d6025906c6", size = 2591744, upload-time = "2026-02-06T09:57:58.341Z" }, + { url = "https://files.pythonhosted.org/packages/04/63/9b53fc9a9151dd24386785171a4191ee7cb5afb4d983b6a6a87408f41b28/grpcio_tools-1.78.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e3191af125dcb705aa6bc3856ba81ba99b94121c1b6ebee152e66ea084672831", size = 2905113, upload-time = "2026-02-06T09:58:00.38Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/0ad8d789f3a2a00893131c140865605fa91671a6e6fcf9da659e1fabba10/grpcio_tools-1.78.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:283239ddbb67ae83fac111c61b25d8527a1dbd355b377cbc8383b79f1329944d", size = 2656436, upload-time = "2026-02-06T09:58:03.038Z" }, + { url = "https://files.pythonhosted.org/packages/09/4d/580f47ce2fc61b093ade747b378595f51b4f59972dd39949f7444b464a03/grpcio_tools-1.78.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ac977508c0db15301ef36d6c79769ec1a6cc4e3bc75735afca7fe7e360cead3a", size = 3106128, upload-time = "2026-02-06T09:58:05.064Z" }, + { url = "https://files.pythonhosted.org/packages/c9/29/d83b2d89f8d10e438bad36b1eb29356510fb97e81e6a608b22ae1890e8e6/grpcio_tools-1.78.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4ff605e25652a0bd13aa8a73a09bc48669c68170902f5d2bf1468a57d5e78771", size = 3654953, upload-time = "2026-02-06T09:58:07.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/71/917ce85633311e54fefd7e6eb1224fb780ef317a4d092766f5630c3fc419/grpcio_tools-1.78.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0197d7b561c79be78ab93d0fe2836c8def470683df594bae3ac89dd8e5c821b2", size = 3322630, upload-time = "2026-02-06T09:58:10.305Z" }, + { url = "https://files.pythonhosted.org/packages/b2/55/3fbf6b26ab46fc79e1e6f7f4e0993cf540263dad639290299fad374a0829/grpcio_tools-1.78.0-cp311-cp311-win32.whl", hash = "sha256:28f71f591f7f39555863ced84fcc209cbf4454e85ef957232f43271ee99af577", size = 993804, upload-time = "2026-02-06T09:58:13.698Z" }, + { url = "https://files.pythonhosted.org/packages/73/86/4affe006d9e1e9e1c6653d6aafe2f8b9188acb2b563cd8ed3a2c7c0e8aec/grpcio_tools-1.78.0-cp311-cp311-win_amd64.whl", hash = "sha256:5a6de495dabf86a3b40b9a7492994e1232b077af9d63080811838b781abbe4e8", size = 1158566, upload-time = "2026-02-06T09:58:15.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ae/5b1fa5dd8d560a6925aa52de0de8731d319f121c276e35b9b2af7cc220a2/grpcio_tools-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:9eb122da57d4cad7d339fc75483116f0113af99e8d2c67f3ef9cae7501d806e4", size = 2546823, upload-time = "2026-02-06T09:58:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ed/d33ccf7fa701512efea7e7e23333b748848a123e9d3bbafde4e126784546/grpcio_tools-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:d0c501b8249940b886420e6935045c44cb818fa6f265f4c2b97d5cff9cb5e796", size = 5706776, upload-time = "2026-02-06T09:58:20.944Z" }, + { url = "https://files.pythonhosted.org/packages/c6/69/4285583f40b37af28277fc6b867d636e3b10e1b6a7ebd29391a856e1279b/grpcio_tools-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:77e5aa2d2a7268d55b1b113f958264681ef1994c970f69d48db7d4683d040f57", size = 2593972, upload-time = "2026-02-06T09:58:23.29Z" }, + { url = "https://files.pythonhosted.org/packages/d7/eb/ecc1885bd6b3147f0a1b7dff5565cab72f01c8f8aa458f682a1c77a9fb08/grpcio_tools-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:8e3c0b0e6ba5275322ba29a97bf890565a55f129f99a21b121145e9e93a22525", size = 2905531, upload-time = "2026-02-06T09:58:25.406Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a9/511d0040ced66960ca10ba0f082d6b2d2ee6dd61837b1709636fdd8e23b4/grpcio_tools-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975d4cb48694e20ebd78e1643e5f1cd94cdb6a3d38e677a8e84ae43665aa4790", size = 2656909, upload-time = "2026-02-06T09:58:28.022Z" }, + { url = "https://files.pythonhosted.org/packages/06/a3/3d2c707e7dee8df842c96fbb24feb2747e506e39f4a81b661def7fed107c/grpcio_tools-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:553ff18c5d52807dedecf25045ae70bad7a3dbba0b27a9a3cdd9bcf0a1b7baec", size = 3109778, upload-time = "2026-02-06T09:58:30.091Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4b/646811ba241bf05da1f0dc6f25764f1c837f78f75b4485a4210c84b79eae/grpcio_tools-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8c7f5e4af5a84d2e96c862b1a65e958a538237e268d5f8203a3a784340975b51", size = 3658763, upload-time = "2026-02-06T09:58:32.875Z" }, + { url = "https://files.pythonhosted.org/packages/45/de/0a5ef3b3e79d1011375f5580dfee3a9c1ccb96c5f5d1c74c8cee777a2483/grpcio_tools-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96183e2b44afc3f9a761e9d0f985c3b44e03e8bb98e626241a6cbfb3b6f7e88f", size = 3325116, upload-time = "2026-02-06T09:58:34.894Z" }, + { url = "https://files.pythonhosted.org/packages/95/d2/6391b241ad571bc3e71d63f957c0b1860f0c47932d03c7f300028880f9b8/grpcio_tools-1.78.0-cp312-cp312-win32.whl", hash = "sha256:2250e8424c565a88573f7dc10659a0b92802e68c2a1d57e41872c9b88ccea7a6", size = 993493, upload-time = "2026-02-06T09:58:37.242Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8f/7d0d3a39ecad76ccc136be28274daa660569b244fa7d7d0bbb24d68e5ece/grpcio_tools-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:217d1fa29de14d9c567d616ead7cb0fef33cde36010edff5a9390b00d52e5094", size = 1158423, upload-time = "2026-02-06T09:58:40.072Z" }, + { url = "https://files.pythonhosted.org/packages/53/ce/17311fb77530420e2f441e916b347515133e83d21cd6cc77be04ce093d5b/grpcio_tools-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:2d6de1cc23bdc1baafc23e201b1e48c617b8c1418b4d8e34cebf72141676e5fb", size = 2546284, upload-time = "2026-02-06T09:58:43.073Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d3/79e101483115f0e78223397daef71751b75eba7e92a32060c10aae11ca64/grpcio_tools-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2afeaad88040894c76656202ff832cb151bceb05c0e6907e539d129188b1e456", size = 5705653, upload-time = "2026-02-06T09:58:45.533Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a7/52fa3ccb39ceeee6adc010056eadfbca8198651c113e418dafebbdf2b306/grpcio_tools-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:33cc593735c93c03d63efe7a8ba25f3c66f16c52f0651910712490244facad72", size = 2592788, upload-time = "2026-02-06T09:58:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/68/08/682ff6bb548225513d73dc9403742d8975439d7469c673bc534b9bbc83a7/grpcio_tools-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2921d7989c4d83b71f03130ab415fa4d66e6693b8b8a1fcbb7a1c67cff19b812", size = 2905157, upload-time = "2026-02-06T09:58:51.478Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/264f3836a96423b7018e5ada79d62576a6401f6da4e1f4975b18b2be1265/grpcio_tools-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e6a0df438e82c804c7b95e3f311c97c2f876dcc36376488d5b736b7bcf5a9b45", size = 2656166, upload-time = "2026-02-06T09:58:54.117Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/f108276611522e03e98386b668cc7e575eff6952f2db9caa15b2a3b3e883/grpcio_tools-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e9c6070a9500798225191ef25d0055a15d2c01c9c8f2ee7b681fffa99c98c822", size = 3109110, upload-time = "2026-02-06T09:58:56.891Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c7/cf048dbcd64b3396b3c860a2ffbcc67a8f8c87e736aaa74c2e505a7eee4c/grpcio_tools-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:394e8b57d85370a62e5b0a4d64c96fcf7568345c345d8590c821814d227ecf1d", size = 3657863, upload-time = "2026-02-06T09:58:59.176Z" }, + { url = "https://files.pythonhosted.org/packages/b6/37/e2736912c8fda57e2e57a66ea5e0bc8eb9a5fb7ded00e866ad22d50afb08/grpcio_tools-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3ef700293ab375e111a2909d87434ed0a0b086adf0ce67a8d9cf12ea7765e63", size = 3324748, upload-time = "2026-02-06T09:59:01.242Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/726abc75bb5bfc2841e88ea05896e42f51ca7c30cb56da5c5b63058b3867/grpcio_tools-1.78.0-cp313-cp313-win32.whl", hash = "sha256:6993b960fec43a8d840ee5dc20247ef206c1a19587ea49fe5e6cc3d2a09c1585", size = 993074, upload-time = "2026-02-06T09:59:03.085Z" }, + { url = "https://files.pythonhosted.org/packages/c5/68/91b400bb360faf9b177ffb5540ec1c4d06ca923691ddf0f79e2c9683f4da/grpcio_tools-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:275ce3c2978842a8cf9dd88dce954e836e590cf7029649ad5d1145b779039ed5", size = 1158185, upload-time = "2026-02-06T09:59:05.036Z" }, + { url = "https://files.pythonhosted.org/packages/cf/5e/278f3831c8d56bae02e3acc570465648eccf0a6bbedcb1733789ac966803/grpcio_tools-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:8b080d0d072e6032708a3a91731b808074d7ab02ca8fb9847b6a011fdce64cd9", size = 2546270, upload-time = "2026-02-06T09:59:07.426Z" }, + { url = "https://files.pythonhosted.org/packages/a3/d9/68582f2952b914b60dddc18a2e3f9c6f09af9372b6f6120d6cf3ec7f8b4e/grpcio_tools-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8c0ad8f8f133145cd7008b49cb611a5c6a9d89ab276c28afa17050516e801f79", size = 5705731, upload-time = "2026-02-06T09:59:09.856Z" }, + { url = "https://files.pythonhosted.org/packages/70/68/feb0f9a48818ee1df1e8b644069379a1e6ef5447b9b347c24e96fd258e5d/grpcio_tools-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2f8ea092a7de74c6359335d36f0674d939a3c7e1a550f4c2c9e80e0226de8fe4", size = 2593896, upload-time = "2026-02-06T09:59:12.23Z" }, + { url = "https://files.pythonhosted.org/packages/1f/08/a430d8d06e1b8d33f3e48d3f0cc28236723af2f35e37bd5c8db05df6c3aa/grpcio_tools-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:da422985e0cac822b41822f43429c19ecb27c81ffe3126d0b74e77edec452608", size = 2905298, upload-time = "2026-02-06T09:59:14.458Z" }, + { url = "https://files.pythonhosted.org/packages/71/0a/348c36a3eae101ca0c090c9c3bc96f2179adf59ee0c9262d11cdc7bfe7db/grpcio_tools-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4fab1faa3fbcb246263e68da7a8177d73772283f9db063fb8008517480888d26", size = 2656186, upload-time = "2026-02-06T09:59:16.949Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3f/18219f331536fad4af6207ade04142292faa77b5cb4f4463787988963df8/grpcio_tools-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dd9c094f73f734becae3f20f27d4944d3cd8fb68db7338ee6c58e62fc5c3d99f", size = 3109859, upload-time = "2026-02-06T09:59:19.202Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d9/341ea20a44c8e5a3a18acc820b65014c2e3ea5b4f32a53d14864bcd236bc/grpcio_tools-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2ed51ce6b833068f6c580b73193fc2ec16468e6bc18354bc2f83a58721195a58", size = 3657915, upload-time = "2026-02-06T09:59:21.839Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f4/5978b0f91611a64371424c109dd0027b247e5b39260abad2eaee66b6aa37/grpcio_tools-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:05803a5cdafe77c8bdf36aa660ad7a6a1d9e49bc59ce45c1bade2a4698826599", size = 3324724, upload-time = "2026-02-06T09:59:24.402Z" }, + { url = "https://files.pythonhosted.org/packages/b2/80/96a324dba99cfbd20e291baf0b0ae719dbb62b76178c5ce6c788e7331cb1/grpcio_tools-1.78.0-cp314-cp314-win32.whl", hash = "sha256:f7c722e9ce6f11149ac5bddd5056e70aaccfd8168e74e9d34d8b8b588c3f5c7c", size = 1015505, upload-time = "2026-02-06T09:59:26.3Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d1/909e6a05bfd44d46327dc4b8a78beb2bae4fb245ffab2772e350081aaf7e/grpcio_tools-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d58ade518b546120ec8f0a8e006fc8076ae5df151250ebd7e82e9b5e152c229", size = 1190196, upload-time = "2026-02-06T09:59:28.359Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/bc/1559d46557fe6eca0b46c88d4c2676285f1f3be2e8d06bb5d15fbffc814a/opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa", size = 20416, upload-time = "2026-03-04T14:17:23.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149", size = 18369, upload-time = "2026-03-04T14:17:04.796Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/7f/b9e60435cfcc7590fa87436edad6822240dddbc184643a2a005301cc31f4/opentelemetry_exporter_otlp_proto_grpc-1.40.0.tar.gz", hash = "sha256:bd4015183e40b635b3dab8da528b27161ba83bf4ef545776b196f0fb4ec47740", size = 25759, upload-time = "2026-03-04T14:17:24.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/6f/7ee0980afcbdcd2d40362da16f7f9796bd083bf7f0b8e038abfbc0300f5d/opentelemetry_exporter_otlp_proto_grpc-1.40.0-py3-none-any.whl", hash = "sha256:2aa0ca53483fe0cf6405087a7491472b70335bc5c7944378a0a8e72e86995c52", size = 20304, upload-time = "2026-03-04T14:17:05.942Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/fa/73d50e2c15c56be4d000c98e24221d494674b0cc95524e2a8cb3856d95a4/opentelemetry_exporter_otlp_proto_http-1.40.0.tar.gz", hash = "sha256:db48f5e0f33217588bbc00274a31517ba830da576e59503507c839b38fa0869c", size = 17772, upload-time = "2026-03-04T14:17:25.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/3a/8865d6754e61c9fb170cdd530a124a53769ee5f740236064816eb0ca7301/opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl", hash = "sha256:a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069", size = 19960, upload-time = "2026-03-04T14:17:07.153Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/77/dd38991db037fdfce45849491cb61de5ab000f49824a00230afb112a4392/opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd", size = 45667, upload-time = "2026-03-04T14:17:31.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f", size = 72073, upload-time = "2026-03-04T14:17:16.673Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2", size = 184252, upload-time = "2026-03-04T14:17:31.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1", size = 141951, upload-time = "2026-03-04T14:17:17.961Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a", size = 145755, upload-time = "2026-03-04T14:17:32.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, + { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" }, + { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "sigil-sdk" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "protobuf" }, +] + +[package.optional-dependencies] +dev = [ + { name = "grpcio-tools" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "grpcio", specifier = ">=1.78.0" }, + { name = "grpcio-tools", marker = "extra == 'dev'", specifier = ">=1.78.0" }, + { name = "opentelemetry-api", specifier = ">=1.27.0" }, + { name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.27.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.27.0" }, + { name = "opentelemetry-proto", specifier = ">=1.27.0" }, + { name = "opentelemetry-sdk", specifier = ">=1.27.0" }, + { name = "protobuf", specifier = ">=6.31.1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +]