diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 7032e4fa467..7a9946fda6c 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -6,9 +6,15 @@ reviews: request_changes_workflow: false high_level_summary: true review_status: true + changed_files_summary: true + slop_detection: + enabled: true auto_review: enabled: true drafts: false + auto_incremental_review: true + base_branches: + - ".*" path_instructions: - path: "core/**" instructions: | diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 25fa245ac13..bf442efc726 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -264,3 +264,38 @@ updates: schedule: interval: daily open-pull-requests-limit: 0 + + - package-ecosystem: gomod + directory: /.github/workflows/scripts/schemasync + schedule: + interval: weekly + + - package-ecosystem: gomod + directory: /examples/mcps/oauth-demo-server + schedule: + interval: weekly + + - package-ecosystem: gomod + directory: /plugins/compat + schedule: + interval: weekly + + - package-ecosystem: gomod + directory: /plugins/prompts + schedule: + interval: weekly + + - package-ecosystem: gomod + directory: /tests/async + schedule: + interval: weekly + + - package-ecosystem: gomod + directory: /tests/e2e/clis + schedule: + interval: weekly + + - package-ecosystem: gomod + directory: /tests/semanticcache + schedule: + interval: weekly diff --git a/.github/workflows/helm-release.yml b/.github/workflows/helm-release.yml index ec0738f951a..ee6aecb0f83 100644 --- a/.github/workflows/helm-release.yml +++ b/.github/workflows/helm-release.yml @@ -117,7 +117,7 @@ jobs: fi - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0 + uses: step-security/actions-gh-pages@db28a5e317d585916043c4acc5a4319ab3da9275 # v4.0.1 if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/v1.5.0' with: github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-pipeline.yml b/.github/workflows/release-pipeline.yml index 9958422bb5f..e0e306b9463 100644 --- a/.github/workflows/release-pipeline.yml +++ b/.github/workflows/release-pipeline.yml @@ -843,7 +843,7 @@ jobs: run: npm install -g newman newman-reporter-htmlextra - name: Setup Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: step-security/setup-buildx-action@f931205d68723ad9589fd2a7e2ece238bf9de341 # v4.0.0 - name: Test Docker image (amd64) env: @@ -963,7 +963,7 @@ jobs: run: npm install -g newman newman-reporter-htmlextra - name: Setup Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: step-security/setup-buildx-action@f931205d68723ad9589fd2a7e2ece238bf9de341 # v4.0.0 - name: Test Docker image (arm64) env: @@ -1653,10 +1653,10 @@ jobs: echo "verified=true" >> $GITHUB_OUTPUT - name: Setup Docker Buidx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: step-security/setup-buildx-action@f931205d68723ad9589fd2a7e2ece238bf9de341 # v4.0.0 - name: Log in to Docker Hub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: step-security/docker-login-action@870af644803bf9f204aed474adbad2958fec048b # v4.1.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} @@ -1670,7 +1670,7 @@ jobs: echo "tags=${BASE_TAG}" >> $GITHUB_OUTPUT - name: Build and push AMD64 Docker image - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: step-security/docker-build-push-action@846549baaf047e867d038826129a64d81df0f704 # v7.1.0 with: context: . build-args: | @@ -1741,10 +1741,10 @@ jobs: echo "verified=true" >> $GITHUB_OUTPUT - name: Setup Docker Buidx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: step-security/setup-buildx-action@f931205d68723ad9589fd2a7e2ece238bf9de341 # v4.0.0 - name: Log in to Docker Hub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: step-security/docker-login-action@870af644803bf9f204aed474adbad2958fec048b # v4.1.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} @@ -1758,7 +1758,7 @@ jobs: echo "tags=${BASE_TAG}" >> $GITHUB_OUTPUT - name: Build and push ARM64 Docker image - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: step-security/docker-build-push-action@846549baaf047e867d038826129a64d81df0f704 # v7.1.0 with: context: . file: ./transports/Dockerfile @@ -1793,7 +1793,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Log in to Docker Hub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: step-security/docker-login-action@870af644803bf9f204aed474adbad2958fec048b # v4.1.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} @@ -1864,10 +1864,10 @@ jobs: echo "verified=true" >> $GITHUB_OUTPUT - name: Setup Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: step-security/setup-buildx-action@f931205d68723ad9589fd2a7e2ece238bf9de341 # v4.0.0 - name: Log in to Docker Hub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: step-security/docker-login-action@870af644803bf9f204aed474adbad2958fec048b # v4.1.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} @@ -1881,7 +1881,7 @@ jobs: echo "tags=${BASE_TAG}" >> $GITHUB_OUTPUT - name: Build and push UBI9 AMD64 Docker image - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: step-security/docker-build-push-action@846549baaf047e867d038826129a64d81df0f704 # v7.1.0 with: context: . build-args: | @@ -1955,10 +1955,10 @@ jobs: echo "verified=true" >> $GITHUB_OUTPUT - name: Setup Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: step-security/setup-buildx-action@f931205d68723ad9589fd2a7e2ece238bf9de341 # v4.0.0 - name: Log in to Docker Hub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: step-security/docker-login-action@870af644803bf9f204aed474adbad2958fec048b # v4.1.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} @@ -1972,7 +1972,7 @@ jobs: echo "tags=${BASE_TAG}" >> $GITHUB_OUTPUT - name: Build and push UBI9 ARM64 Docker image - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: step-security/docker-build-push-action@846549baaf047e867d038826129a64d81df0f704 # v7.1.0 with: context: . file: ./transports/Dockerfile.redhat @@ -2009,7 +2009,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Log in to Docker Hub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: step-security/docker-login-action@870af644803bf9f204aed474adbad2958fec048b # v4.1.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} diff --git a/.github/workflows/snyk.yml b/.github/workflows/snyk.yml index ee3005df45d..2c5d0bda826 100644 --- a/.github/workflows/snyk.yml +++ b/.github/workflows/snyk.yml @@ -47,7 +47,7 @@ jobs: node-version: "25" - name: Install uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + uses: step-security/setup-uv@ccf0a26ce9117d9e99292b0ce953ea5d9ffe778e # v7.3.0 with: version: "0.11.0" python-version: "3.11" @@ -120,7 +120,7 @@ jobs: node-version: "25" - name: Install uv - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + uses: step-security/setup-uv@ccf0a26ce9117d9e99292b0ce953ea5d9ffe778e # v7.3.0 with: version: "0.11.0" python-version: "3.11" diff --git a/.gitignore b/.gitignore index 3702ed6ec6d..4decb801837 100644 --- a/.gitignore +++ b/.gitignore @@ -99,6 +99,7 @@ dist/ # IDE .idea/ .vscode/ +.zed/ # OS .DS_Store diff --git a/cli/go.mod b/cli/go.mod index 49c0e947c41..780e970f25a 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -3,7 +3,7 @@ module github.com/maximhq/bifrost/cli go 1.26.3 require ( - github.com/bytedance/sonic v1.15.0 + github.com/bytedance/sonic v1.15.1 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 @@ -18,7 +18,7 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bytedance/gopkg v0.1.3 // indirect - github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect @@ -46,7 +46,7 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/arch v0.23.0 // indirect - golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect + golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect ) diff --git a/cli/go.sum b/cli/go.sum index c903e6b185a..584cf141e0e 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -6,10 +6,10 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= -github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= -github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= -github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= -github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= +github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -89,8 +89,8 @@ github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8u github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= -golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= -golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= diff --git a/core/bifrost.go b/core/bifrost.go index a5d839cbc70..c9b2f04dbd0 100644 --- a/core/bifrost.go +++ b/core/bifrost.go @@ -6448,6 +6448,9 @@ func (bifrost *Bifrost) handleProviderRequest(provider schemas.Provider, config if bifrostError != nil { return nil, bifrostError } + if passthroughResponse != nil { + passthroughResponse.Path = req.BifrostRequest.PassthroughRequest.Path + } response.PassthroughResponse = passthroughResponse default: _, model, _ := req.BifrostRequest.GetRequestFields() diff --git a/core/changelog.md b/core/changelog.md index e1ee61de19b..bfea4d6dbdc 100644 --- a/core/changelog.md +++ b/core/changelog.md @@ -2,4 +2,4 @@ - fix: corrected Bedrock outputAssessments type - fix: added Model field to TextCompletionChunkResponse (thanks [@kuishou68](https://github.com/kuishou68)!) - fix: accept orphaned tool results in OpenAI to Anthropic conversion flow -- fix(mcp): allow inline stdio env assignments (thanks [@Shushmitaaaa](https://github.com/Shushmitaaaa)!) +- fix(mcp): allow inline stdio env assignments (thanks [@Shushmitaaaa](https://github.com/Shushmitaaaa)!) \ No newline at end of file diff --git a/core/go.mod b/core/go.mod index 508fd8ced24..79f7288be0a 100644 --- a/core/go.mod +++ b/core/go.mod @@ -6,7 +6,7 @@ require ( cloud.google.com/go v0.123.0 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 - github.com/andybalholm/brotli v1.2.0 + github.com/andybalholm/brotli v1.2.1 github.com/aws/aws-sdk-go-v2 v1.41.7 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 github.com/aws/aws-sdk-go-v2/config v1.32.11 @@ -14,18 +14,18 @@ require ( github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 github.com/aws/smithy-go v1.25.1 - github.com/bytedance/sonic v1.15.0 + github.com/bytedance/sonic v1.15.1 github.com/cespare/xxhash/v2 v2.3.0 github.com/fasthttp/websocket v1.5.12 github.com/google/uuid v1.6.0 github.com/hajimehoshi/go-mp3 v0.3.4 - github.com/klauspost/compress v1.18.2 + github.com/klauspost/compress v1.18.6 github.com/mark3labs/mcp-go v0.43.2 github.com/rs/zerolog v1.34.0 github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 - github.com/valyala/fasthttp v1.68.0 + github.com/valyala/fasthttp v1.71.0 go.starlark.net v0.0.0-20260102030733-3fee463870c9 golang.org/x/oauth2 v0.36.0 golang.org/x/text v0.37.0 @@ -51,10 +51,10 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/bytedance/gopkg v0.1.3 // indirect - github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect @@ -76,5 +76,5 @@ require ( golang.org/x/crypto v0.52.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/sys v0.45.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect ) diff --git a/core/go.sum b/core/go.sum index 34b55e72773..845df9306c1 100644 --- a/core/go.sum +++ b/core/go.sum @@ -14,8 +14,8 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= @@ -60,10 +60,10 @@ github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJ github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= -github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= -github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= -github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= -github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= +github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= 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/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= @@ -78,8 +78,8 @@ github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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= @@ -91,8 +91,8 @@ github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcI github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -150,8 +150,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok= -github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= @@ -177,8 +177,8 @@ golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/core/network/http.go b/core/network/http.go index a394588a6ea..392245f4927 100644 --- a/core/network/http.go +++ b/core/network/http.go @@ -5,6 +5,7 @@ package network import ( "crypto/tls" "encoding/base64" + "errors" "io" "net" "net/http" @@ -258,16 +259,21 @@ func StaleConnectionRetryIfErr(_ *fasthttp.Request, attempts int, err error) (re if err == nil { return false, false } - errStr := err.Error() - // io.EOF — server closed the connection (fasthttp converts this to - // ErrConnectionClosed AFTER the retry loop, so RetryIfErr sees raw EOF) - // "cannot find whitespace in the first line of response" — stale chunked data in buffer - // "connection reset by peer" — server RST'd the idle connection (read-side) - // "broken pipe" — server closed the idle connection (write-side EPIPE) - if err == io.EOF || + errStr := strings.ToLower(err.Error()) + // ErrConnectionClosed — server closed the connection before returning the first + // response byte. fasthttp converts raw io.EOF to this AFTER the retry loop, so + // RetryIfErr normally sees raw io.EOF; we match both to stay robust across versions. + // io.EOF / io.ErrUnexpectedEOF — server closed the connection. + // "cannot find whitespace in the first line of response" — stale chunked data in buffer. + // reset / broken pipe / closed connection variants — server or intermediary closed idle conn. + if errors.Is(err, fasthttp.ErrConnectionClosed) || + errors.Is(err, io.EOF) || + errors.Is(err, io.ErrUnexpectedEOF) || strings.Contains(errStr, "cannot find whitespace") || strings.Contains(errStr, "connection reset by peer") || - strings.Contains(errStr, "broken pipe") { + strings.Contains(errStr, "broken pipe") || + strings.Contains(errStr, "use of closed network connection") || + strings.Contains(errStr, "server closed connection") { return true, true } return false, false diff --git a/core/network/http_test.go b/core/network/http_test.go index 858d318574f..715b1cbbfa3 100644 --- a/core/network/http_test.go +++ b/core/network/http_test.go @@ -46,6 +46,27 @@ func TestStaleConnectionRetryIfErr(t *testing.T) { wantReset: true, wantRetry: true, }, + { + name: "retries on wrapped io.EOF", + err: fmt.Errorf("read response: %w", io.EOF), + attempts: 1, + wantReset: true, + wantRetry: true, + }, + { + name: "retries on unexpected EOF", + err: io.ErrUnexpectedEOF, + attempts: 1, + wantReset: true, + wantRetry: true, + }, + { + name: "retries on wrapped unexpected EOF", + err: fmt.Errorf("read response: %w", io.ErrUnexpectedEOF), + attempts: 1, + wantReset: true, + wantRetry: true, + }, { name: "retries on broken pipe (write to closed connection)", err: fmt.Errorf("write tcp 10.0.0.1:53374->10.0.0.2:30000: write: broken pipe"), @@ -53,6 +74,32 @@ func TestStaleConnectionRetryIfErr(t *testing.T) { wantReset: true, wantRetry: true, }, + { + name: "retries on use of closed network connection", + err: fmt.Errorf("read tcp 10.0.0.1:53374->10.0.0.2:443: use of closed network connection"), + attempts: 1, + wantReset: true, + wantRetry: true, + }, + { + name: "retries on server closed connection", + err: fmt.Errorf("server closed connection before returning the first response byte"), + attempts: 1, + wantReset: true, + wantRetry: true, + }, + { + // fasthttp.ErrConnectionClosed is treated as retryable: it means the server + // closed an idle keep-alive connection before sending any response byte (a + // stale connection). In fasthttp v1.68.0 the callback actually receives raw + // io.EOF — the sentinel is only produced AFTER the retry loop (client.go:1413) — + // but we match it explicitly to stay correct if a future version surfaces it. + name: "retries on fasthttp.ErrConnectionClosed sentinel", + err: fasthttp.ErrConnectionClosed, + attempts: 1, + wantReset: true, + wantRetry: true, + }, { name: "does not retry on second attempt", err: io.EOF, @@ -411,7 +458,7 @@ func TestMaxConnWaitTimeoutAlignedWithReadTimeout(t *testing.T) { defer server.Close() client := &fasthttp.Client{ - MaxConnsPerHost: 1, // Only 1 connection allowed — second request must wait + MaxConnsPerHost: 1, // Only 1 connection allowed — second request must wait MaxConnWaitTimeout: 2 * time.Second, // Wait up to 2s for a free connection slot ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, diff --git a/core/network/multipart.go b/core/network/multipart.go index 1e15e942930..5910bddd762 100644 --- a/core/network/multipart.go +++ b/core/network/multipart.go @@ -43,7 +43,13 @@ func ParseMultipartFormFields(contentType string, body []byte) (map[string]any, _ = part.Close() return nil, readErr } - payload[name] = string(val) + if _, ok := payload[name]; !ok { + payload[name] = string(val) + } else if item, isString := payload[name].(string); isString { + payload[name] = []string{item, string(val)} + } else if items, isList := payload[name].([]string); isList { + payload[name] = append(items, string(val)) + } } _ = part.Close() } @@ -88,6 +94,10 @@ func ReconstructMultipartBody(origContentType string, origBody []byte, payload m } } else if name != "" { if val, ok := payload[name]; ok { + if writtenFields[name] { + _ = part.Close() + continue + } if err := WriteMultipartField(writer, name, val); err != nil { _ = part.Close() return nil, "", err @@ -130,11 +140,12 @@ func WriteMultipartField(writer *multipart.Writer, name string, val any) error { case string: return writer.WriteField(name, v) case []string: - encoded, err := schemas.MarshalSorted(v) - if err != nil { - return err + for _, item := range v { + if err := writer.WriteField(name, item); err != nil { + return err + } } - return writer.WriteField(name, string(encoded)) + return nil default: return writer.WriteField(name, fmt.Sprintf("%v", val)) } diff --git a/core/network/multipart_test.go b/core/network/multipart_test.go index 26887a516f4..32565d6f680 100644 --- a/core/network/multipart_test.go +++ b/core/network/multipart_test.go @@ -220,7 +220,7 @@ func TestWriteMultipartField(t *testing.T) { } }) - t.Run("writes []string as JSON array", func(t *testing.T) { + t.Run("writes []string as multiple values", func(t *testing.T) { var buf bytes.Buffer writer := multipart.NewWriter(&buf) if err := WriteMultipartField(writer, "tags", []string{"a", "b"}); err != nil { @@ -231,14 +231,10 @@ func TestWriteMultipartField(t *testing.T) { if err != nil { t.Fatalf("parse error: %v", err) } - val, ok := parsed["tags"].(string) + arr, ok := parsed["tags"].([]string) if !ok { t.Fatalf("tags not a string, got %T", parsed["tags"]) } - var arr []string - if err := sonic.UnmarshalString(val, &arr); err != nil { - t.Fatalf("failed to unmarshal tags JSON: %v", err) - } if len(arr) != 2 || arr[0] != "a" || arr[1] != "b" { t.Errorf("tags = %v, want [a b]", arr) } diff --git a/core/network/utils.go b/core/network/utils.go new file mode 100644 index 00000000000..2df72933635 --- /dev/null +++ b/core/network/utils.go @@ -0,0 +1,62 @@ +package network + +import "net" + +// IsLocalhost reports whether hostname is localhost or a loopback literal. +func IsLocalhost(hostname string) bool { + return hostname == "localhost" || + hostname == "127.0.0.1" || + hostname == "::1" || + hostname == "0.0.0.0" || + hostname == "::" +} + +var privateSubnets []*net.IPNet +var linkLocalSubnet *net.IPNet + +func init() { + for _, cidr := range []string{ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "169.254.0.0/16", // link-local / AWS metadata + "127.0.0.0/8", // loopback + } { + _, subnet, _ := net.ParseCIDR(cidr) + privateSubnets = append(privateSubnets, subnet) + } + _, linkLocalSubnet, _ = net.ParseCIDR("169.254.0.0/16") +} + +// IsLinkLocal reports whether ip is a link-local address. +// These are always blocked regardless of AllowPrivateNetwork — they include +// cloud instance metadata endpoints (169.254.169.254, fe80::) that must +// never be reachable even in private-network deployments. +func IsLinkLocal(ip net.IP) bool { + if ip.To4() != nil { + return linkLocalSubnet.Contains(ip) + } + return ip.IsLinkLocalUnicast() +} + +// IsPrivateIP reports whether ip falls in a private, loopback, or link-local range. +func IsPrivateIP(ip net.IP) bool { + if ip == nil || ip.IsUnspecified() { + return true + } + for _, subnet := range privateSubnets { + if subnet.Contains(ip) { + return true + } + } + // IPv6: loopback, link-local, unique-local (fc00::/7) + if ip.To4() == nil { + if ip.IsLoopback() || ip.IsLinkLocalUnicast() { + return true + } + if len(ip) == 16 && (ip[0]&0xfe) == 0xfc { + return true + } + } + return false +} diff --git a/core/providers/anthropic/anthropic.go b/core/providers/anthropic/anthropic.go index 21bf81db78d..c3096ae2402 100644 --- a/core/providers/anthropic/anthropic.go +++ b/core/providers/anthropic/anthropic.go @@ -100,7 +100,7 @@ func NewAnthropicProvider(config *schemas.ProviderConfig, logger schemas.Logger) // Configure proxy and retry policy client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) - client = providerUtils.ConfigureDialer(client) + client = providerUtils.ConfigureDialer(client, config.NetworkConfig.AllowPrivateNetwork) client = providerUtils.ConfigureTLS(client, config.NetworkConfig, logger) streamingClient := providerUtils.BuildStreamingClient(client) // Set default BaseURL if not provided @@ -2597,7 +2597,7 @@ func (provider *AnthropicProvider) Passthrough( return nil, bifrostErr } - headers := providerUtils.ExtractProviderResponseHeaders(resp) + headers := providerUtils.ExtractPassthroughProviderResponseHeaders(resp) ctx.SetValue(schemas.BifrostContextKeyProviderResponseHeaders, headers) body, err := providerUtils.CheckAndDecodeBody(resp) @@ -2605,6 +2605,11 @@ func (provider *AnthropicProvider) Passthrough( return nil, providerUtils.NewBifrostOperationError("failed to decode response body", err) } + var passthroughUsage *schemas.BifrostPassthroughUsage + if resp.StatusCode() >= 200 && resp.StatusCode() < 300 { + passthroughUsage = ExtractAnthropicPassthroughUsage(req.Path, req.Body, body) + } + bifrostResponse := &schemas.BifrostPassthroughResponse{ StatusCode: resp.StatusCode(), Headers: headers, @@ -2612,7 +2617,9 @@ func (provider *AnthropicProvider) Passthrough( ExtraFields: schemas.BifrostResponseExtraFields{ Latency: latency.Milliseconds(), ProviderResponseHeaders: headers, + PassthroughPath: req.Path, }, + PassthroughUsage: passthroughUsage, } return bifrostResponse, nil @@ -2678,7 +2685,7 @@ func (provider *AnthropicProvider) PassthroughStream( return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderDoRequest, err) } - headers := providerUtils.ExtractProviderResponseHeaders(resp) + headers := providerUtils.ExtractPassthroughProviderResponseHeaders(resp) ctx.SetValue(schemas.BifrostContextKeyProviderResponseHeaders, headers) bodyStream := resp.BodyStream() @@ -2690,71 +2697,32 @@ func (provider *AnthropicProvider) PassthroughStream( ) } - // Wrap reader with idle timeout to detect stalled streams. providerUtils.SetStreamIdleTimeoutIfEmpty(ctx, provider.networkConfig.StreamIdleTimeoutInSeconds) - rawBodyStream := bodyStream - bodyStream, stopIdleTimeout := providerUtils.NewIdleTimeoutReader(bodyStream, rawBodyStream, providerUtils.GetStreamIdleTimeout(ctx), ctx) - - // Cancellation must close the raw stream to unblock reads. - stopCancellation := providerUtils.SetupStreamCancellation(ctx, rawBodyStream, provider.logger) - - extraFields := schemas.BifrostResponseExtraFields{ - ProviderResponseHeaders: headers, - } - statusCode := resp.StatusCode() - - ch := make(chan *schemas.BifrostStreamChunk, schemas.DefaultStreamBufferSize) - go func() { - defer providerUtils.EnsureStreamFinalizerCalled(ctx, postHookSpanFinalizer) - defer func() { - if ctx.Err() == context.Canceled { - providerUtils.HandleStreamCancellation(ctx, postHookRunner, ch, provider.logger, postHookSpanFinalizer, req.Body) - } else if ctx.Err() == context.DeadlineExceeded { - providerUtils.HandleStreamTimeout(ctx, postHookRunner, ch, provider.logger, postHookSpanFinalizer, req.Body) - } - close(ch) - }() - defer providerUtils.ReleaseStreamingResponse(ctx, resp) - defer stopIdleTimeout() - defer stopCancellation() - - buf := make([]byte, 4096) - for { - n, readErr := bodyStream.Read(buf) - if n > 0 { - chunk := make([]byte, n) - copy(chunk, buf[:n]) - providerUtils.ProcessAndSendResponse(ctx, postHookRunner, &schemas.BifrostResponse{ - PassthroughResponse: &schemas.BifrostPassthroughResponse{ - StatusCode: statusCode, - Headers: headers, - Body: chunk, - ExtraFields: extraFields, - }, - }, ch, postHookSpanFinalizer) - } - if readErr == io.EOF { - ctx.SetValue(schemas.BifrostContextKeyStreamEndIndicator, true) - extraFields.Latency = time.Since(startTime).Milliseconds() - providerUtils.ProcessAndSendResponse(ctx, postHookRunner, &schemas.BifrostResponse{ - PassthroughResponse: &schemas.BifrostPassthroughResponse{ - StatusCode: statusCode, - Headers: headers, - ExtraFields: extraFields, - }, - }, ch, postHookSpanFinalizer) - return - } - if readErr != nil { - if ctx.Err() != nil { - return // let defer handle cancel/timeout + strippedPath := req.Path + if idx := strings.IndexByte(strippedPath, '?'); idx >= 0 { + strippedPath = strippedPath[:idx] + } + var messagesUsage *AnthropicPassthroughStreamUsage + if strings.HasSuffix(strippedPath, "/messages") { + messagesUsage = &AnthropicPassthroughStreamUsage{} + } + return providerUtils.StreamPassthrough( + ctx, postHookRunner, postHookSpanFinalizer, resp, bodyStream, + providerUtils.PassthroughStreamParams{ + StatusCode: resp.StatusCode(), + Headers: headers, + Path: req.Path, + RawRequest: req.Body, + CancellationBody: req.Body, + StartTime: startTime, + Logger: provider.logger, + HasUsage: HasAnthropicPassthroughUsage, + Observe: func(event []byte) *schemas.BifrostPassthroughUsage { + if messagesUsage != nil { + return messagesUsage.ObserveEvent(event) } - ctx.SetValue(schemas.BifrostContextKeyStreamEndIndicator, true) - extraFields.Latency = time.Since(startTime).Milliseconds() - providerUtils.ProcessAndSendError(ctx, postHookRunner, readErr, ch, provider.logger, postHookSpanFinalizer) - return - } - } - }() - return ch, nil + return ExtractAnthropicPassthroughUsage(req.Path, req.Body, event) + }, + }, + ), nil } diff --git a/core/providers/anthropic/passthrough_usage.go b/core/providers/anthropic/passthrough_usage.go new file mode 100644 index 00000000000..1996e4521e2 --- /dev/null +++ b/core/providers/anthropic/passthrough_usage.go @@ -0,0 +1,188 @@ +package anthropic + +import ( + "strings" + + "github.com/bytedance/sonic" + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + "github.com/maximhq/bifrost/core/schemas" +) + +// ExtractAnthropicPassthroughUsage extracts usage from a passthrough response payload. path is +// the stripped request path; body is a single SSE data event (streaming) or the full response +// body (non-streaming). Streaming /messages usage is assembled per-event by +// AnthropicPassthroughStreamUsage, so here /messages only ever sees a plain JSON body. +func ExtractAnthropicPassthroughUsage(path string, _, body []byte) *schemas.BifrostPassthroughUsage { + if idx := strings.IndexByte(path, '?'); idx >= 0 { + path = path[:idx] + } + + switch { + case strings.HasSuffix(path, "/messages"): + return extractAnthropicMessagesUsage(body) + case strings.HasSuffix(path, "/complete"): + return extractAnthropicCompleteUsage(body) + } + return nil +} + +func HasAnthropicPassthroughUsage(event []byte) bool { + return providerUtils.GetJSONField(event, "usage").Exists() || + providerUtils.GetJSONField(event, "message.usage").Exists() +} + +// buildAnthropicPassthroughUsage converts AnthropicUsage directly into BifrostPassthroughUsage. +func buildAnthropicPassthroughUsage(au *AnthropicUsage) *schemas.BifrostPassthroughUsage { + if au == nil { + return nil + } + totalInput := au.InputTokens + au.CacheReadInputTokens + au.CacheCreationInputTokens + total := totalInput + au.OutputTokens + if total == 0 { + return nil + } + + usage := &schemas.BifrostLLMUsage{ + PromptTokens: totalInput, + CompletionTokens: au.OutputTokens, + TotalTokens: total, + } + + if au.CacheReadInputTokens > 0 || au.CacheCreationInputTokens > 0 { + details := &schemas.ChatPromptTokensDetails{ + CachedReadTokens: au.CacheReadInputTokens, + CachedWriteTokens: au.CacheCreationInputTokens, + } + if au.CacheCreation.Ephemeral5mInputTokens > 0 || au.CacheCreation.Ephemeral1hInputTokens > 0 { + details.CachedWriteTokenDetails = &schemas.ChatCachedWriteTokenDetails{ + CachedWriteTokens5m: au.CacheCreation.Ephemeral5mInputTokens, + CachedWriteTokens1h: au.CacheCreation.Ephemeral1hInputTokens, + } + } + usage.PromptTokensDetails = details + } + + if au.ServerToolUse != nil && au.ServerToolUse.WebSearchRequests > 0 { + n := au.ServerToolUse.WebSearchRequests + usage.CompletionTokensDetails = &schemas.ChatCompletionTokensDetails{ + NumSearchQueries: &n, + } + } + + u := &schemas.BifrostPassthroughUsage{LLMUsage: usage} + if au.ServiceTier != nil { + t := MapAnthropicServiceTierToBifrost(*au.ServiceTier) + u.ServiceTier = &t + } + return u +} + +// AnthropicPassthroughStreamUsage incrementally merges /v1/messages stream usage across events +// without retaining the response body. Anthropic splits usage: message_start nests it under +// message.usage (input, cache tokens incl. 5m/1h split, service_tier), while message_delta has +// it at the top level (final output). Taking the max of each field across events combines them +// order-independently — the same merge the native Anthropic stream does (anthropic.go). +type AnthropicPassthroughStreamUsage struct { + combined AnthropicUsage + seen bool +} + +// ObserveEvent merges one framed SSE data payload's usage into the running total and returns +// the running usage (nil until any usage-bearing event is seen). +func (a *AnthropicPassthroughStreamUsage) ObserveEvent(event []byte) *schemas.BifrostPassthroughUsage { + var evt AnthropicStreamEvent + if err := sonic.Unmarshal(event, &evt); err != nil { + return a.usage() + } + // message_delta carries usage at the top level; message_start nests it under message.usage. + var u *AnthropicUsage + if evt.Usage != nil { + u = evt.Usage + } else if evt.Message != nil && evt.Message.Usage != nil { + u = evt.Message.Usage + } + if u == nil { + return a.usage() + } + + a.seen = true + c := &a.combined + if u.InputTokens > c.InputTokens { + c.InputTokens = u.InputTokens + } + if u.OutputTokens > c.OutputTokens { + c.OutputTokens = u.OutputTokens + } + if u.CacheReadInputTokens > c.CacheReadInputTokens { + c.CacheReadInputTokens = u.CacheReadInputTokens + } + if u.CacheCreationInputTokens > c.CacheCreationInputTokens { + c.CacheCreationInputTokens = u.CacheCreationInputTokens + } + if u.CacheCreation.Ephemeral5mInputTokens > c.CacheCreation.Ephemeral5mInputTokens { + c.CacheCreation.Ephemeral5mInputTokens = u.CacheCreation.Ephemeral5mInputTokens + } + if u.CacheCreation.Ephemeral1hInputTokens > c.CacheCreation.Ephemeral1hInputTokens { + c.CacheCreation.Ephemeral1hInputTokens = u.CacheCreation.Ephemeral1hInputTokens + } + if u.ServerToolUse != nil { + if c.ServerToolUse == nil { + c.ServerToolUse = &AnthropicServerToolUseUsage{} + } + if u.ServerToolUse.WebSearchRequests > c.ServerToolUse.WebSearchRequests { + c.ServerToolUse.WebSearchRequests = u.ServerToolUse.WebSearchRequests + } + } + if u.ServiceTier != nil { + c.ServiceTier = u.ServiceTier + } + return a.usage() +} + +func (a *AnthropicPassthroughStreamUsage) usage() *schemas.BifrostPassthroughUsage { + if !a.seen { + return nil + } + return buildAnthropicPassthroughUsage(&a.combined) +} + +// extractAnthropicMessagesUsage parses usage from a /v1/messages response body. Streaming usage +// is assembled per-event by AnthropicPassthroughStreamUsage, so this only sees a plain JSON +// (non-streaming) body, which carries the full usage block at the top level. +func extractAnthropicMessagesUsage(body []byte) *schemas.BifrostPassthroughUsage { + if len(body) == 0 { + return nil + } + var resp AnthropicMessageResponse + if err := sonic.Unmarshal(body, &resp); err != nil || resp.Usage == nil { + return nil + } + return buildAnthropicPassthroughUsage(resp.Usage) +} + +// extractAnthropicCompleteUsage handles the legacy /v1/complete endpoint. +func extractAnthropicCompleteUsage(body []byte) *schemas.BifrostPassthroughUsage { + if len(body) == 0 { + return nil + } + var resp struct { + Usage *struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + } `json:"usage"` + } + if err := sonic.Unmarshal(body, &resp); err != nil || resp.Usage == nil { + return nil + } + total := resp.Usage.InputTokens + resp.Usage.OutputTokens + if total == 0 { + return nil + } + return &schemas.BifrostPassthroughUsage{ + LLMUsage: &schemas.BifrostLLMUsage{ + PromptTokens: resp.Usage.InputTokens, + CompletionTokens: resp.Usage.OutputTokens, + TotalTokens: total, + }, + } +} diff --git a/core/providers/anthropic/passthrough_usage_test.go b/core/providers/anthropic/passthrough_usage_test.go new file mode 100644 index 00000000000..065cc33f86a --- /dev/null +++ b/core/providers/anthropic/passthrough_usage_test.go @@ -0,0 +1,167 @@ +package anthropic_test + +import ( + "testing" + + "github.com/maximhq/bifrost/core/providers/anthropic" + "github.com/maximhq/bifrost/core/schemas" +) + +func TestExtractAnthropicPassthroughUsage(t *testing.T) { + tests := []struct { + name string + path string + body string + check func(t *testing.T, u *schemas.BifrostPassthroughUsage) + }{ + { + name: "messages non-stream usage + service tier mapping", + path: "/v1/messages", + body: `{"usage":{"input_tokens":66,"output_tokens":26,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"service_tier":"standard"}}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + anthropicMustLLM(t, u, 66, 26, 92) + // Anthropic "standard" normalizes to the neutral "default". + if u.ServiceTier == nil || *u.ServiceTier != schemas.BifrostServiceTierDefault { + t.Fatalf("service tier = %v, want default", u.ServiceTier) + } + }, + }, + { + name: "messages with cache 5m/1h breakdown", + path: "/v1/messages", + body: `{"usage":{"input_tokens":10,"output_tokens":5,"cache_read_input_tokens":3,"cache_creation_input_tokens":7,"cache_creation":{"ephemeral_5m_input_tokens":4,"ephemeral_1h_input_tokens":3}}}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + // PromptTokens = input + cache_read + cache_creation = 10 + 3 + 7 = 20 + anthropicMustLLM(t, u, 20, 5, 25) + d := u.LLMUsage.PromptTokensDetails + if d == nil || d.CachedReadTokens != 3 || d.CachedWriteTokens != 7 { + t.Fatalf("cache tokens = %+v", d) + } + if d.CachedWriteTokenDetails == nil || d.CachedWriteTokenDetails.CachedWriteTokens1h != 3 || + d.CachedWriteTokenDetails.CachedWriteTokens5m != 4 { + t.Fatalf("5m/1h breakdown = %+v", d.CachedWriteTokenDetails) + } + }, + }, + { + name: "messages with web search server tool", + path: "/v1/messages", + body: `{"usage":{"input_tokens":10,"output_tokens":5,"server_tool_use":{"web_search_requests":2}}}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + if u == nil || u.LLMUsage == nil || u.LLMUsage.CompletionTokensDetails == nil || + u.LLMUsage.CompletionTokensDetails.NumSearchQueries == nil || + *u.LLMUsage.CompletionTokensDetails.NumSearchQueries != 2 { + t.Fatalf("web search requests = %+v, want 2", u) + } + }, + }, + { + name: "messages with priority tier", + path: "/v1/messages", + body: `{"usage":{"input_tokens":1,"output_tokens":1,"service_tier":"priority"}}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + if u == nil || u.ServiceTier == nil || *u.ServiceTier != schemas.BifrostServiceTierPriority { + t.Fatalf("service tier = %v, want priority", u.ServiceTier) + } + }, + }, + { + name: "legacy complete endpoint", + path: "/v1/complete", + body: `{"completion":"hi","usage":{"input_tokens":8,"output_tokens":4}}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + anthropicMustLLM(t, u, 8, 4, 12) + }, + }, + { + name: "messages zero usage -> nil", + path: "/v1/messages", + body: `{"usage":{"input_tokens":0,"output_tokens":0}}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + if u != nil { + t.Fatalf("expected nil, got %+v", u) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u := anthropic.ExtractAnthropicPassthroughUsage(tt.path, nil, []byte(tt.body)) + tt.check(t, u) + }) + } +} + +// TestAnthropicPassthroughStreamUsage exercises the per-event max-merge accumulator used for +// streaming /messages: input/cache/tier come from message_start, final output from message_delta. +func TestAnthropicPassthroughStreamUsage(t *testing.T) { + t.Run("merges message_start + message_delta", func(t *testing.T) { + acc := &anthropic.AnthropicPassthroughStreamUsage{} + + // Non-usage event before any usage -> still nil. + if u := acc.ObserveEvent([]byte(`{"type":"content_block_start","index":0}`)); u != nil { + t.Fatalf("expected nil before any usage event, got %+v", u) + } + + // message_start: input + cache 5m/1h + service_tier (output is a placeholder here). + acc.ObserveEvent([]byte(`{"type":"message_start","message":{"usage":{"input_tokens":66,"cache_read_input_tokens":2,"cache_creation_input_tokens":5,"cache_creation":{"ephemeral_5m_input_tokens":2,"ephemeral_1h_input_tokens":3},"output_tokens":1,"service_tier":"standard"}}}`)) + // content delta carries no usage; must not disturb the running totals. + acc.ObserveEvent([]byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}`)) + // message_delta: final output tokens. + u := acc.ObserveEvent([]byte(`{"type":"message_delta","usage":{"output_tokens":26}}`)) + + // PromptTokens = input(66) + cache_read(2) + cache_creation(5) = 73; output = 26. + anthropicMustLLM(t, u, 73, 26, 99) + d := u.LLMUsage.PromptTokensDetails + if d == nil || d.CachedWriteTokenDetails == nil || d.CachedWriteTokenDetails.CachedWriteTokens1h != 3 { + t.Fatalf("1h cache split lost in merge: %+v", d) + } + // service_tier from message_start, normalized. + if u.ServiceTier == nil || *u.ServiceTier != schemas.BifrostServiceTierDefault { + t.Fatalf("service tier = %v, want default", u.ServiceTier) + } + }) + + t.Run("server tool use from message_delta", func(t *testing.T) { + acc := &anthropic.AnthropicPassthroughStreamUsage{} + acc.ObserveEvent([]byte(`{"type":"message_start","message":{"usage":{"input_tokens":10,"output_tokens":1}}}`)) + u := acc.ObserveEvent([]byte(`{"type":"message_delta","usage":{"output_tokens":4,"server_tool_use":{"web_search_requests":3}}}`)) + if u == nil || u.LLMUsage == nil || u.LLMUsage.CompletionTokensDetails == nil || + u.LLMUsage.CompletionTokensDetails.NumSearchQueries == nil || + *u.LLMUsage.CompletionTokensDetails.NumSearchQueries != 3 { + t.Fatalf("web search requests = %+v, want 3", u) + } + }) +} + +func TestHasAnthropicPassthroughUsage(t *testing.T) { + tests := []struct { + name string + event string + want bool + }{ + {"message_start (nested message.usage)", `{"type":"message_start","message":{"usage":{"input_tokens":1}}}`, true}, + {"message_delta (top-level usage)", `{"type":"message_delta","usage":{"output_tokens":1}}`, true}, + {"content_block_delta", `{"type":"content_block_delta","delta":{"text":"x"}}`, false}, + {"ping", `{"type":"ping"}`, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := anthropic.HasAnthropicPassthroughUsage([]byte(tt.event)); got != tt.want { + t.Fatalf("HasAnthropicPassthroughUsage = %v, want %v", got, tt.want) + } + }) + } +} + +func anthropicMustLLM(t *testing.T, u *schemas.BifrostPassthroughUsage, prompt, completion, total int) { + t.Helper() + if u == nil || u.LLMUsage == nil { + t.Fatalf("expected LLMUsage, got %+v", u) + } + if u.LLMUsage.PromptTokens != prompt || u.LLMUsage.CompletionTokens != completion || u.LLMUsage.TotalTokens != total { + t.Fatalf("LLMUsage = {prompt:%d completion:%d total:%d}, want {%d %d %d}", + u.LLMUsage.PromptTokens, u.LLMUsage.CompletionTokens, u.LLMUsage.TotalTokens, prompt, completion, total) + } +} diff --git a/core/providers/anthropic/requestbuilder_test.go b/core/providers/anthropic/requestbuilder_test.go index 3a12999c34e..7963f6c384f 100644 --- a/core/providers/anthropic/requestbuilder_test.go +++ b/core/providers/anthropic/requestbuilder_test.go @@ -125,6 +125,37 @@ func TestBuildAnthropicResponsesRequestBody_RawBodyPath(t *testing.T) { } }) + t.Run("azure_strips_claude_code_diagnostics", func(t *testing.T) { + ctx := schemas.NewBifrostContext(context.Background(), time.Time{}) + ctx.SetValue(schemas.BifrostContextKeyUseRawRequestBody, true) + + request := &schemas.BifrostResponsesRequest{ + Provider: schemas.Azure, + Model: "claude-opus-4-7", + RawRequestBody: []byte(`{ + "model":"claude-opus-4-7", + "max_tokens":64000, + "messages":[{"role":"user","content":"hi"}], + "diagnostics":{"previous_message_id":null} + }`), + } + + result, err := BuildAnthropicResponsesRequestBody(ctx, request, AnthropicRequestBuildConfig{ + Provider: schemas.Azure, + Deployment: "my-azure-deployment", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if providerUtils.JSONFieldExists(result, "diagnostics") { + t.Fatalf("expected diagnostics to be stripped for Azure, got: %s", string(result)) + } + if providerUtils.GetJSONField(result, "model").String() != "my-azure-deployment" { + t.Fatalf("expected Azure deployment model rewrite, got: %s", string(result)) + } + }) + t.Run("adds_max_tokens_if_missing", func(t *testing.T) { ctx := schemas.NewBifrostContext(context.Background(), time.Time{}) ctx.SetValue(schemas.BifrostContextKeyUseRawRequestBody, true) diff --git a/core/providers/anthropic/types.go b/core/providers/anthropic/types.go index 10404671d97..7c196d2d514 100644 --- a/core/providers/anthropic/types.go +++ b/core/providers/anthropic/types.go @@ -135,6 +135,7 @@ type ProviderFeatureSupport struct { FileSearch bool // file_search server tool (OpenAI-only) ImageGeneration bool // image_generation server tool (OpenAI-only) ServiceTier bool // service_tier request field — strip when false (Vertex uses headers instead) + Diagnostics bool // diagnostics request field — undocumented Claude Code session-continuity field (diagnostics.previous_message_id); not in the public Messages API reference, so treated as Claude API only and stripped elsewhere (fail-closed). Azure rejects it; Bedrock/Vertex undocumented. } // ProviderFeatures maps each provider to its supported Anthropic features. @@ -153,6 +154,7 @@ var ProviderFeatures = map[schemas.ModelProvider]ProviderFeatureSupport{ FastMode: true, RedactThinking: true, TaskBudgets: true, InferenceGeo: true, EagerInputStreaming: true, AdvisorTool: true, ServiceTier: true, + Diagnostics: true, // Claude Code talks to the direct API and sends diagnostics.previous_message_id; only this provider keeps it. }, // Google Vertex AI — cite: A (overview table) and V-platform. // Notably NOT supported: MCP (MCP-excl), Skills/container.skills, diff --git a/core/providers/anthropic/utils.go b/core/providers/anthropic/utils.go index 6db61840ee4..1f9c4e1b102 100644 --- a/core/providers/anthropic/utils.go +++ b/core/providers/anthropic/utils.go @@ -372,6 +372,15 @@ func StripUnsupportedFieldsFromRawBody(jsonBody []byte, provider schemas.ModelPr var err error + // diagnostics — undocumented Claude Code field; gated through the feature + // map like every other field. Only Anthropic direct keeps it (fail-closed). + if !features.Diagnostics && providerUtils.JSONFieldExists(jsonBody, "diagnostics") { + jsonBody, err = providerUtils.DeleteJSONField(jsonBody, "diagnostics") + if err != nil { + return nil, fmt.Errorf("strip raw diagnostics: %w", err) + } + } + // speed — provider AND model gate if providerUtils.JSONFieldExists(jsonBody, "speed") { if !features.FastMode || !SupportsFastMode(model) { diff --git a/core/providers/anthropic/utils_test.go b/core/providers/anthropic/utils_test.go index 10982774de8..340711d803c 100644 --- a/core/providers/anthropic/utils_test.go +++ b/core/providers/anthropic/utils_test.go @@ -1479,6 +1479,31 @@ func TestNetworkConfigBetaOverridesFlow(t *testing.T) { } func TestStripUnsupportedFieldsFromRawBody(t *testing.T) { + t.Run("diagnostics_gated_via_feature_map", func(t *testing.T) { + // diagnostics is an undocumented Claude Code session-continuity field + // (diagnostics.previous_message_id). Only Anthropic direct keeps it; + // every other provider strips it fail-closed via Diagnostics=false. + const body = `{"model":"claude-opus-4-7","diagnostics":{"previous_message_id":null}}` + // Anthropic keeps it. + result, err := StripUnsupportedFieldsFromRawBody([]byte(body), schemas.Anthropic, "claude-opus-4-7") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !providerUtils.JSONFieldExists(result, "diagnostics") { + t.Errorf("expected diagnostics to be kept for Anthropic, got: %s", string(result)) + } + // Azure, Bedrock, Vertex strip it. + for _, provider := range []schemas.ModelProvider{schemas.Azure, schemas.Bedrock, schemas.Vertex} { + result, err := StripUnsupportedFieldsFromRawBody([]byte(body), provider, "claude-opus-4-7") + if err != nil { + t.Fatalf("unexpected error for %s: %v", provider, err) + } + if providerUtils.JSONFieldExists(result, "diagnostics") { + t.Errorf("expected diagnostics to be stripped for %s, got: %s", provider, string(result)) + } + } + }) + t.Run("bedrock_strips_new_request_level_fields", func(t *testing.T) { // Raw body with every new typed field. Targeting Bedrock: speed (no FastMode), // inference_geo (no InferenceGeo), mcp_servers (no MCP), container.skills diff --git a/core/providers/azure/azure.go b/core/providers/azure/azure.go index 57050e58ace..c0d44423bd5 100644 --- a/core/providers/azure/azure.go +++ b/core/providers/azure/azure.go @@ -186,7 +186,7 @@ func NewAzureProvider(config *schemas.ProviderConfig, logger schemas.Logger) (*A // Configure proxy and retry policy client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) - client = providerUtils.ConfigureDialer(client) + client = providerUtils.ConfigureDialer(client, config.NetworkConfig.AllowPrivateNetwork) client = providerUtils.ConfigureTLS(client, config.NetworkConfig, logger) streamingClient := providerUtils.BuildStreamingClient(client) return &AzureProvider{ @@ -3538,7 +3538,7 @@ func (provider *AzureProvider) Passthrough( return nil, bifrostErr } - headers := providerUtils.ExtractProviderResponseHeaders(resp) + headers := providerUtils.ExtractPassthroughProviderResponseHeaders(resp) ctx.SetValue(schemas.BifrostContextKeyProviderResponseHeaders, headers) body, err := providerUtils.CheckAndDecodeBody(resp) @@ -3546,6 +3546,11 @@ func (provider *AzureProvider) Passthrough( return nil, providerUtils.NewBifrostOperationError("failed to decode response body", err) } + var passthroughUsage *schemas.BifrostPassthroughUsage + if resp.StatusCode() >= 200 && resp.StatusCode() < 300 { + passthroughUsage = extractAzurePassthroughUsage(req.Method, req.Path, req.Body, body, req.Model) + } + bifrostResponse := &schemas.BifrostPassthroughResponse{ StatusCode: resp.StatusCode(), Headers: headers, @@ -3553,7 +3558,9 @@ func (provider *AzureProvider) Passthrough( ExtraFields: schemas.BifrostResponseExtraFields{ Latency: latency.Milliseconds(), ProviderResponseHeaders: headers, + PassthroughPath: req.Path, }, + PassthroughUsage: passthroughUsage, } return bifrostResponse, nil @@ -3619,7 +3626,7 @@ func (provider *AzureProvider) PassthroughStream( return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderDoRequest, err) } - headers := providerUtils.ExtractProviderResponseHeaders(resp) + headers := providerUtils.ExtractPassthroughProviderResponseHeaders(resp) ctx.SetValue(schemas.BifrostContextKeyProviderResponseHeaders, headers) rawBodyStream := resp.BodyStream() @@ -3628,68 +3635,34 @@ func (provider *AzureProvider) PassthroughStream( return nil, providerUtils.NewBifrostOperationError("provider returned an empty stream body", fmt.Errorf("provider returned an empty stream body")) } - bodyStream, stopIdleTimeout := providerUtils.NewIdleTimeoutReader(rawBodyStream, rawBodyStream, providerUtils.GetStreamIdleTimeout(ctx), ctx) - stopCancellation := providerUtils.SetupStreamCancellation(ctx, rawBodyStream, provider.logger) - - extraFields := schemas.BifrostResponseExtraFields{ - ProviderResponseHeaders: headers, - } - statusCode := resp.StatusCode() - - ch := make(chan *schemas.BifrostStreamChunk, schemas.DefaultStreamBufferSize) - go func() { - defer providerUtils.EnsureStreamFinalizerCalled(ctx, postHookSpanFinalizer) - defer func() { - if ctx.Err() == context.Canceled { - providerUtils.HandleStreamCancellation(ctx, postHookRunner, ch, provider.logger, postHookSpanFinalizer, providerUtils.PassthroughJSONBody(fasthttpReq, req.Body)) - } else if ctx.Err() == context.DeadlineExceeded { - providerUtils.HandleStreamTimeout(ctx, postHookRunner, ch, provider.logger, postHookSpanFinalizer, providerUtils.PassthroughJSONBody(fasthttpReq, req.Body)) - } - close(ch) - }() - defer providerUtils.ReleaseStreamingResponse(ctx, resp) - defer stopIdleTimeout() - defer stopCancellation() - - buf := make([]byte, 4096) - for { - n, readErr := bodyStream.Read(buf) - if n > 0 { - chunk := make([]byte, n) - copy(chunk, buf[:n]) - providerUtils.ProcessAndSendResponse(ctx, postHookRunner, &schemas.BifrostResponse{ - PassthroughResponse: &schemas.BifrostPassthroughResponse{ - StatusCode: statusCode, - Headers: headers, - Body: chunk, - ExtraFields: extraFields, - }, - }, ch, postHookSpanFinalizer) - } - if readErr == io.EOF { - ctx.SetValue(schemas.BifrostContextKeyStreamEndIndicator, true) - extraFields.Latency = time.Since(startTime).Milliseconds() - providerUtils.ProcessAndSendResponse(ctx, postHookRunner, &schemas.BifrostResponse{ - PassthroughResponse: &schemas.BifrostPassthroughResponse{ - StatusCode: statusCode, - Headers: headers, - ExtraFields: extraFields, - }, - }, ch, postHookSpanFinalizer) - return - } - if readErr != nil { - if ctx.Err() != nil { - return + var anthropicUsage *anthropic.AnthropicPassthroughStreamUsage + if schemas.IsAnthropicModel(req.Model) { + anthropicUsage = &anthropic.AnthropicPassthroughStreamUsage{} + } + return providerUtils.StreamPassthrough( + ctx, postHookRunner, postHookSpanFinalizer, resp, rawBodyStream, + providerUtils.PassthroughStreamParams{ + StatusCode: resp.StatusCode(), + Headers: headers, + Path: req.Path, + RawRequest: req.Body, + CancellationBody: providerUtils.PassthroughJSONBody(fasthttpReq, req.Body), + StartTime: startTime, + Logger: provider.logger, + HasUsage: func(event []byte) bool { + if anthropicUsage != nil { + return anthropic.HasAnthropicPassthroughUsage(event) } - ctx.SetValue(schemas.BifrostContextKeyStreamEndIndicator, true) - extraFields.Latency = time.Since(startTime).Milliseconds() - providerUtils.ProcessAndSendError(ctx, postHookRunner, readErr, ch, provider.logger, postHookSpanFinalizer) - return - } - } - }() - return ch, nil + return openai.HasOpenAIPassthroughUsage(event) + }, + Observe: func(event []byte) *schemas.BifrostPassthroughUsage { + if anthropicUsage != nil { + return anthropicUsage.ObserveEvent(event) + } + return openai.ExtractOpenAIPassthroughUsage(req.Method, req.Path, req.Body, event) + }, + }, + ), nil } // buildPassthroughURL constructs the full Azure URL for a passthrough request. @@ -3729,3 +3702,14 @@ func (provider *AzureProvider) buildPassthroughURL(key schemas.Key, path, rawQue } return fullURL } + +// extractAzurePassthroughUsage dispatches usage extraction by the upstream API the +// passthrough request targets. Azure serves both OpenAI and Azure-hosted Anthropic models, +// so Anthropic routes (e.g. /messages) must use the Anthropic extractor — otherwise their +// usage is dropped and budgets/logging stay wrong. +func extractAzurePassthroughUsage(method, path string, reqBody, body []byte, model string) *schemas.BifrostPassthroughUsage { + if schemas.IsAnthropicModel(model) { + return anthropic.ExtractAnthropicPassthroughUsage(path, reqBody, body) + } + return openai.ExtractOpenAIPassthroughUsage(method, path, reqBody, body) +} diff --git a/core/providers/azure/passthrough_usage_test.go b/core/providers/azure/passthrough_usage_test.go new file mode 100644 index 00000000000..b7f0657725e --- /dev/null +++ b/core/providers/azure/passthrough_usage_test.go @@ -0,0 +1,54 @@ +package azure + +import ( + "testing" + + "github.com/maximhq/bifrost/core/schemas" +) + +// TestExtractAzurePassthroughUsage verifies Azure dispatches usage extraction by upstream model: +// Anthropic models use the Anthropic extractor (its usage shape), everything else uses OpenAI's. +func TestExtractAzurePassthroughUsage(t *testing.T) { + tests := []struct { + name string + method string + path string + body string + model string + check func(t *testing.T, u *schemas.BifrostPassthroughUsage) + }{ + { + name: "anthropic model routes to anthropic extractor", + method: "POST", + path: "/v1/messages", + body: `{"usage":{"input_tokens":66,"output_tokens":26}}`, + model: "claude-sonnet-4-5", + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + if u == nil || u.LLMUsage == nil || u.LLMUsage.PromptTokens != 66 || + u.LLMUsage.CompletionTokens != 26 || u.LLMUsage.TotalTokens != 92 { + t.Fatalf("anthropic dispatch usage = %+v", u) + } + }, + }, + { + name: "openai model routes to openai extractor", + method: "POST", + path: "/chat/completions", + body: `{"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}`, + model: "gpt-4o", + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + if u == nil || u.LLMUsage == nil || u.LLMUsage.PromptTokens != 10 || + u.LLMUsage.CompletionTokens != 5 || u.LLMUsage.TotalTokens != 15 { + t.Fatalf("openai dispatch usage = %+v", u) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u := extractAzurePassthroughUsage(tt.method, tt.path, nil, []byte(tt.body), tt.model) + tt.check(t, u) + }) + } +} diff --git a/core/providers/bedrock/bedrock.go b/core/providers/bedrock/bedrock.go index fe7fd58ad87..f443b549426 100644 --- a/core/providers/bedrock/bedrock.go +++ b/core/providers/bedrock/bedrock.go @@ -135,7 +135,7 @@ func NewBedrockProvider(config *schemas.ProviderConfig, logger schemas.Logger) ( ConnPoolStrategy: fasthttp.FIFO, } mantleFasthttpClient = providerUtils.ConfigureProxy(mantleFasthttpClient, config.ProxyConfig, logger) - mantleFasthttpClient = providerUtils.ConfigureDialer(mantleFasthttpClient) + mantleFasthttpClient = providerUtils.ConfigureDialer(mantleFasthttpClient, config.NetworkConfig.AllowPrivateNetwork) mantleFasthttpClient = providerUtils.ConfigureTLS(mantleFasthttpClient, config.NetworkConfig, logger) mantleStreamingFasthttpClient := providerUtils.BuildStreamingClient(mantleFasthttpClient) diff --git a/core/providers/cerebras/cerebras.go b/core/providers/cerebras/cerebras.go index b2f38ea38fd..8ddf675b0fc 100644 --- a/core/providers/cerebras/cerebras.go +++ b/core/providers/cerebras/cerebras.go @@ -41,7 +41,7 @@ func NewCerebrasProvider(config *schemas.ProviderConfig, logger schemas.Logger) // Configure proxy and retry policy client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) - client = providerUtils.ConfigureDialer(client) + client = providerUtils.ConfigureDialer(client, config.NetworkConfig.AllowPrivateNetwork) client = providerUtils.ConfigureTLS(client, config.NetworkConfig, logger) streamingClient := providerUtils.BuildStreamingClient(client) // Set default BaseURL if not provided diff --git a/core/providers/cohere/cohere.go b/core/providers/cohere/cohere.go index 49718b1750a..af224036900 100644 --- a/core/providers/cohere/cohere.go +++ b/core/providers/cohere/cohere.go @@ -114,7 +114,7 @@ func NewCohereProvider(config *schemas.ProviderConfig, logger schemas.Logger) (* // Setting proxy and retry policy client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) - client = providerUtils.ConfigureDialer(client) + client = providerUtils.ConfigureDialer(client, config.NetworkConfig.AllowPrivateNetwork) client = providerUtils.ConfigureTLS(client, config.NetworkConfig, logger) // Pre-warm response pools for i := 0; i < config.ConcurrencyAndBufferSize.Concurrency; i++ { diff --git a/core/providers/elevenlabs/elevenlabs.go b/core/providers/elevenlabs/elevenlabs.go index 853bbb56abd..a784054ea3a 100644 --- a/core/providers/elevenlabs/elevenlabs.go +++ b/core/providers/elevenlabs/elevenlabs.go @@ -48,7 +48,7 @@ func NewElevenlabsProvider(config *schemas.ProviderConfig, logger schemas.Logger // Configure proxy and retry policy client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) - client = providerUtils.ConfigureDialer(client) + client = providerUtils.ConfigureDialer(client, config.NetworkConfig.AllowPrivateNetwork) client = providerUtils.ConfigureTLS(client, config.NetworkConfig, logger) streamingClient := providerUtils.BuildStreamingClient(client) // Set default BaseURL if not provided diff --git a/core/providers/fireworks/fireworks.go b/core/providers/fireworks/fireworks.go index 646acbb3cf3..bdd76c1ebdb 100644 --- a/core/providers/fireworks/fireworks.go +++ b/core/providers/fireworks/fireworks.go @@ -41,7 +41,7 @@ func NewFireworksProvider(config *schemas.ProviderConfig, logger schemas.Logger) // Configure proxy and retry policy client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) - client = providerUtils.ConfigureDialer(client) + client = providerUtils.ConfigureDialer(client, config.NetworkConfig.AllowPrivateNetwork) client = providerUtils.ConfigureTLS(client, config.NetworkConfig, logger) streamingClient := providerUtils.BuildStreamingClient(client) // Set default BaseURL if not provided diff --git a/core/providers/gemini/gemini.go b/core/providers/gemini/gemini.go index bdab2575333..fb6f438bd3f 100644 --- a/core/providers/gemini/gemini.go +++ b/core/providers/gemini/gemini.go @@ -75,7 +75,7 @@ func NewGeminiProvider(config *schemas.ProviderConfig, logger schemas.Logger) *G // Configure proxy and retry policy client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) - client = providerUtils.ConfigureDialer(client) + client = providerUtils.ConfigureDialer(client, config.NetworkConfig.AllowPrivateNetwork) client = providerUtils.ConfigureTLS(client, config.NetworkConfig, logger) streamingClient := providerUtils.BuildStreamingClient(client) @@ -4127,7 +4127,7 @@ func (provider *GeminiProvider) Passthrough( return nil, bifrostErr } - headers := providerUtils.ExtractProviderResponseHeaders(resp) + headers := providerUtils.ExtractPassthroughProviderResponseHeaders(resp) ctx.SetValue(schemas.BifrostContextKeyProviderResponseHeaders, headers) body, err := providerUtils.CheckAndDecodeBody(resp) @@ -4135,6 +4135,11 @@ func (provider *GeminiProvider) Passthrough( return nil, providerUtils.NewBifrostOperationError("failed to decode response body", err) } + var passthroughUsage *schemas.BifrostPassthroughUsage + if resp.StatusCode() >= 200 && resp.StatusCode() < 300 { + passthroughUsage = ExtractGeminiPassthroughUsage(req.Path, req.Body, body) + } + bifrostResponse := &schemas.BifrostPassthroughResponse{ StatusCode: resp.StatusCode(), Headers: headers, @@ -4142,7 +4147,9 @@ func (provider *GeminiProvider) Passthrough( ExtraFields: schemas.BifrostResponseExtraFields{ Latency: latency.Milliseconds(), ProviderResponseHeaders: headers, + PassthroughPath: req.Path, }, + PassthroughUsage: passthroughUsage, } return bifrostResponse, nil @@ -4208,7 +4215,7 @@ func (provider *GeminiProvider) PassthroughStream( return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderDoRequest, err) } - headers := providerUtils.ExtractProviderResponseHeaders(resp) + headers := providerUtils.ExtractPassthroughProviderResponseHeaders(resp) ctx.SetValue(schemas.BifrostContextKeyProviderResponseHeaders, headers) bodyStream := resp.BodyStream() @@ -4220,87 +4227,22 @@ func (provider *GeminiProvider) PassthroughStream( ) } - // Wrap reader with idle timeout to detect stalled streams. providerUtils.SetStreamIdleTimeoutIfEmpty(ctx, provider.networkConfig.StreamIdleTimeoutInSeconds) - rawBodyStream := bodyStream - bodyStream, stopIdleTimeout := providerUtils.NewIdleTimeoutReader(bodyStream, rawBodyStream, providerUtils.GetStreamIdleTimeout(ctx), ctx) - - // Cancellation must close the raw stream to unblock reads. - stopCancellation := providerUtils.SetupStreamCancellation(ctx, rawBodyStream, provider.logger) - - extraFields := schemas.BifrostResponseExtraFields{ - ProviderResponseHeaders: headers, - } - statusCode := resp.StatusCode() - - ch := make(chan *schemas.BifrostStreamChunk, schemas.DefaultStreamBufferSize) - go func() { - defer providerUtils.EnsureStreamFinalizerCalled(ctx, postHookSpanFinalizer) - defer func() { - if ctx.Err() == context.Canceled { - providerUtils.HandleStreamCancellation(ctx, postHookRunner, ch, provider.logger, postHookSpanFinalizer, providerUtils.PassthroughJSONBody(fasthttpReq, req.Body)) - } else if ctx.Err() == context.DeadlineExceeded { - providerUtils.HandleStreamTimeout(ctx, postHookRunner, ch, provider.logger, postHookSpanFinalizer, providerUtils.PassthroughJSONBody(fasthttpReq, req.Body)) - } - close(ch) - }() - defer providerUtils.ReleaseStreamingResponse(ctx, resp) - defer stopIdleTimeout() - defer stopCancellation() - - terminalDetector := &providerUtils.StreamTerminalDetector{} - buf := make([]byte, 4096) - for { - n, readErr := bodyStream.Read(buf) - if n > 0 { - chunk := make([]byte, n) - copy(chunk, buf[:n]) - providerUtils.ProcessAndSendResponse(ctx, postHookRunner, &schemas.BifrostResponse{ - PassthroughResponse: &schemas.BifrostPassthroughResponse{ - StatusCode: statusCode, - Headers: headers, - Body: chunk, - ExtraFields: extraFields, - }, - }, ch, postHookSpanFinalizer) - - if terminalDetector.ObserveChunk(chunk) { - ctx.SetValue(schemas.BifrostContextKeyStreamEndIndicator, true) - extraFields.Latency = time.Since(startTime).Milliseconds() - providerUtils.ProcessAndSendResponse(ctx, postHookRunner, &schemas.BifrostResponse{ - PassthroughResponse: &schemas.BifrostPassthroughResponse{ - StatusCode: statusCode, - Headers: headers, - ExtraFields: extraFields, - }, - }, ch, postHookSpanFinalizer) - return - } - } - if readErr == io.EOF { - ctx.SetValue(schemas.BifrostContextKeyStreamEndIndicator, true) - extraFields.Latency = time.Since(startTime).Milliseconds() - providerUtils.ProcessAndSendResponse(ctx, postHookRunner, &schemas.BifrostResponse{ - PassthroughResponse: &schemas.BifrostPassthroughResponse{ - StatusCode: statusCode, - Headers: headers, - ExtraFields: extraFields, - }, - }, ch, postHookSpanFinalizer) - return - } - if readErr != nil { - if ctx.Err() != nil { - return // let defer handle cancel/timeout - } - if readErr != io.EOF { - ctx.SetValue(schemas.BifrostContextKeyStreamEndIndicator, true) - extraFields.Latency = time.Since(startTime).Milliseconds() - providerUtils.ProcessAndSendError(ctx, postHookRunner, readErr, ch, provider.logger, postHookSpanFinalizer) - } - return - } - } - }() - return ch, nil + return providerUtils.StreamPassthrough( + ctx, postHookRunner, postHookSpanFinalizer, resp, bodyStream, + providerUtils.PassthroughStreamParams{ + StatusCode: resp.StatusCode(), + Headers: headers, + Path: req.Path, + RawRequest: req.Body, + CancellationBody: providerUtils.PassthroughJSONBody(fasthttpReq, req.Body), + StartTime: startTime, + UseTerminalDetector: true, + Logger: provider.logger, + HasUsage: HasGeminiPassthroughUsage, + Observe: func(event []byte) *schemas.BifrostPassthroughUsage { + return ExtractGeminiPassthroughUsage(req.Path, req.Body, event) + }, + }, + ), nil } diff --git a/core/providers/gemini/gemini_test.go b/core/providers/gemini/gemini_test.go index 773f8f30a39..56c95143276 100644 --- a/core/providers/gemini/gemini_test.go +++ b/core/providers/gemini/gemini_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/bytedance/sonic" "github.com/maximhq/bifrost/core/internal/llmtests" "github.com/maximhq/bifrost/core/providers/gemini" "github.com/stretchr/testify/assert" @@ -432,6 +433,180 @@ func TestThoughtSignatureBypassSentinelRoundTripsThroughJSON(t *testing.T) { assert.Equal(t, []byte("skip_thought_signature_validator"), decoded.ThoughtSignature) } +func TestGeminiGenerationRequestUnmarshalAcceptsSchemaIntegerConstraints(t *testing.T) { + tests := []struct { + name string + body string + }{ + { + name: "numeric constraints", + body: `{ + "contents": [{"role": "user", "parts": [{"text": "Search docs"}]}], + "tools": [{ + "functionDeclarations": [{ + "name": "exa_web_search_exa", + "description": "Search project docs", + "parameters": { + "type": "object", + "minProperties": 1, + "maxProperties": 3, + "properties": { + "query": {"type": "string", "description": "Search query", "minLength": 1, "maxLength": 100}, + "tags": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 5} + }, + "required": ["query"] + } + }] + }] + }`, + }, + { + name: "quoted constraints", + body: `{ + "contents": [{"role": "user", "parts": [{"text": "Search docs"}]}], + "tools": [{ + "functionDeclarations": [{ + "name": "exa_web_search_exa", + "description": "Search project docs", + "parameters": { + "type": "object", + "minProperties": "1", + "maxProperties": "3", + "properties": { + "query": {"type": "string", "description": "Search query", "minLength": "1", "maxLength": "100"}, + "tags": {"type": "array", "items": {"type": "string"}, "minItems": "1", "maxItems": "5"} + }, + "required": ["query"] + } + }] + }] + }`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var req gemini.GeminiGenerationRequest + require.NoError(t, sonic.Unmarshal([]byte(tt.body), &req)) + require.Len(t, req.Tools, 1) + require.Len(t, req.Tools[0].FunctionDeclarations, 1) + + params := req.Tools[0].FunctionDeclarations[0].Parameters + require.NotNil(t, params) + require.NotNil(t, params.MinProperties) + require.NotNil(t, params.MaxProperties) + assert.Equal(t, int64(1), *params.MinProperties) + assert.Equal(t, int64(3), *params.MaxProperties) + + query := params.Properties["query"] + require.NotNil(t, query) + require.NotNil(t, query.MinLength) + require.NotNil(t, query.MaxLength) + assert.Equal(t, int64(1), *query.MinLength) + assert.Equal(t, int64(100), *query.MaxLength) + + tags := params.Properties["tags"] + require.NotNil(t, tags) + require.NotNil(t, tags.MinItems) + require.NotNil(t, tags.MaxItems) + assert.Equal(t, int64(1), *tags.MinItems) + assert.Equal(t, int64(5), *tags.MaxItems) + }) + } + + t.Run("invalid string constraint", func(t *testing.T) { + var req gemini.GeminiGenerationRequest + err := sonic.Unmarshal([]byte(`{ + "tools": [{ + "functionDeclarations": [{ + "name": "exa_web_search_exa", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "minLength": "many"} + } + } + }] + }] + }`), &req) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid schema integer constraint") + }) + + t.Run("max int64 numeric constraint", func(t *testing.T) { + var req gemini.GeminiGenerationRequest + require.NoError(t, sonic.Unmarshal([]byte(`{ + "tools": [{ + "functionDeclarations": [{ + "name": "exa_web_search_exa", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "maxLength": 9223372036854775807} + } + } + }] + }] + }`), &req)) + + query := req.Tools[0].FunctionDeclarations[0].Parameters.Properties["query"] + require.NotNil(t, query.MaxLength) + assert.Equal(t, int64(9223372036854775807), *query.MaxLength) + }) + + t.Run("null constraint remains unset", func(t *testing.T) { + var req gemini.GeminiGenerationRequest + require.NoError(t, sonic.Unmarshal([]byte(`{ + "tools": [{ + "functionDeclarations": [{ + "name": "exa_web_search_exa", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "minLength": null} + } + } + }] + }] + }`), &req)) + + query := req.Tools[0].FunctionDeclarations[0].Parameters.Properties["query"] + assert.Nil(t, query.MinLength) + }) + + invalidConstraints := []struct { + name string + constraint string + }{ + {name: "float", constraint: `1.5`}, + {name: "bool", constraint: `true`}, + {name: "object", constraint: `{}`}, + {name: "array", constraint: `[]`}, + {name: "overflow string", constraint: `"9223372036854775808"`}, + } + + for _, tt := range invalidConstraints { + t.Run("invalid "+tt.name+" constraint", func(t *testing.T) { + var req gemini.GeminiGenerationRequest + err := sonic.Unmarshal([]byte(`{ + "tools": [{ + "functionDeclarations": [{ + "name": "exa_web_search_exa", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "minLength": `+tt.constraint+`} + } + } + }] + }] + }`), &req) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid schema integer constraint") + }) + } +} + // parseToolParams parses fd.ParametersJSONSchema (raw JSON Schema passthrough) into a // map for assertions. All tool conversion paths now use ParametersJSONSchema; fd.Parameters // is always nil. diff --git a/core/providers/gemini/passthrough_usage.go b/core/providers/gemini/passthrough_usage.go new file mode 100644 index 00000000000..75a209f3be7 --- /dev/null +++ b/core/providers/gemini/passthrough_usage.go @@ -0,0 +1,364 @@ +package gemini + +import ( + "strconv" + "strings" + + "github.com/bytedance/sonic" + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + "github.com/maximhq/bifrost/core/schemas" +) + +// ExtractGeminiPassthroughUsage extracts usage from a completed Gemini/Vertex +// passthrough response. Handles both SSE streaming (last event) and plain JSON +// (non-streaming) for all billable endpoint types. +// +// :generateContent handles all modalities — text, speech, transcription, and non-Imagen +// image generation. Output modality detection routes to the correct BifrostPassthroughUsage +// shape so the pricing engine uses the appropriate cost function. +// +// :predict is Imagen priced per-image. :predictLongRunning is Veo priced per-second. +// /interactions paths use the Interactions API usage shape. +func ExtractGeminiPassthroughUsage(path string, reqBody, body []byte) *schemas.BifrostPassthroughUsage { + if idx := strings.IndexByte(path, '?'); idx >= 0 { + path = path[:idx] + } + + // Interactions API uses /interactions paths — no colon suffix. + if strings.Contains(path, "/interactions") { + return extractGeminiInteractionsUsage(body) + } + + colonIdx := strings.LastIndexByte(path, ':') + action := "" + if colonIdx >= 0 { + action = path[colonIdx+1:] + } + + switch action { + case "generateContent", "streamGenerateContent": + return extractGeminiGenerateContentUsage(body) + + case "embedContent", "batchEmbedContents": + return extractGeminiEmbeddingUsage(body) + + case "predict": + return extractGeminiPredictUsage(reqBody, body) + + case "predictLongRunning": + // Veo video generation — priced per second of video. + return extractGeminiVeoUsage(reqBody) + } + + // Unknown action (e.g. countTokens, generateVideos) — try usageMetadata as best-effort. + return extractGeminiGenerateContentUsage(body) +} + +func HasGeminiPassthroughUsage(event []byte) bool { + return providerUtils.GetJSONField(event, "usageMetadata").Exists() || + providerUtils.GetJSONField(event, "usage").Exists() || + providerUtils.GetJSONField(event, "interaction.usage").Exists() +} + +// ---- :generateContent / :streamGenerateContent ---- + +type geminiPassthroughResp struct { + UsageMetadata *GenerateContentResponseUsageMetadata `json:"usageMetadata"` +} + +// extractGeminiGenerateContentUsage routes to the correct BifrostPassthroughUsage shape +// based on output modality from the response's candidatesTokensDetails: +// +// - IMAGE tokens in output → ImageUsage + LLMUsage → ImageGenerationRequest → computeImageCost +// - AUDIO tokens in output → LLMUsage with CompletionTokensDetails.AudioTokens → ResponsesRequest → computeTextCost audio differential +// - TEXT / default → LLMUsage → ResponsesRequest → computeTextCost +// +// Gemini TTS bills by input tokens (not chars), so AUDIO output stays in the ResponsesRequest +// path where computeTextCost applies the OutputCostPerAudioToken rate differential. +func extractGeminiGenerateContentUsage(body []byte) *schemas.BifrostPassthroughUsage { + if len(body) == 0 { + return nil + } + + var resp geminiPassthroughResp + if err := sonic.Unmarshal(body, &resp); err != nil || resp.UsageMetadata == nil { + return nil + } + + // ConvertGeminiUsageMetadataToResponsesUsage handles thinking tokens, cached content, + // and per-modality breakdowns (text, audio, image) for all :generateContent request types. + ru := ConvertGeminiUsageMetadataToResponsesUsage(resp.UsageMetadata) + if ru == nil || ru.TotalTokens == 0 { + return nil + } + + // IMAGE output → ImageUsage routes to computeImageCost via ImageGenerationRequest. + // LLMUsage is also set so the logging plugin can display tokens in/out. + if ru.OutputTokensDetails != nil && ru.OutputTokensDetails.ImageTokens != nil && *ru.OutputTokensDetails.ImageTokens > 0 { + imageUsage := &schemas.ImageUsage{ + InputTokens: ru.InputTokens, + OutputTokens: ru.OutputTokens, + TotalTokens: ru.TotalTokens, + OutputTokensDetails: &schemas.ImageTokenDetails{ + ImageTokens: *ru.OutputTokensDetails.ImageTokens, + }, + } + if ru.InputTokensDetails != nil && (ru.InputTokensDetails.TextTokens > 0 || ru.InputTokensDetails.ImageTokens > 0) { + imageUsage.InputTokensDetails = &schemas.ImageTokenDetails{ + TextTokens: ru.InputTokensDetails.TextTokens, + ImageTokens: ru.InputTokensDetails.ImageTokens, + } + } + return &schemas.BifrostPassthroughUsage{ + ImageUsage: imageUsage, + LLMUsage: &schemas.BifrostLLMUsage{ + PromptTokens: ru.InputTokens, + CompletionTokens: ru.OutputTokens, + TotalTokens: ru.TotalTokens, + }, + } + } + + // TEXT / AUDIO / default → LLMUsage with full modality details for computeTextCost. + // For AUDIO output, CompletionTokensDetails.AudioTokens is set so computeTextCost applies + // the OutputCostPerAudioToken rate differential: cost = tokens * (audioRate - textRate). + usage := &schemas.BifrostLLMUsage{ + PromptTokens: ru.InputTokens, + CompletionTokens: ru.OutputTokens, + TotalTokens: ru.TotalTokens, + } + if ru.InputTokensDetails != nil { + usage.PromptTokensDetails = &schemas.ChatPromptTokensDetails{ + CachedReadTokens: ru.InputTokensDetails.CachedReadTokens, + TextTokens: ru.InputTokensDetails.TextTokens, + AudioTokens: ru.InputTokensDetails.AudioTokens, + ImageTokens: ru.InputTokensDetails.ImageTokens, + } + } + if ru.OutputTokensDetails != nil { + usage.CompletionTokensDetails = &schemas.ChatCompletionTokensDetails{ + ReasoningTokens: ru.OutputTokensDetails.ReasoningTokens, + AudioTokens: ru.OutputTokensDetails.AudioTokens, + } + } + + return &schemas.BifrostPassthroughUsage{LLMUsage: usage} +} + +// ---- :embedContent / :batchEmbedContents ---- + +func extractGeminiEmbeddingUsage(body []byte) *schemas.BifrostPassthroughUsage { + // Embeddings are never streamed, so body is plain JSON. + if len(body) == 0 { + return nil + } + + var resp geminiPassthroughResp + if err := sonic.Unmarshal(body, &resp); err != nil || resp.UsageMetadata == nil { + return nil + } + + m := resp.UsageMetadata + total := int(m.TotalTokenCount) + prompt := int(m.PromptTokenCount) + if total == 0 && prompt == 0 { + return nil + } + if total == 0 { + total = prompt + } + return &schemas.BifrostPassthroughUsage{ + LLMUsage: &schemas.BifrostLLMUsage{ + PromptTokens: prompt, + TotalTokens: total, + }, + } +} + +// ---- /interactions (Interactions API) ---- +// Non-streaming: usage sits at top level. +// Streaming InteractionCompletedEvent: usage is nested under "interaction". +// Fields: total_input_tokens, total_output_tokens, total_thought_tokens (reasoning), +// total_cached_tokens. service_tier "standard" is the default and is not forwarded. + +type geminiInteractionsUsage struct { + TotalTokens int `json:"total_tokens"` + InputTokens int `json:"total_input_tokens"` + OutputTokens int `json:"total_output_tokens"` + ThoughtTokens int `json:"total_thought_tokens"` + CachedTokens int `json:"total_cached_tokens"` +} + +type geminiInteractionsWrapper struct { + Usage *geminiInteractionsUsage `json:"usage"` + ServiceTier *string `json:"service_tier"` + // Streaming InteractionCompletedEvent nests the completed interaction object. + Interaction *struct { + Usage *geminiInteractionsUsage `json:"usage"` + ServiceTier *string `json:"service_tier"` + } `json:"interaction"` +} + +func extractGeminiInteractionsUsage(body []byte) *schemas.BifrostPassthroughUsage { + if len(body) == 0 { + return nil + } + + var w geminiInteractionsWrapper + if err := sonic.Unmarshal(body, &w); err != nil { + return nil + } + + // Streaming takes priority: nested under "interaction" with a non-zero total. + u, tier := w.Usage, w.ServiceTier + if w.Interaction != nil && w.Interaction.Usage != nil && w.Interaction.Usage.TotalTokens > 0 { + u, tier = w.Interaction.Usage, w.Interaction.ServiceTier + } + if u == nil || u.TotalTokens == 0 { + return nil + } + + usage := &schemas.BifrostLLMUsage{ + PromptTokens: u.InputTokens, + CompletionTokens: u.OutputTokens + u.ThoughtTokens, + TotalTokens: u.TotalTokens, + } + if u.CachedTokens > 0 { + usage.PromptTokensDetails = &schemas.ChatPromptTokensDetails{ + CachedReadTokens: u.CachedTokens, + } + } + if u.ThoughtTokens > 0 { + usage.CompletionTokensDetails = &schemas.ChatCompletionTokensDetails{ + ReasoningTokens: u.ThoughtTokens, + } + } + + result := &schemas.BifrostPassthroughUsage{LLMUsage: usage} + // "standard" is the default tier — only forward non-standard values. + if tier != nil && *tier != "" && *tier != "standard" { + t := schemas.BifrostServiceTier(*tier) + result.ServiceTier = &t + } + return result +} + +// ---- :predictLongRunning (Veo video generation) ---- + +func extractGeminiVeoUsage(reqBody []byte) *schemas.BifrostPassthroughUsage { + // Default matches the native Gemini/Vertex path (schemas.DefaultVideoDuration). + secs := 8 + if d, err := strconv.Atoi(schemas.DefaultVideoDuration); err == nil { + secs = d + } + if len(reqBody) > 0 { + if d := providerUtils.GetJSONField(reqBody, "parameters.durationSeconds"); d.Exists() && d.Int() > 0 { + secs = int(d.Int()) + } + } + return &schemas.BifrostPassthroughUsage{VideoSeconds: &secs} +} + +// ---- :predict dispatch (Vertex/Gemini prediction endpoint) ---- +// The :predict action is shared by embeddings and Imagen image generation, distinguished by +// the per-prediction structure: +// +// - embedding (text or multimodal): predictions[] carry an `embeddings` block (text, with +// statistics.token_count) or modality vectors (textEmbedding/imageEmbedding/videoEmbeddings, +// multimodal — no token count) → token usage +// - Imagen image gen: predictions[].bytesBase64Encoded → per-image count +// +// An embedding response carries only LLMUsage, so it resolves to EmbeddingRequest via +// detectPassthroughRequestType's :predict mapping. Imagen sets ImageUsage and is resolved by +// its usage shape before that fallback, so the fallback only ever classifies embeddings. +type geminiPredictResponse struct { + Predictions []struct { + Embeddings *struct { + Statistics *struct { + TokenCount int `json:"token_count"` + } `json:"statistics"` + } `json:"embeddings"` + // Multimodal embedding modality vectors — used only to recognize the response as an + // embedding (multimodal responses carry no token count to bill). + TextEmbedding []float64 `json:"textEmbedding"` + ImageEmbedding []float64 `json:"imageEmbedding"` + VideoEmbeddings []any `json:"videoEmbeddings"` + } `json:"predictions"` +} + +func extractGeminiPredictUsage(reqBody, body []byte) *schemas.BifrostPassthroughUsage { + if len(body) > 0 { + var resp geminiPredictResponse + if err := sonic.Unmarshal(body, &resp); err == nil { + if u := resp.embeddingUsage(); u != nil { + return u + } + } + } + // Default: Imagen image generation (priced per image). + return extractGeminiImagenUsage(reqBody, body) +} + +// embeddingUsage returns token usage when the :predict response is an embedding response +// (text or multimodal), else nil. It returns non-nil for any embedding response — even when no +// token count is present (multimodal) — so an embedding is billed as such rather than misrouted +// to the Imagen per-image path. +func (r *geminiPredictResponse) embeddingUsage() *schemas.BifrostPassthroughUsage { + total, isEmbedding := 0, false + for i := range r.Predictions { + p := r.Predictions[i] + if p.Embeddings != nil { + isEmbedding = true + if p.Embeddings.Statistics != nil { + total += p.Embeddings.Statistics.TokenCount + } + } + if len(p.TextEmbedding) > 0 || len(p.ImageEmbedding) > 0 || len(p.VideoEmbeddings) > 0 { + isEmbedding = true + } + } + if !isEmbedding { + return nil + } + return &schemas.BifrostPassthroughUsage{ + LLMUsage: &schemas.BifrostLLMUsage{PromptTokens: total, TotalTokens: total}, + } +} + +// ---- :predict (Imagen) ---- +// Imagen is priced per image. Extract count from predictions in the response, +// with the requested sampleCount from the request body as a fallback. + +func extractGeminiImagenUsage(reqBody, body []byte) *schemas.BifrostPassthroughUsage { + u := &schemas.BifrostPassthroughUsage{ + ImageUsage: &schemas.ImageUsage{}, + } + + // Request body: sampleCount is the requested number of images. + if len(reqBody) > 0 { + var req GeminiImagenRequest + if err := sonic.Unmarshal(reqBody, &req); err == nil && + req.Parameters.SampleCount != nil && *req.Parameters.SampleCount > 0 { + if u.ImageUsage.OutputTokensDetails == nil { + u.ImageUsage.OutputTokensDetails = &schemas.ImageTokenDetails{} + } + u.ImageUsage.OutputTokensDetails.NImages = *req.Parameters.SampleCount + } + } + + // Response body: actual delivered predictions (may be fewer than requested). + if len(body) > 0 { + var resp GeminiImagenResponse + if err := sonic.Unmarshal(body, &resp); err == nil && len(resp.Predictions) > 0 { + if u.ImageUsage.OutputTokensDetails == nil { + u.ImageUsage.OutputTokensDetails = &schemas.ImageTokenDetails{} + } + u.ImageUsage.OutputTokensDetails.NImages = len(resp.Predictions) + } + } + + if u.ImageUsage.OutputTokensDetails == nil || u.ImageUsage.OutputTokensDetails.NImages == 0 { + return nil + } + return u +} diff --git a/core/providers/gemini/passthrough_usage_test.go b/core/providers/gemini/passthrough_usage_test.go new file mode 100644 index 00000000000..c278861ae87 --- /dev/null +++ b/core/providers/gemini/passthrough_usage_test.go @@ -0,0 +1,186 @@ +package gemini_test + +import ( + "testing" + + "github.com/maximhq/bifrost/core/providers/gemini" + "github.com/maximhq/bifrost/core/schemas" +) + +func TestExtractGeminiPassthroughUsage(t *testing.T) { + tests := []struct { + name string + path string + reqBody string + body string + check func(t *testing.T, u *schemas.BifrostPassthroughUsage) + }{ + { + name: "generateContent text with thinking tokens", + path: "/v1beta/models/gemini-2.5-flash:generateContent", + body: `{"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":30,"totalTokenCount":199,"thoughtsTokenCount":159,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10}]}}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + // thinking tokens fold into completion: 30 + 159 = 189 + geminiMustLLM(t, u, 10, 189, 199) + if u.LLMUsage.CompletionTokensDetails == nil || u.LLMUsage.CompletionTokensDetails.ReasoningTokens != 159 { + t.Fatalf("reasoning tokens = %+v, want 159", u.LLMUsage.CompletionTokensDetails) + } + }, + }, + { + name: "generateContent image output -> ImageUsage", + path: "/v1beta/models/gemini-2.0-flash:generateContent", + body: `{"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":200,"totalTokenCount":205,"candidatesTokensDetails":[{"modality":"IMAGE","tokenCount":200}]}}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + if u == nil || u.ImageUsage == nil || u.ImageUsage.OutputTokensDetails == nil || + u.ImageUsage.OutputTokensDetails.ImageTokens != 200 { + t.Fatalf("image usage = %+v", u) + } + }, + }, + { + name: "embedContent", + path: "/v1beta/models/text-embedding-004:embedContent", + body: `{"usageMetadata":{"promptTokenCount":7,"totalTokenCount":7}}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + if u == nil || u.LLMUsage == nil || u.LLMUsage.PromptTokens != 7 || u.LLMUsage.TotalTokens != 7 { + t.Fatalf("embedding usage = %+v", u) + } + }, + }, + { + name: "predict text embedding (token_count)", + path: "/v1/projects/p/locations/l/publishers/google/models/text-embedding-005:predict", + body: `{"predictions":[{"embeddings":{"statistics":{"token_count":6}}}]}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + if u == nil || u.LLMUsage == nil || u.LLMUsage.PromptTokens != 6 || u.LLMUsage.TotalTokens != 6 { + t.Fatalf("text embedding usage = %+v", u) + } + if u.ImageUsage != nil { + t.Fatalf("text embedding must not be billed as image: %+v", u.ImageUsage) + } + }, + }, + { + name: "predict multimodal embedding -> embedding (not image)", + path: "/v1/projects/p/locations/l/publishers/google/models/multimodalembedding@001:predict", + body: `{"predictions":[{"textEmbedding":[0.1,0.2],"imageEmbedding":[0.3]}]}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + if u == nil || u.LLMUsage == nil { + t.Fatalf("multimodal embedding usage = %+v", u) + } + if u.ImageUsage != nil { + t.Fatalf("multimodal embedding must not be billed as image: %+v", u.ImageUsage) + } + }, + }, + { + name: "predict imagen -> per-image count from predictions", + path: "/v1/projects/p/locations/l/publishers/google/models/imagen-3.0-generate-002:predict", + body: `{"predictions":[{"bytesBase64Encoded":"aaa"},{"bytesBase64Encoded":"bbb"}]}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + if u == nil || u.ImageUsage == nil || u.ImageUsage.OutputTokensDetails == nil || + u.ImageUsage.OutputTokensDetails.NImages != 2 { + t.Fatalf("imagen NImages = %+v, want 2", u) + } + }, + }, + { + name: "predict imagen -> sampleCount fallback when no predictions", + path: "/v1/projects/p/locations/l/publishers/google/models/imagen-3.0-generate-002:predict", + reqBody: `{"parameters":{"sampleCount":3}}`, + body: `{}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + if u == nil || u.ImageUsage == nil || u.ImageUsage.OutputTokensDetails == nil || + u.ImageUsage.OutputTokensDetails.NImages != 3 { + t.Fatalf("imagen sampleCount fallback NImages = %+v, want 3", u) + } + }, + }, + { + name: "predictLongRunning veo -> seconds from request", + path: "/v1beta/models/veo-3.1-generate-preview:predictLongRunning", + reqBody: `{"parameters":{"durationSeconds":6}}`, + body: `{"name":"models/veo/operations/abc"}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + if u == nil || u.VideoSeconds == nil || *u.VideoSeconds != 6 { + t.Fatalf("veo seconds = %+v, want 6", u) + } + }, + }, + { + name: "predictLongRunning veo -> default seconds", + path: "/v1beta/models/veo-3.1-generate-preview:predictLongRunning", + reqBody: `{}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + if u == nil || u.VideoSeconds == nil || *u.VideoSeconds != 8 { + t.Fatalf("veo default seconds = %+v, want 8", u) + } + }, + }, + { + name: "interactions top-level usage (standard tier dropped)", + path: "/v1beta/interactions", + body: `{"usage":{"total_tokens":104,"total_input_tokens":7,"total_output_tokens":27,"total_thought_tokens":70,"total_cached_tokens":0},"service_tier":"standard"}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + // output + thought folded into completion: 27 + 70 = 97 + geminiMustLLM(t, u, 7, 97, 104) + if u.LLMUsage.CompletionTokensDetails == nil || u.LLMUsage.CompletionTokensDetails.ReasoningTokens != 70 { + t.Fatalf("reasoning tokens = %+v, want 70", u.LLMUsage.CompletionTokensDetails) + } + if u.ServiceTier != nil { + t.Fatalf("standard tier should be dropped, got %v", *u.ServiceTier) + } + }, + }, + { + name: "interactions nested (streaming completed) + non-standard tier", + path: "/v1beta/interactions", + body: `{"interaction":{"usage":{"total_tokens":50,"total_input_tokens":10,"total_output_tokens":40},"service_tier":"priority"}}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + geminiMustLLM(t, u, 10, 40, 50) + if u.ServiceTier == nil || *u.ServiceTier != "priority" { + t.Fatalf("service tier = %v, want priority", u.ServiceTier) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u := gemini.ExtractGeminiPassthroughUsage(tt.path, []byte(tt.reqBody), []byte(tt.body)) + tt.check(t, u) + }) + } +} + +func TestHasGeminiPassthroughUsage(t *testing.T) { + tests := []struct { + name string + event string + want bool + }{ + {"usageMetadata", `{"usageMetadata":{"totalTokenCount":5}}`, true}, + {"interactions top-level usage", `{"usage":{"total_tokens":5}}`, true}, + {"interactions nested", `{"interaction":{"usage":{"total_tokens":5}}}`, true}, + {"content chunk (no usage)", `{"candidates":[{"content":{"parts":[{"text":"hi"}]}}]}`, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := gemini.HasGeminiPassthroughUsage([]byte(tt.event)); got != tt.want { + t.Fatalf("HasGeminiPassthroughUsage = %v, want %v", got, tt.want) + } + }) + } +} + +func geminiMustLLM(t *testing.T, u *schemas.BifrostPassthroughUsage, prompt, completion, total int) { + t.Helper() + if u == nil || u.LLMUsage == nil { + t.Fatalf("expected LLMUsage, got %+v", u) + } + if u.LLMUsage.PromptTokens != prompt || u.LLMUsage.CompletionTokens != completion || u.LLMUsage.TotalTokens != total { + t.Fatalf("LLMUsage = {prompt:%d completion:%d total:%d}, want {%d %d %d}", + u.LLMUsage.PromptTokens, u.LLMUsage.CompletionTokens, u.LLMUsage.TotalTokens, prompt, completion, total) + } +} diff --git a/core/providers/gemini/types.go b/core/providers/gemini/types.go index ce7be29f9af..eab2772ea46 100644 --- a/core/providers/gemini/types.go +++ b/core/providers/gemini/types.go @@ -830,10 +830,10 @@ const ( type TrafficType string const ( - TrafficTypeUnspecified TrafficType = "TRAFFIC_TYPE_UNSPECIFIED" - TrafficTypeOnDemand TrafficType = "ON_DEMAND" - TrafficTypeOnDemandPriority TrafficType = "ON_DEMAND_PRIORITY" - TrafficTypeOnDemandFlex TrafficType = "ON_DEMAND_FLEX" + TrafficTypeUnspecified TrafficType = "TRAFFIC_TYPE_UNSPECIFIED" + TrafficTypeOnDemand TrafficType = "ON_DEMAND" + TrafficTypeOnDemandPriority TrafficType = "ON_DEMAND_PRIORITY" + TrafficTypeOnDemandFlex TrafficType = "ON_DEMAND_FLEX" TrafficTypeProvisionedThroughput TrafficType = "PROVISIONED_THROUGHPUT" ) @@ -997,6 +997,73 @@ type Schema struct { Type Type `json:"type,omitempty"` } +type flexibleSchemaInt64 int64 + +var schemaIntegerJSON = sonic.Config{UseInt64: true}.Froze() + +func (i *flexibleSchemaInt64) UnmarshalJSON(data []byte) error { + var value any + if err := schemaIntegerJSON.Unmarshal(data, &value); err != nil { + return fmt.Errorf("invalid schema integer constraint: %w", err) + } + + switch typedValue := value.(type) { + case nil: + return nil + case int64: + *i = flexibleSchemaInt64(typedValue) + return nil + case string: + parsed, err := strconv.ParseInt(typedValue, 10, 64) + if err != nil { + return fmt.Errorf("invalid schema integer constraint %q: %w", typedValue, err) + } + *i = flexibleSchemaInt64(parsed) + return nil + default: + return fmt.Errorf("invalid schema integer constraint %v", typedValue) + } +} + +func schemaInt64Ptr(value *flexibleSchemaInt64) *int64 { + if value == nil { + return nil + } + out := int64(*value) + return &out +} + +// UnmarshalJSON accepts both the quoted integer format emitted by this struct's +// JSON tags and standard numeric JSON Schema constraints used by SDKs. +func (s *Schema) UnmarshalJSON(data []byte) error { + type schemaAlias Schema + type schemaWithFlexibleConstraints struct { + *schemaAlias + MaxItems *flexibleSchemaInt64 `json:"maxItems,omitempty"` + MaxLength *flexibleSchemaInt64 `json:"maxLength,omitempty"` + MaxProperties *flexibleSchemaInt64 `json:"maxProperties,omitempty"` + MinItems *flexibleSchemaInt64 `json:"minItems,omitempty"` + MinLength *flexibleSchemaInt64 `json:"minLength,omitempty"` + MinProperties *flexibleSchemaInt64 `json:"minProperties,omitempty"` + } + + var aux schemaAlias + withConstraints := schemaWithFlexibleConstraints{schemaAlias: &aux} + if err := sonic.Unmarshal(data, &withConstraints); err != nil { + return err + } + + *s = Schema(aux) + s.MaxItems = schemaInt64Ptr(withConstraints.MaxItems) + s.MaxLength = schemaInt64Ptr(withConstraints.MaxLength) + s.MaxProperties = schemaInt64Ptr(withConstraints.MaxProperties) + s.MinItems = schemaInt64Ptr(withConstraints.MinItems) + s.MinLength = schemaInt64Ptr(withConstraints.MinLength) + s.MinProperties = schemaInt64Ptr(withConstraints.MinProperties) + + return nil +} + // Type represents the type of the data. type Type string diff --git a/core/providers/groq/groq.go b/core/providers/groq/groq.go index f5671b8d63a..c62f59f2ab9 100644 --- a/core/providers/groq/groq.go +++ b/core/providers/groq/groq.go @@ -46,7 +46,7 @@ func NewGroqProvider(config *schemas.ProviderConfig, logger schemas.Logger) (*Gr // Configure proxy and retry policy client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) - client = providerUtils.ConfigureDialer(client) + client = providerUtils.ConfigureDialer(client, config.NetworkConfig.AllowPrivateNetwork) client = providerUtils.ConfigureTLS(client, config.NetworkConfig, logger) streamingClient := providerUtils.BuildStreamingClient(client) // Set default BaseURL if not provided diff --git a/core/providers/huggingface/huggingface.go b/core/providers/huggingface/huggingface.go index 32e66b35c2e..dbb22bb692d 100644 --- a/core/providers/huggingface/huggingface.go +++ b/core/providers/huggingface/huggingface.go @@ -88,7 +88,7 @@ func NewHuggingFaceProvider(config *schemas.ProviderConfig, logger schemas.Logge } client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) - client = providerUtils.ConfigureDialer(client) + client = providerUtils.ConfigureDialer(client, config.NetworkConfig.AllowPrivateNetwork) client = providerUtils.ConfigureTLS(client, config.NetworkConfig, logger) streamingClient := providerUtils.BuildStreamingClient(client) if config.NetworkConfig.BaseURL == "" { diff --git a/core/providers/mistral/mistral.go b/core/providers/mistral/mistral.go index f155654179a..f4fab4bdd07 100644 --- a/core/providers/mistral/mistral.go +++ b/core/providers/mistral/mistral.go @@ -51,7 +51,7 @@ func NewMistralProvider(config *schemas.ProviderConfig, logger schemas.Logger) * // Configure proxy and retry policy client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) - client = providerUtils.ConfigureDialer(client) + client = providerUtils.ConfigureDialer(client, config.NetworkConfig.AllowPrivateNetwork) client = providerUtils.ConfigureTLS(client, config.NetworkConfig, logger) streamingClient := providerUtils.BuildStreamingClient(client) // Set default BaseURL if not provided diff --git a/core/providers/nebius/nebius.go b/core/providers/nebius/nebius.go index cb249d873e2..2e3140be0d1 100644 --- a/core/providers/nebius/nebius.go +++ b/core/providers/nebius/nebius.go @@ -44,7 +44,7 @@ func NewNebiusProvider(config *schemas.ProviderConfig, logger schemas.Logger) (* // Configure proxy and retry policy client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) - client = providerUtils.ConfigureDialer(client) + client = providerUtils.ConfigureDialer(client, config.NetworkConfig.AllowPrivateNetwork) client = providerUtils.ConfigureTLS(client, config.NetworkConfig, logger) streamingClient := providerUtils.BuildStreamingClient(client) // Set default BaseURL if not provided diff --git a/core/providers/ollama/ollama.go b/core/providers/ollama/ollama.go index 1542a63e521..95b583d5bdf 100644 --- a/core/providers/ollama/ollama.go +++ b/core/providers/ollama/ollama.go @@ -47,7 +47,7 @@ func NewOllamaProvider(config *schemas.ProviderConfig, logger schemas.Logger) (* // Configure proxy and retry policy client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) - client = providerUtils.ConfigureDialer(client) + client = providerUtils.ConfigureDialer(client, config.NetworkConfig.AllowPrivateNetwork) client = providerUtils.ConfigureTLS(client, config.NetworkConfig, logger) streamingClient := providerUtils.BuildStreamingClient(client) config.NetworkConfig.BaseURL = strings.TrimRight(config.NetworkConfig.BaseURL, "/") diff --git a/core/providers/openai/openai.go b/core/providers/openai/openai.go index bcb4a173af2..c91d6f4a9f9 100644 --- a/core/providers/openai/openai.go +++ b/core/providers/openai/openai.go @@ -58,7 +58,7 @@ func NewOpenAIProvider(config *schemas.ProviderConfig, logger schemas.Logger) *O // Configure proxy and retry policy client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) - client = providerUtils.ConfigureDialer(client) + client = providerUtils.ConfigureDialer(client, config.NetworkConfig.AllowPrivateNetwork) client = providerUtils.ConfigureTLS(client, config.NetworkConfig, logger) streamingClient := providerUtils.BuildStreamingClient(client) // Set default BaseURL if not provided @@ -558,7 +558,8 @@ func HandleOpenAITextCompletionStreaming( // Skip scanner for non-SSE responses — avoids bufio.Scanner buffer bloat // on non-line-delimited data (e.g. provider returned JSON instead of SSE). - if providerUtils.DrainNonSSEStreamResponse(resp) { + reader, drained := providerUtils.DrainNonSSEStreamReader(resp, reader) + if drained { ctx.SetValue(schemas.BifrostContextKeyStreamEndIndicator, true) providerUtils.ProcessAndSendError(ctx, postHookRunner, errors.New("provider returned non-SSE response for streaming request"), responseChan, logger, postHookSpanFinalizer) return @@ -1101,7 +1102,8 @@ func HandleOpenAIChatCompletionStreaming( // Skip scanner for non-SSE responses — avoids bufio.Scanner buffer bloat // on non-line-delimited data (e.g. provider returned JSON instead of SSE). - if providerUtils.DrainNonSSEStreamResponse(resp) { + reader, drained := providerUtils.DrainNonSSEStreamReader(resp, reader) + if drained { ctx.SetValue(schemas.BifrostContextKeyStreamEndIndicator, true) providerUtils.ProcessAndSendError(ctx, postHookRunner, errors.New("provider returned non-SSE response for streaming request"), responseChan, logger, postHookSpanFinalizer) return @@ -1740,7 +1742,8 @@ func HandleOpenAIResponsesStreaming( // Skip scanner for non-SSE responses — avoids bufio.Scanner buffer bloat // on non-line-delimited data (e.g. provider returned JSON instead of SSE). - if providerUtils.DrainNonSSEStreamResponse(resp) { + reader, drained := providerUtils.DrainNonSSEStreamReader(resp, reader) + if drained { ctx.SetValue(schemas.BifrostContextKeyStreamEndIndicator, true) providerUtils.ProcessAndSendError(ctx, postHookRunner, errors.New("provider returned non-SSE response for streaming request"), responseChan, logger, postHookSpanFinalizer) return @@ -2350,7 +2353,8 @@ func HandleOpenAISpeechStreamRequest( // Skip scanner for non-SSE responses — avoids bufio.Scanner buffer bloat // on non-line-delimited data (e.g. provider returned JSON instead of SSE). - if providerUtils.DrainNonSSEStreamResponse(resp) { + reader, drained := providerUtils.DrainNonSSEStreamReader(resp, reader) + if drained { ctx.SetValue(schemas.BifrostContextKeyStreamEndIndicator, true) providerUtils.ProcessAndSendError(ctx, postHookRunner, errors.New("provider returned non-SSE response for streaming request"), responseChan, logger, postHookSpanFinalizer) return @@ -2791,7 +2795,8 @@ func HandleOpenAITranscriptionStreamRequest( // Skip scanner for non-SSE responses — avoids bufio.Scanner buffer bloat // on non-line-delimited data (e.g. provider returned JSON instead of SSE). - if providerUtils.DrainNonSSEStreamResponse(resp) { + reader, drained := providerUtils.DrainNonSSEStreamReader(resp, reader) + if drained { ctx.SetValue(schemas.BifrostContextKeyStreamEndIndicator, true) providerUtils.ProcessAndSendError(ctx, postHookRunner, errors.New("provider returned non-SSE response for streaming request"), responseChan, logger, postHookSpanFinalizer) return @@ -3229,7 +3234,8 @@ func HandleOpenAIImageGenerationStreaming( // Skip scanner for non-SSE responses — avoids bufio.Scanner buffer bloat // on non-line-delimited data (e.g. provider returned JSON instead of SSE). - if providerUtils.DrainNonSSEStreamResponse(resp) { + reader, drained := providerUtils.DrainNonSSEStreamReader(resp, reader) + if drained { ctx.SetValue(schemas.BifrostContextKeyStreamEndIndicator, true) providerUtils.ProcessAndSendError(ctx, postHookRunner, errors.New("provider returned non-SSE response for streaming request"), responseChan, logger, postHookSpanFinalizer) return @@ -4467,7 +4473,8 @@ func HandleOpenAIImageEditStreamRequest( // Skip scanner for non-SSE responses — avoids bufio.Scanner buffer bloat // on non-line-delimited data (e.g. provider returned JSON instead of SSE). - if providerUtils.DrainNonSSEStreamResponse(resp) { + reader, drained := providerUtils.DrainNonSSEStreamReader(resp, reader) + if drained { ctx.SetValue(schemas.BifrostContextKeyStreamEndIndicator, true) providerUtils.ProcessAndSendError(ctx, postHookRunner, errors.New("provider returned non-SSE response for streaming request"), responseChan, logger, postHookSpanFinalizer) return @@ -6966,7 +6973,7 @@ func (provider *OpenAIProvider) Passthrough( return nil, bifrostErr } - headers := providerUtils.ExtractProviderResponseHeaders(resp) + headers := providerUtils.ExtractPassthroughProviderResponseHeaders(resp) ctx.SetValue(schemas.BifrostContextKeyProviderResponseHeaders, headers) body, err := providerUtils.CheckAndDecodeBody(resp) @@ -6974,6 +6981,11 @@ func (provider *OpenAIProvider) Passthrough( return nil, providerUtils.NewBifrostOperationError("failed to decode response body", err) } + var passthroughUsage *schemas.BifrostPassthroughUsage + if resp.StatusCode() >= 200 && resp.StatusCode() < 300 { + passthroughUsage = ExtractOpenAIPassthroughUsage(req.Method, req.Path, req.Body, body) + } + bifrostResponse := &schemas.BifrostPassthroughResponse{ StatusCode: resp.StatusCode(), Headers: headers, @@ -6981,7 +6993,9 @@ func (provider *OpenAIProvider) Passthrough( ExtraFields: schemas.BifrostResponseExtraFields{ Latency: latency.Milliseconds(), ProviderResponseHeaders: headers, + PassthroughPath: req.Path, }, + PassthroughUsage: passthroughUsage, } return bifrostResponse, nil @@ -7052,7 +7066,7 @@ func (provider *OpenAIProvider) PassthroughStream( return nil, providerUtils.NewBifrostOperationError(schemas.ErrProviderDoRequest, err) } - headers := providerUtils.ExtractProviderResponseHeaders(resp) + headers := providerUtils.ExtractPassthroughProviderResponseHeaders(resp) ctx.SetValue(schemas.BifrostContextKeyProviderResponseHeaders, headers) rawBodyStream := resp.BodyStream() @@ -7063,71 +7077,21 @@ func (provider *OpenAIProvider) PassthroughStream( fmt.Errorf("provider returned an empty stream body")) } - // Wrap reader with idle timeout to detect stalled streams. - bodyStream, stopIdleTimeout := providerUtils.NewIdleTimeoutReader(rawBodyStream, rawBodyStream, providerUtils.GetStreamIdleTimeout(ctx), ctx) - - // Cancellation must close the raw stream to unblock reads. - stopCancellation := providerUtils.SetupStreamCancellation(ctx, rawBodyStream, provider.logger) - - extraFields := schemas.BifrostResponseExtraFields{ - ProviderResponseHeaders: headers, - } - statusCode := resp.StatusCode() - - ch := make(chan *schemas.BifrostStreamChunk, schemas.DefaultStreamBufferSize) - go func() { - defer providerUtils.EnsureStreamFinalizerCalled(ctx, postHookSpanFinalizer) - defer func() { - if ctx.Err() == context.Canceled { - providerUtils.HandleStreamCancellation(ctx, postHookRunner, ch, provider.logger, postHookSpanFinalizer, providerUtils.PassthroughJSONBody(fasthttpReq, req.Body)) - } else if ctx.Err() == context.DeadlineExceeded { - providerUtils.HandleStreamTimeout(ctx, postHookRunner, ch, provider.logger, postHookSpanFinalizer, providerUtils.PassthroughJSONBody(fasthttpReq, req.Body)) - } - close(ch) - }() - defer providerUtils.ReleaseStreamingResponse(ctx, resp) - defer stopIdleTimeout() - defer stopCancellation() - - buf := make([]byte, 4096) - for { - n, readErr := bodyStream.Read(buf) - if n > 0 { - chunk := make([]byte, n) - copy(chunk, buf[:n]) - providerUtils.ProcessAndSendResponse(ctx, postHookRunner, &schemas.BifrostResponse{ - PassthroughResponse: &schemas.BifrostPassthroughResponse{ - StatusCode: statusCode, - Headers: headers, - Body: chunk, - ExtraFields: extraFields, - }, - }, ch, postHookSpanFinalizer) - } - if readErr == io.EOF { - ctx.SetValue(schemas.BifrostContextKeyStreamEndIndicator, true) - extraFields.Latency = time.Since(startTime).Milliseconds() - providerUtils.ProcessAndSendResponse(ctx, postHookRunner, &schemas.BifrostResponse{ - PassthroughResponse: &schemas.BifrostPassthroughResponse{ - StatusCode: statusCode, - Headers: headers, - ExtraFields: extraFields, - }, - }, ch, postHookSpanFinalizer) - return - } - if readErr != nil { - if ctx.Err() != nil { - return // let defer handle cancel/timeout - } - if readErr != io.EOF { - ctx.SetValue(schemas.BifrostContextKeyStreamEndIndicator, true) - extraFields.Latency = time.Since(startTime).Milliseconds() - providerUtils.ProcessAndSendError(ctx, postHookRunner, readErr, ch, provider.logger, postHookSpanFinalizer) - } - return - } - } - }() - return ch, nil + // Forward raw chunks to the client and extract usage incrementally per SSE event — + return providerUtils.StreamPassthrough( + ctx, postHookRunner, postHookSpanFinalizer, resp, rawBodyStream, + providerUtils.PassthroughStreamParams{ + StatusCode: resp.StatusCode(), + Headers: headers, + Path: req.Path, + RawRequest: req.Body, + CancellationBody: providerUtils.PassthroughJSONBody(fasthttpReq, req.Body), + StartTime: startTime, + Logger: provider.logger, + HasUsage: HasOpenAIPassthroughUsage, + Observe: func(event []byte) *schemas.BifrostPassthroughUsage { + return ExtractOpenAIPassthroughUsage(req.Method, req.Path, req.Body, event) + }, + }, + ), nil } diff --git a/core/providers/openai/passthrough_usage.go b/core/providers/openai/passthrough_usage.go new file mode 100644 index 00000000000..38b4e28af7d --- /dev/null +++ b/core/providers/openai/passthrough_usage.go @@ -0,0 +1,400 @@ +package openai + +import ( + "bytes" + "mime/multipart" + "strconv" + "strings" + + "github.com/bytedance/sonic" + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + "github.com/maximhq/bifrost/core/schemas" +) + +// ExtractOpenAIPassthroughUsage extracts usage from a passthrough response payload. method is the +// HTTP method (used to bill only generation routes); path is the stripped request path; reqBody is +// the original request body (needed for speech char count and image/video parameters); body is a +// single SSE data event (streaming) or the full response body (non-streaming). +func ExtractOpenAIPassthroughUsage(method, path string, reqBody, body []byte) *schemas.BifrostPassthroughUsage { + if idx := strings.IndexByte(path, '?'); idx >= 0 { + path = path[:idx] + } + + switch { + case strings.HasSuffix(path, "/chat/completions"), + strings.HasSuffix(path, "/completions"): + return extractOAIChatUsage(body) + + case strings.HasSuffix(path, "/responses"): + return extractOAIResponsesUsage(body) + + case strings.HasSuffix(path, "/embeddings"): + return extractOAIEmbeddingUsage(body) + + case strings.HasSuffix(path, "/audio/speech"): + return extractOAISpeechUsage(reqBody) + + case strings.HasSuffix(path, "/audio/transcriptions"), + strings.HasSuffix(path, "/audio/translations"): + return extractOAITranscriptionUsage(body) + + case strings.HasSuffix(path, "/images/generations"), + strings.HasSuffix(path, "/images/edits"), + strings.HasSuffix(path, "/images/variations"): + return extractOAIImageUsage(reqBody, body) + + case strings.Contains(path, "/video"): + if strings.EqualFold(method, "POST") { + return extractOAIVideoUsage(reqBody) + } + return nil + + case strings.HasSuffix(path, "/containers"): + // Collection path serves both create (POST, billable) and list (GET, free); + // extractOAIContainerUsage disambiguates by response shape. Retrieve/delete use + // /containers/{id} and never match this suffix. + return extractOAIContainerUsage(body) + } + + return nil +} + +func HasOpenAIPassthroughUsage(event []byte) bool { + return providerUtils.GetJSONField(event, "usage").Exists() || + providerUtils.GetJSONField(event, "response.usage").Exists() +} + +// ---- video generation ---- +const openAIVideoDefaultSeconds = 4 + +func extractOAIVideoUsage(reqBody []byte) *schemas.BifrostPassthroughUsage { + secs := openAIVideoDefaultSeconds + if len(reqBody) > 0 { + // JSON body: OpenAI documents `seconds` as a top-level request field. gjson .Float() + // handles both the numeric and string forms the API accepts. + if v := providerUtils.GetJSONField(reqBody, "seconds"); v.Exists() && v.Float() > 0 { + secs = int(v.Float()) + } else if form := parseMultipartFormValues(reqBody); form != nil { + // Multipart body (binary asset upload): `seconds` rides as a form field. + if v := firstFormValue(form, "seconds"); v != "" { + if f, parseErr := strconv.ParseFloat(v, 64); parseErr == nil && f > 0 { + secs = int(f) + } + } + } + } + return &schemas.BifrostPassthroughUsage{VideoSeconds: &secs} +} + +// parseMultipartFormValues sniffs the multipart boundary from the first line of body and returns +// the parsed form values, or nil when body is not multipart/form-data (e.g. JSON). OpenAI sends +// /v1/images/{edits,variations} and binary-asset video requests as multipart; their scalar +// params (seconds, size, quality, n) ride along as form fields. +func parseMultipartFormValues(body []byte) map[string][]string { + if len(body) == 0 { + return nil + } + firstLine, _, _ := bytes.Cut(body, []byte("\n")) + boundary := strings.TrimRight(strings.TrimPrefix(string(firstLine), "--"), "\r") + if boundary == "" { + return nil + } + mr := multipart.NewReader(bytes.NewReader(body), boundary) + form, err := mr.ReadForm(32 << 20) + if err != nil { + return nil + } + // ReadForm spills parts over maxMemory to temp files; clean them up. form.Value is held in + // memory and stays valid after RemoveAll (which only purges spilled file parts). + defer form.RemoveAll() + return form.Value +} + +func firstFormValue(form map[string][]string, key string) string { + if v := form[key]; len(v) > 0 { + return v[0] + } + return "" +} + +// ---- chat / text completions ---- +// BifrostLLMUsage is OpenAI-compatible so we can unmarshal directly. + +type oaiChatUsageWrapper struct { + Usage *schemas.BifrostLLMUsage `json:"usage"` + ServiceTier *string `json:"service_tier"` +} + +func extractOAIChatUsage(body []byte) *schemas.BifrostPassthroughUsage { + if len(body) == 0 { + return nil + } + var w oaiChatUsageWrapper + if err := sonic.Unmarshal(body, &w); err != nil || w.Usage == nil || w.Usage.TotalTokens == 0 { + return nil + } + u := &schemas.BifrostPassthroughUsage{LLMUsage: w.Usage} + if w.ServiceTier != nil { + t := schemas.BifrostServiceTier(*w.ServiceTier) + u.ServiceTier = &t + } + return u +} + +// ---- responses API ---- +// A single wrapper handles both response formats in one unmarshal pass: +// - streaming: "response.completed" event nests usage under "response" +// - non-streaming: usage sits at the top level +type oaiResponsesWrapper struct { + Response *struct { + Usage *schemas.ResponsesResponseUsage `json:"usage"` + ServiceTier *string `json:"service_tier"` + } `json:"response"` + Usage *schemas.ResponsesResponseUsage `json:"usage"` + ServiceTier *string `json:"service_tier"` +} + +func extractOAIResponsesUsage(body []byte) *schemas.BifrostPassthroughUsage { + if len(body) == 0 { + return nil + } + + var w oaiResponsesWrapper + if err := sonic.Unmarshal(body, &w); err != nil { + return nil + } + + // Streaming takes priority: nested under "response" with a non-zero total. + ru, tier := w.Usage, w.ServiceTier + if w.Response != nil && w.Response.Usage != nil && w.Response.Usage.TotalTokens > 0 { + ru, tier = w.Response.Usage, w.Response.ServiceTier + } + if ru == nil || ru.TotalTokens == 0 { + return nil + } + return buildOAIResponsesUsage(ru, tier) +} + +func buildOAIResponsesUsage(ru *schemas.ResponsesResponseUsage, serviceTier *string) *schemas.BifrostPassthroughUsage { + usage := &schemas.BifrostLLMUsage{ + PromptTokens: ru.InputTokens, + CompletionTokens: ru.OutputTokens, + TotalTokens: ru.TotalTokens, + } + if ru.InputTokensDetails != nil { + usage.PromptTokensDetails = &schemas.ChatPromptTokensDetails{ + CachedReadTokens: ru.InputTokensDetails.CachedReadTokens, + CachedWriteTokens: ru.InputTokensDetails.CachedWriteTokens, + } + } + if ru.OutputTokensDetails != nil { + usage.CompletionTokensDetails = &schemas.ChatCompletionTokensDetails{ + ReasoningTokens: ru.OutputTokensDetails.ReasoningTokens, + } + if ru.OutputTokensDetails.NumSearchQueries != nil { + usage.CompletionTokensDetails.NumSearchQueries = ru.OutputTokensDetails.NumSearchQueries + } + } + u := &schemas.BifrostPassthroughUsage{LLMUsage: usage} + if serviceTier != nil { + t := schemas.BifrostServiceTier(*serviceTier) + u.ServiceTier = &t + } + return u +} + +// ---- embeddings ---- +// Embeddings are not typically streamed; body is plain JSON. + +func extractOAIEmbeddingUsage(body []byte) *schemas.BifrostPassthroughUsage { + if len(body) == 0 { + return nil + } + var w oaiChatUsageWrapper + if err := sonic.Unmarshal(body, &w); err != nil || w.Usage == nil || w.Usage.TotalTokens == 0 { + return nil + } + return &schemas.BifrostPassthroughUsage{LLMUsage: w.Usage} +} + +// ---- speech (TTS) ---- +// Response is binary audio; pricing is based on input character count from the request. + +func extractOAISpeechUsage(reqBody []byte) *schemas.BifrostPassthroughUsage { + if len(reqBody) == 0 { + return nil + } + var req OpenAISpeechRequest + if err := sonic.Unmarshal(reqBody, &req); err != nil || req.Input == "" { + return nil + } + return &schemas.BifrostPassthroughUsage{ + AudioInputChars: len([]rune(req.Input)), + } +} + +// ---- transcription / translation ---- + +type oaiTranscriptionResponseWrapper struct { + Usage *schemas.TranscriptionUsage `json:"usage"` + Duration float64 `json:"duration"` // seconds fallback for older models +} + +func extractOAITranscriptionUsage(body []byte) *schemas.BifrostPassthroughUsage { + var r oaiTranscriptionResponseWrapper + if err := sonic.Unmarshal(body, &r); err != nil { + return nil + } + u := &schemas.BifrostPassthroughUsage{} + if r.Usage != nil && r.Usage.TotalTokens != nil && *r.Usage.TotalTokens > 0 { + promptTokens := 0 + if r.Usage.InputTokens != nil { + promptTokens = *r.Usage.InputTokens + } + u.LLMUsage = &schemas.BifrostLLMUsage{ + PromptTokens: promptTokens, + TotalTokens: *r.Usage.TotalTokens, + } + if r.Usage.InputTokenDetails != nil { + u.AudioTokenDetails = &schemas.TranscriptionUsageInputTokenDetails{ + AudioTokens: r.Usage.InputTokenDetails.AudioTokens, + TextTokens: r.Usage.InputTokenDetails.TextTokens, + } + } + u.AudioSeconds = r.Usage.Seconds + } else if r.Duration > 0 { + secs := int(r.Duration) + u.AudioSeconds = &secs + } + if u.LLMUsage == nil && u.AudioSeconds == nil { + return nil + } + return u +} + +// ---- image generation / edit / variation ---- +// Size, Quality, N come from the request body; usage/data count from the response. + +func extractOAIImageUsage(reqBody, body []byte) *schemas.BifrostPassthroughUsage { + u := &schemas.BifrostPassthroughUsage{} + + // Request body: size, quality, n. /v1/images/{edits,variations} are sent as + // multipart/form-data (binary image upload) with these as form fields; /v1/images/generations + // (and JSON-mode edits) carry them as a JSON OpenAIImageGenerationRequest. + if len(reqBody) > 0 { + var size, quality string + var n int + if form := parseMultipartFormValues(reqBody); form != nil { + size = firstFormValue(form, "size") + quality = firstFormValue(form, "quality") + if v := firstFormValue(form, "n"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil { + n = parsed + } + } + } else { + var req OpenAIImageGenerationRequest + if err := sonic.Unmarshal(reqBody, &req); err == nil { + if req.Size != nil { + size = *req.Size + } + if req.Quality != nil { + quality = *req.Quality + } + if req.N != nil { + n = *req.N + } + } + } + if size != "" { + u.ImageSize = size + } + if quality != "" { + u.ImageQuality = quality + } + if n > 0 { + if u.ImageUsage == nil { + u.ImageUsage = &schemas.ImageUsage{} + } + if u.ImageUsage.OutputTokensDetails == nil { + u.ImageUsage.OutputTokensDetails = &schemas.ImageTokenDetails{} + } + u.ImageUsage.OutputTokensDetails.NImages = n + } + } + + // Response body: use OpenAIImageStreamResponse (streaming SSE event) or fall back to + // plain JSON for non-streaming passthrough routes. + if len(body) > 0 { + var resp OpenAIImageStreamResponse + if err := sonic.Unmarshal(body, &resp); err == nil { + if resp.Usage != nil { + u.ImageUsage = resp.Usage + } + if resp.Size != "" && u.ImageSize == "" { + u.ImageSize = resp.Size + } + if resp.Quality != "" && u.ImageQuality == "" { + u.ImageQuality = resp.Quality + } + } + // Mirror the native path (populateOutputImageCount): count delivered images from + // the `data` array when the request didn't specify n and no token usage was + // returned (e.g. DALL·E, which has no usage block). + if dataLen := int(providerUtils.GetJSONField(body, "data.#").Int()); dataLen > 0 { + if u.ImageUsage == nil { + u.ImageUsage = &schemas.ImageUsage{} + } + if u.ImageUsage.OutputTokensDetails == nil { + u.ImageUsage.OutputTokensDetails = &schemas.ImageTokenDetails{} + } + if u.ImageUsage.OutputTokensDetails.NImages == 0 { + u.ImageUsage.OutputTokensDetails.NImages = dataLen + } + } + } + + if u.ImageUsage == nil { + u.ImageUsage = &schemas.ImageUsage{} + } + // Populate LLMUsage from image token counts so logs show token totals. + if u.ImageUsage.TotalTokens > 0 { + u.LLMUsage = &schemas.BifrostLLMUsage{ + PromptTokens: u.ImageUsage.InputTokens, + CompletionTokens: u.ImageUsage.OutputTokens, + TotalTokens: u.ImageUsage.TotalTokens, + } + } + return u +} + +// ---- containers (code interpreter sessions) ---- +// Only the create call (POST /v1/containers) is billable — a flat per-session fee priced +// under the synthetic "container-{memory_limit}" model key (falling back to "container"). +// The collection path also serves list (GET /v1/containers), so disambiguate by response +// shape: a create returns a single {"object":"container", "id":...} object, while a list +// returns {"object":"list", "data":[...]}. Retrieve/delete hit /containers/{id} and never +// reach this extractor. Containers are never streamed, so body is plain JSON. + +func extractOAIContainerUsage(body []byte) *schemas.BifrostPassthroughUsage { + if len(body) == 0 { + return nil + } + var resp struct { + Object string `json:"object"` + ID string `json:"id"` + MemoryLimit string `json:"memory_limit"` + } + if err := sonic.Unmarshal(body, &resp); err != nil { + return nil + } + // Bill only a created container (single object), not list/other shapes. + if resp.Object != "container" || resp.ID == "" { + return nil + } + identifier := "container" + if resp.MemoryLimit != "" { + identifier = "container-" + resp.MemoryLimit + } + return &schemas.BifrostPassthroughUsage{ContainerIdentifier: identifier} +} diff --git a/core/providers/openai/passthrough_usage_test.go b/core/providers/openai/passthrough_usage_test.go new file mode 100644 index 00000000000..c1bdae8995b --- /dev/null +++ b/core/providers/openai/passthrough_usage_test.go @@ -0,0 +1,292 @@ +package openai_test + +import ( + "bytes" + "mime/multipart" + "testing" + + "github.com/maximhq/bifrost/core/providers/openai" + "github.com/maximhq/bifrost/core/schemas" +) + +// multipartBody builds a multipart/form-data body with the given fields, matching what the +// video passthrough extractor sniffs (boundary on the first line). +func multipartBody(t *testing.T, fields map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + for k, v := range fields { + if err := w.WriteField(k, v); err != nil { + t.Fatalf("WriteField: %v", err) + } + } + if err := w.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + return buf.Bytes() +} + +func TestExtractOpenAIPassthroughUsage(t *testing.T) { + tests := []struct { + name string + method string + path string + reqBody string + body string + check func(t *testing.T, u *schemas.BifrostPassthroughUsage) + }{ + { + name: "chat completions usage + service tier", + path: "/v1/chat/completions", + body: `{"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15},"service_tier":"default"}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + mustLLM(t, u, 10, 5, 15) + if u.ServiceTier == nil || *u.ServiceTier != schemas.BifrostServiceTierDefault { + t.Fatalf("service tier = %v, want default", u.ServiceTier) + } + }, + }, + { + name: "chat completions zero usage -> nil", + path: "/v1/chat/completions", + body: `{"usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}`, + check: mustNil, + }, + { + name: "chat completions content delta (no usage) -> nil", + path: "/v1/chat/completions", + body: `{"choices":[{"delta":{"content":"hi"}}]}`, + check: mustNil, + }, + { + name: "responses top-level usage with reasoning + search queries", + path: "/v1/responses", + body: `{"usage":{"input_tokens":20,"output_tokens":8,"total_tokens":28,"output_tokens_details":{"reasoning_tokens":3,"num_search_queries":2}}}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + mustLLM(t, u, 20, 8, 28) + d := u.LLMUsage.CompletionTokensDetails + if d == nil || d.ReasoningTokens != 3 { + t.Fatalf("reasoning tokens = %v, want 3", d) + } + if d.NumSearchQueries == nil || *d.NumSearchQueries != 2 { + t.Fatalf("num search queries = %v, want 2", d.NumSearchQueries) + } + }, + }, + { + name: "responses nested response.usage", + path: "/v1/responses", + body: `{"response":{"usage":{"input_tokens":20,"output_tokens":8,"total_tokens":28}}}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + mustLLM(t, u, 20, 8, 28) + }, + }, + { + name: "embeddings", + path: "/v1/embeddings", + body: `{"usage":{"prompt_tokens":12,"total_tokens":12}}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + if u == nil || u.LLMUsage == nil || u.LLMUsage.PromptTokens != 12 || u.LLMUsage.TotalTokens != 12 { + t.Fatalf("embeddings usage = %+v", u) + } + }, + }, + { + name: "speech char count from request", + path: "/v1/audio/speech", + reqBody: `{"input":"héllo"}`, // 5 runes + body: "", + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + if u == nil || u.AudioInputChars != 5 { + t.Fatalf("audio input chars = %+v, want 5", u) + } + }, + }, + { + name: "transcription token usage", + path: "/v1/audio/transcriptions", + body: `{"usage":{"type":"tokens","input_tokens":4,"total_tokens":10,"input_token_details":{"audio_tokens":3,"text_tokens":1},"seconds":2}}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + if u == nil || u.LLMUsage == nil || u.LLMUsage.PromptTokens != 4 || u.LLMUsage.TotalTokens != 10 { + t.Fatalf("transcription usage = %+v", u) + } + if u.AudioTokenDetails == nil || u.AudioTokenDetails.AudioTokens != 3 { + t.Fatalf("audio token details = %+v", u.AudioTokenDetails) + } + if u.AudioSeconds == nil || *u.AudioSeconds != 2 { + t.Fatalf("audio seconds = %v, want 2", u.AudioSeconds) + } + }, + }, + { + name: "transcription duration fallback", + path: "/v1/audio/transcriptions", + body: `{"duration":3}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + if u == nil || u.AudioSeconds == nil || *u.AudioSeconds != 3 { + t.Fatalf("audio seconds = %+v, want 3", u) + } + }, + }, + { + name: "image generation response usage", + path: "/v1/images/generations", + body: `{"usage":{"input_tokens":5,"output_tokens":100,"total_tokens":105}}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + if u == nil || u.ImageUsage == nil || u.ImageUsage.TotalTokens != 105 { + t.Fatalf("image usage = %+v", u) + } + if u.LLMUsage == nil || u.LLMUsage.TotalTokens != 105 { + t.Fatalf("image llm usage = %+v", u.LLMUsage) + } + }, + }, + { + name: "image variation count from request n", + path: "/v1/images/variations", + reqBody: `{"n":2}`, + body: `{"data":[{"b64_json":"x"},{"b64_json":"y"}]}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + if u == nil || u.ImageUsage == nil || u.ImageUsage.OutputTokensDetails == nil || + u.ImageUsage.OutputTokensDetails.NImages != 2 { + t.Fatalf("image NImages = %+v", u) + } + }, + }, + { + name: "video seconds default (no body)", + path: "/v1/videos", + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + if u == nil || u.VideoSeconds == nil || *u.VideoSeconds != 4 { + t.Fatalf("video seconds = %+v, want default 4", u) + } + }, + }, + { + name: "container create with memory limit", + path: "/v1/containers", + body: `{"object":"container","id":"cntr_1","memory_limit":"1g"}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + if u == nil || u.ContainerIdentifier != "container-1g" { + t.Fatalf("container id = %+v, want container-1g", u) + } + }, + }, + { + name: "container create without memory limit", + path: "/v1/containers", + body: `{"object":"container","id":"cntr_1"}`, + check: func(t *testing.T, u *schemas.BifrostPassthroughUsage) { + if u == nil || u.ContainerIdentifier != "container" { + t.Fatalf("container id = %+v, want container", u) + } + }, + }, + { + name: "container list -> nil (not billable)", + path: "/v1/containers", + body: `{"object":"list","data":[{"object":"container","id":"c1"}]}`, + check: mustNil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + method := tt.method + if method == "" { + method = "POST" + } + u := openai.ExtractOpenAIPassthroughUsage(method, tt.path, []byte(tt.reqBody), []byte(tt.body)) + tt.check(t, u) + }) + } +} + +func TestExtractOpenAIPassthroughUsage_VideoMultipartSeconds(t *testing.T) { + reqBody := multipartBody(t, map[string]string{"seconds": "6"}) + u := openai.ExtractOpenAIPassthroughUsage("POST", "/v1/videos", reqBody, nil) + if u == nil || u.VideoSeconds == nil || *u.VideoSeconds != 6 { + t.Fatalf("video seconds = %+v, want 6", u) + } +} + +// Video usage is billable only on POST. GET (list/retrieve) and DELETE must not accrue +// per-second video usage even though the path contains "/videos". +func TestExtractOpenAIPassthroughUsage_VideoNonPOSTNotBilled(t *testing.T) { + for _, method := range []string{"GET", "DELETE"} { + t.Run(method, func(t *testing.T) { + if u := openai.ExtractOpenAIPassthroughUsage(method, "/v1/videos", nil, nil); u != nil { + t.Fatalf("%s /v1/videos = %+v, want nil", method, u) + } + }) + } +} + +// JSON create-video bodies carry `seconds` as a top-level field (numeric or string). +func TestExtractOpenAIPassthroughUsage_VideoJSONSeconds(t *testing.T) { + for _, body := range []string{`{"seconds":12}`, `{"seconds":"12"}`} { + u := openai.ExtractOpenAIPassthroughUsage("POST", "/v1/videos", []byte(body), nil) + if u == nil || u.VideoSeconds == nil || *u.VideoSeconds != 12 { + t.Fatalf("video seconds for %s = %+v, want 12", body, u) + } + } +} + +// /v1/images/variations is multipart/form-data; size/quality/n ride as form fields. +func TestExtractOpenAIPassthroughUsage_ImageMultipartParams(t *testing.T) { + reqBody := multipartBody(t, map[string]string{"size": "1024x1024", "quality": "high", "n": "3"}) + u := openai.ExtractOpenAIPassthroughUsage("POST", "/v1/images/variations", reqBody, nil) + if u == nil { + t.Fatal("expected usage, got nil") + } + if u.ImageSize != "1024x1024" { + t.Fatalf("image size = %q, want 1024x1024", u.ImageSize) + } + if u.ImageQuality != "high" { + t.Fatalf("image quality = %q, want high", u.ImageQuality) + } + if u.ImageUsage == nil || u.ImageUsage.OutputTokensDetails == nil || + u.ImageUsage.OutputTokensDetails.NImages != 3 { + t.Fatalf("image NImages = %+v, want 3", u) + } +} + +func TestHasOpenAIPassthroughUsage(t *testing.T) { + tests := []struct { + name string + event string + want bool + }{ + {"top-level usage", `{"usage":{"total_tokens":5}}`, true}, + {"nested response.usage", `{"response":{"usage":{"total_tokens":5}}}`, true}, + {"content delta", `{"choices":[{"delta":{"content":"hi"}}]}`, false}, + {"ping", `{"type":"ping"}`, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := openai.HasOpenAIPassthroughUsage([]byte(tt.event)); got != tt.want { + t.Fatalf("HasOpenAIPassthroughUsage = %v, want %v", got, tt.want) + } + }) + } +} + +// ---- shared assertion helpers ---- + +func mustNil(t *testing.T, u *schemas.BifrostPassthroughUsage) { + t.Helper() + if u != nil { + t.Fatalf("expected nil usage, got %+v", u) + } +} + +func mustLLM(t *testing.T, u *schemas.BifrostPassthroughUsage, prompt, completion, total int) { + t.Helper() + if u == nil || u.LLMUsage == nil { + t.Fatalf("expected LLMUsage, got %+v", u) + } + if u.LLMUsage.PromptTokens != prompt || u.LLMUsage.CompletionTokens != completion || u.LLMUsage.TotalTokens != total { + t.Fatalf("LLMUsage = {prompt:%d completion:%d total:%d}, want {%d %d %d}", + u.LLMUsage.PromptTokens, u.LLMUsage.CompletionTokens, u.LLMUsage.TotalTokens, prompt, completion, total) + } +} diff --git a/core/providers/openai/responses.go b/core/providers/openai/responses.go index 3f634cdce9f..e640d2023ef 100644 --- a/core/providers/openai/responses.go +++ b/core/providers/openai/responses.go @@ -146,6 +146,28 @@ func ToOpenAIResponsesRequest(bifrostReq *schemas.BifrostResponsesRequest) *Open if !isReasoning { message.ResponsesReasoning.EncryptedContent = nil } + // OpenAI types reasoning.content as an array of reasoning_text blocks, so a + // string value is rejected ("expected an array ... got a string"). Replayed + // reasoning items can arrive with content as a string (e.g. an empty "" round-tripped + // through the response path). message is a value copy, so reassign its Content pointer + // without mutating the caller's input: drop empty strings, promote non-empty ones to a block. + if message.Content != nil { + switch { + case message.Content.ContentStr != nil: + if text := *message.Content.ContentStr; text == "" { + message.Content = nil + } else { + message.Content = &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{{ + Type: schemas.ResponsesOutputMessageContentTypeReasoning, + Text: schemas.Ptr(text), + }}, + } + } + case len(message.Content.ContentBlocks) == 0: + message.Content = nil + } + } messages = append(messages, message) } } else if message.ResponsesToolMessage != nil && @@ -178,7 +200,8 @@ func ToOpenAIResponsesRequest(bifrostReq *schemas.BifrostResponsesRequest) *Open params := bifrostReq.Params // Create the responses request with properly mapped parameters req := &OpenAIResponsesRequest{ - Model: bifrostReq.Model, + Model: bifrostReq.Model, + Provider: bifrostReq.Provider, Input: OpenAIResponsesRequestInput{ OpenAIResponsesRequestInputArray: messages, }, @@ -315,6 +338,11 @@ func (resp *OpenAIResponsesRequest) filterUnsupportedTools() { schemas.ResponsesToolTypeNamespace: true, } + // Allow provider-native tools that are not part of the OpenAI spec + if resp.Provider == schemas.XAI { + supportedTypes[schemas.ResponsesToolTypeXSearch] = true + } + // Filter tools to only include supported types filteredTools := make([]schemas.ResponsesTool, 0, len(resp.Tools)) for _, tool := range resp.Tools { @@ -375,4 +403,10 @@ func (resp *OpenAIResponsesRequest) filterUnsupportedTools() { } } resp.Tools = filteredTools + + // If every tool was stripped, a leftover tool_choice would cause a 400 from + // the upstream ("tool_choice must be specified with tools"). + if len(resp.Tools) == 0 { + resp.ToolChoice = nil + } } diff --git a/core/providers/openai/responses_marshal_test.go b/core/providers/openai/responses_marshal_test.go index 8e68a718ed9..31f7451ae17 100644 --- a/core/providers/openai/responses_marshal_test.go +++ b/core/providers/openai/responses_marshal_test.go @@ -907,3 +907,57 @@ func TestResponsesToolMessage_NamespaceRoundTrip(t *testing.T) { t.Fatalf("namespace not in marshaled output: %s", string(out)) } } + +// TestOpenAIResponsesRequest_MarshalJSON_CompactionSummaryStripped verifies that the +// "summary" field is removed from a compaction input item (OpenAI rejects it as an +// unknown parameter) while a sibling reasoning item keeps its summary array intact. +func TestOpenAIResponsesRequest_MarshalJSON_CompactionSummaryStripped(t *testing.T) { + enc := "gAAAA-encrypted" + compactionType := schemas.ResponsesMessageTypeCompaction + reasoningType := schemas.ResponsesMessageTypeReasoning + + input := OpenAIResponsesRequestInput{ + OpenAIResponsesRequestInputArray: []schemas.ResponsesMessage{ + { + // Compaction item: encrypted_content rides the embedded ResponsesReasoning, + // which would otherwise re-emit summary:null. summary must be stripped. + Type: &compactionType, + ResponsesReasoning: &schemas.ResponsesReasoning{Summary: nil, EncryptedContent: &enc}, + }, + { + // Reasoning item: summary (even empty []) is required by OpenAI and must survive. + Type: &reasoningType, + ResponsesReasoning: &schemas.ResponsesReasoning{Summary: []schemas.ResponsesReasoningSummary{}, EncryptedContent: &enc}, + }, + }, + } + + data, err := input.MarshalJSON() + if err != nil { + t.Fatalf("MarshalJSON returned error: %v", err) + } + + var items []map[string]json.RawMessage + if err := json.Unmarshal(data, &items); err != nil { + t.Fatalf("failed to unmarshal serialized input: %v\n%s", err, string(data)) + } + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d: %s", len(items), string(data)) + } + + // Compaction item (index 0) must NOT carry a summary key. + if _, exists := items[0]["summary"]; exists { + t.Errorf("compaction item should not contain a summary field, got: %s", string(data)) + } + if _, exists := items[0]["encrypted_content"]; !exists { + t.Errorf("compaction item should retain encrypted_content, got: %s", string(data)) + } + + // Reasoning item (index 1) must retain its summary array. + rawSummary, exists := items[1]["summary"] + if !exists { + t.Errorf("reasoning item should retain its summary field, got: %s", string(data)) + } else if string(rawSummary) != "[]" { + t.Errorf("reasoning item summary should be [], got: %s", string(rawSummary)) + } +} diff --git a/core/providers/openai/responses_test.go b/core/providers/openai/responses_test.go index a548d075f3b..928e4eb40fe 100644 --- a/core/providers/openai/responses_test.go +++ b/core/providers/openai/responses_test.go @@ -226,6 +226,81 @@ func TestToOpenAIResponsesRequest_ReasoningOnlyMessageSkip(t *testing.T) { } } +// TestToOpenAIResponsesRequest_ReasoningStringContent guards the Codex/GPT-5.5 +// replay path: a reasoning item can arrive with content as a string (notably an +// empty "" round-tripped through the response path). OpenAI types reasoning.content +// as an array of reasoning_text blocks and rejects a string with +// "expected an array ... got a string", so the outbound conversion must drop the +// empty string and promote a non-empty one to a reasoning_text block. +func TestToOpenAIResponsesRequest_ReasoningStringContent(t *testing.T) { + t.Run("empty string content is dropped", func(t *testing.T) { + bifrostReq := &schemas.BifrostResponsesRequest{ + Model: "gpt-5.5", + Input: []schemas.ResponsesMessage{{ + Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning), + ResponsesReasoning: &schemas.ResponsesReasoning{EncryptedContent: schemas.Ptr("enc1")}, + Content: &schemas.ResponsesMessageContent{ContentStr: schemas.Ptr("")}, + }}, + } + + result := ToOpenAIResponsesRequest(bifrostReq) + original := bifrostReq.Input[0].Content + if original == nil || original.ContentStr == nil || *original.ContentStr != "" { + t.Fatalf("expected input reasoning content string to remain unchanged, got %#v", original) + } + if len(original.ContentBlocks) != 0 { + t.Fatalf("expected input reasoning content blocks to remain empty, got %#v", original.ContentBlocks) + } + if result == nil || len(result.Input.OpenAIResponsesRequestInputArray) != 1 { + t.Fatalf("expected one converted message, got %#v", result) + } + if c := result.Input.OpenAIResponsesRequestInputArray[0].Content; c != nil { + t.Errorf("expected reasoning Content to be dropped, got %#v", c) + } + + // End-to-end: the marshalled request must not carry content:"" on the reasoning item. + out, err := json.Marshal(result) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(out), `"content":""`) { + t.Errorf("reasoning item serialized with empty-string content: %s", string(out)) + } + }) + + t.Run("non-empty string content becomes a reasoning_text block", func(t *testing.T) { + bifrostReq := &schemas.BifrostResponsesRequest{ + Model: "gpt-5.5", + Input: []schemas.ResponsesMessage{{ + Type: schemas.Ptr(schemas.ResponsesMessageTypeReasoning), + ResponsesReasoning: &schemas.ResponsesReasoning{EncryptedContent: schemas.Ptr("enc1")}, + Content: &schemas.ResponsesMessageContent{ContentStr: schemas.Ptr("thinking")}, + }}, + } + + result := ToOpenAIResponsesRequest(bifrostReq) + original := bifrostReq.Input[0].Content + if original == nil || original.ContentStr == nil || *original.ContentStr != "thinking" { + t.Fatalf("expected input reasoning content string to remain unchanged, got %#v", original) + } + if len(original.ContentBlocks) != 0 { + t.Fatalf("expected input reasoning content blocks to remain empty, got %#v", original.ContentBlocks) + } + if result == nil || len(result.Input.OpenAIResponsesRequestInputArray) != 1 { + t.Fatalf("expected one converted message, got %#v", result) + } + c := result.Input.OpenAIResponsesRequestInputArray[0].Content + if c == nil || c.ContentStr != nil || len(c.ContentBlocks) != 1 { + t.Fatalf("expected a single content block, got %#v", c) + } + block := c.ContentBlocks[0] + if block.Type != schemas.ResponsesOutputMessageContentTypeReasoning || + block.Text == nil || *block.Text != "thinking" { + t.Errorf("expected reasoning_text block with text %q, got %#v", "thinking", block) + } + }) +} + func TestToOpenAIResponsesRequest_NormalizesReasoningEffort(t *testing.T) { tests := []struct { name string diff --git a/core/providers/openai/types.go b/core/providers/openai/types.go index e2eab5245a9..e0e4061944f 100644 --- a/core/providers/openai/types.go +++ b/core/providers/openai/types.go @@ -9,6 +9,7 @@ import ( "github.com/bytedance/sonic" providerUtils "github.com/maximhq/bifrost/core/providers/utils" "github.com/maximhq/bifrost/core/schemas" + "github.com/tidwall/sjson" ) const MinMaxCompletionTokens = 16 @@ -340,7 +341,11 @@ func (r *OpenAIResponsesRequestInput) MarshalJSON() ([]byte, error) { // If no CacheControl found anywhere, marshal as-is if !needsCopy { - return providerUtils.MarshalSorted(r.OpenAIResponsesRequestInputArray) + data, err := providerUtils.MarshalSorted(r.OpenAIResponsesRequestInputArray) + if err != nil { + return nil, err + } + return stripCompactionItemSummary(data, r.OpenAIResponsesRequestInputArray), nil } // Only copy messages that have CacheControl @@ -498,11 +503,31 @@ func (r *OpenAIResponsesRequestInput) MarshalJSON() ([]byte, error) { } } } - return providerUtils.MarshalSorted(messagesCopy) + data, err := providerUtils.MarshalSorted(messagesCopy) + if err != nil { + return nil, err + } + return stripCompactionItemSummary(data, messagesCopy), nil } return providerUtils.MarshalSorted(nil) } +// stripCompactionItemSummary removes the "summary" field from compaction input items. +// OpenAI's Responses API rejects "summary" on a compaction item ("Unknown parameter: +// input[N].summary"). Bifrost has no first-class compaction item model, so the item's +// encrypted_content rides the embedded *ResponsesReasoning, whose (no-omitempty) Summary +// re-injects "summary": null. Reasoning items legitimately carry summary and are left intact. +func stripCompactionItemSummary(data []byte, items []schemas.ResponsesMessage) []byte { + for i, msg := range items { + if msg.Type != nil && *msg.Type == schemas.ResponsesMessageTypeCompaction { + if updated, err := sjson.DeleteBytes(data, fmt.Sprintf("%d.summary", i)); err == nil { + data = updated + } + } + } + return data +} + // Helper function to check if a chat message has any CacheControl fields or FileType in file blocks // isAnthropicServerToolShape reports whether the tool carries the Anthropic // server-tool shape (Function and Custom both nil). On outbound to OpenAI, @@ -706,7 +731,8 @@ type OpenAIResponsesRequest struct { schemas.ResponsesParameters Stream *bool `json:"stream,omitempty"` - // Bifrost specific field (only parsed when converting from Provider -> Bifrost request) + // Bifrost specific fields (not serialized to wire) + Provider schemas.ModelProvider `json:"-"` // originating provider, used for provider-specific filtering Fallbacks []string `json:"fallbacks,omitempty"` ExtraParams map[string]interface{} `json:"-"` // Optional: Extra parameters } diff --git a/core/providers/openrouter/openrouter.go b/core/providers/openrouter/openrouter.go index 2bc5162d7c3..4a2b1250ec2 100644 --- a/core/providers/openrouter/openrouter.go +++ b/core/providers/openrouter/openrouter.go @@ -43,7 +43,7 @@ func NewOpenRouterProvider(config *schemas.ProviderConfig, logger schemas.Logger // Configure proxy and retry policy client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) - client = providerUtils.ConfigureDialer(client) + client = providerUtils.ConfigureDialer(client, config.NetworkConfig.AllowPrivateNetwork) client = providerUtils.ConfigureTLS(client, config.NetworkConfig, logger) streamingClient := providerUtils.BuildStreamingClient(client) // Set default BaseURL if not provided diff --git a/core/providers/parasail/parasail.go b/core/providers/parasail/parasail.go index e0d6a84c13d..7c0fe953843 100644 --- a/core/providers/parasail/parasail.go +++ b/core/providers/parasail/parasail.go @@ -42,7 +42,7 @@ func NewParasailProvider(config *schemas.ProviderConfig, logger schemas.Logger) // Configure proxy and retry policy client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) - client = providerUtils.ConfigureDialer(client) + client = providerUtils.ConfigureDialer(client, config.NetworkConfig.AllowPrivateNetwork) client = providerUtils.ConfigureTLS(client, config.NetworkConfig, logger) streamingClient := providerUtils.BuildStreamingClient(client) // Set default BaseURL if not provided diff --git a/core/providers/perplexity/perplexity.go b/core/providers/perplexity/perplexity.go index 52d498e1986..9a8cd2beb6b 100644 --- a/core/providers/perplexity/perplexity.go +++ b/core/providers/perplexity/perplexity.go @@ -44,7 +44,7 @@ func NewPerplexityProvider(config *schemas.ProviderConfig, logger schemas.Logger // Configure proxy and retry policy client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) - client = providerUtils.ConfigureDialer(client) + client = providerUtils.ConfigureDialer(client, config.NetworkConfig.AllowPrivateNetwork) client = providerUtils.ConfigureTLS(client, config.NetworkConfig, logger) streamingClient := providerUtils.BuildStreamingClient(client) // Set default BaseURL if not provided diff --git a/core/providers/replicate/replicate.go b/core/providers/replicate/replicate.go index 44910103b84..bde5f0bdf5a 100644 --- a/core/providers/replicate/replicate.go +++ b/core/providers/replicate/replicate.go @@ -51,7 +51,7 @@ func NewReplicateProvider(config *schemas.ProviderConfig, logger schemas.Logger) // Configure proxy and retry policy client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) - client = providerUtils.ConfigureDialer(client) + client = providerUtils.ConfigureDialer(client, config.NetworkConfig.AllowPrivateNetwork) client = providerUtils.ConfigureTLS(client, config.NetworkConfig, logger) streamingClient := providerUtils.BuildStreamingClient(client) config.NetworkConfig.BaseURL = strings.TrimRight(config.NetworkConfig.BaseURL, "/") diff --git a/core/providers/runway/runway.go b/core/providers/runway/runway.go index e51e5e6355b..d47e8630e6c 100644 --- a/core/providers/runway/runway.go +++ b/core/providers/runway/runway.go @@ -42,7 +42,7 @@ func NewRunwayProvider(config *schemas.ProviderConfig, logger schemas.Logger) (* // Configure proxy if provided client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) - client = providerUtils.ConfigureDialer(client) + client = providerUtils.ConfigureDialer(client, config.NetworkConfig.AllowPrivateNetwork) client = providerUtils.ConfigureTLS(client, config.NetworkConfig, logger) // Set default BaseURL if not provided diff --git a/core/providers/sgl/sgl.go b/core/providers/sgl/sgl.go index 47125d6a80f..3d9b0ba7b4e 100644 --- a/core/providers/sgl/sgl.go +++ b/core/providers/sgl/sgl.go @@ -47,7 +47,7 @@ func NewSGLProvider(config *schemas.ProviderConfig, logger schemas.Logger) (*SGL // Configure proxy and retry policy client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) - client = providerUtils.ConfigureDialer(client) + client = providerUtils.ConfigureDialer(client, config.NetworkConfig.AllowPrivateNetwork) client = providerUtils.ConfigureTLS(client, config.NetworkConfig, logger) streamingClient := providerUtils.BuildStreamingClient(client) config.NetworkConfig.BaseURL = strings.TrimRight(config.NetworkConfig.BaseURL, "/") diff --git a/core/providers/utils/dialer_test.go b/core/providers/utils/dialer_test.go index 1ab1b5570c1..aa3d739e5e8 100644 --- a/core/providers/utils/dialer_test.go +++ b/core/providers/utils/dialer_test.go @@ -5,6 +5,7 @@ import ( "net" "net/http" "net/http/httptest" + "strings" "sync/atomic" "testing" "time" @@ -21,7 +22,7 @@ func TestConfigureDialer_SetsRetryIfErr(t *testing.T) { t.Fatal("precondition: RetryIfErr should be nil on a new client") } - ConfigureDialer(client) + ConfigureDialer(client, false) if client.RetryIfErr == nil { t.Fatal("ConfigureDialer should set RetryIfErr") @@ -46,7 +47,7 @@ func TestConfigureDialer_SetsDial(t *testing.T) { t.Fatal("precondition: Dial should be nil on a new client") } - ConfigureDialer(client) + ConfigureDialer(client, false) if client.Dial == nil { t.Fatal("ConfigureDialer should set a Dial function") @@ -66,7 +67,7 @@ func TestConfigureDialer_ComposesWithExistingDial(t *testing.T) { return net.Dial("tcp", addr) } - ConfigureDialer(client) + ConfigureDialer(client, false) // Start a test server to connect to server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -106,10 +107,8 @@ func TestConfigureDialer_TCPKeepAliveEnabled(t *testing.T) { // Test without existing dial (direct connection path) t.Run("without_existing_dial", func(t *testing.T) { client := &fasthttp.Client{} - ConfigureDialer(client) + ConfigureDialer(client, false) - // The Dial function should create connections with keepalive - // We can verify by making a connection and checking the TCP options req := fasthttp.AcquireRequest() resp := fasthttp.AcquireResponse() defer fasthttp.ReleaseRequest(req) @@ -135,7 +134,7 @@ func TestConfigureDialer_TCPKeepAliveEnabled(t *testing.T) { connFromProxy = conn return conn, err } - ConfigureDialer(client) + ConfigureDialer(client, false) req := fasthttp.AcquireRequest() resp := fasthttp.AcquireResponse() @@ -164,7 +163,7 @@ func TestConfigureDialer_TCPKeepAliveEnabled(t *testing.T) { // same client pointer it received (for chaining). func TestConfigureDialer_ReturnValue(t *testing.T) { client := &fasthttp.Client{} - result := ConfigureDialer(client) + result := ConfigureDialer(client, false) if result != client { t.Error("ConfigureDialer should return the same client pointer") } @@ -180,8 +179,8 @@ func TestConfigureDialer_Idempotent(t *testing.T) { defer server.Close() client := &fasthttp.Client{} - ConfigureDialer(client) - ConfigureDialer(client) // called again + ConfigureDialer(client, false) + ConfigureDialer(client, false) // called again req := fasthttp.AcquireRequest() resp := fasthttp.AcquireResponse() @@ -231,7 +230,7 @@ func TestConfigureDialer_WithRetryOnStaleConnection(t *testing.T) { MaxConnsPerHost: 10, } // Use ConfigureDialer (the function under test) instead of manually setting RetryIfErr - ConfigureDialer(client) + ConfigureDialer(client, false) // First request: establish connection in pool req := fasthttp.AcquireRequest() @@ -293,6 +292,105 @@ func TestConfigureRetry_Deprecated(t *testing.T) { } } +// TestConfigureDialer_SSRFProtection verifies that the default (no-proxy) dial +// path rejects connections to private, loopback, and link-local addresses before +// any TCP socket is opened. +func TestConfigureDialer_SSRFProtection(t *testing.T) { + tests := []struct { + name string + addr string + wantErr string + }{ + // Unspecified addresses — IsPrivateIP rejects them via IsUnspecified() + {"0.0.0.0 all-zeros", "0.0.0.0:80", "unspecified IP"}, + + // RFC 1918 private ranges — LookupIP returns the literal IP, IsPrivateIP rejects it + {"10.x.x.x", "10.0.0.1:80", "private IP"}, + {"172.16.x.x", "172.16.0.1:80", "private IP"}, + {"192.168.x.x", "192.168.1.1:80", "private IP"}, + + // Link-local / cloud metadata + {"169.254.169.254 AWS metadata", "169.254.169.254:80", "link-local IP"}, + {"169.254.x.x link-local", "169.254.1.1:80", "link-local IP"}, + + // IPv6 private + {"[fc00::1] unique-local", "[fc00::1]:80", "private IP"}, + {"[fd00::1] unique-local", "[fd00::1]:80", "private IP"}, + + // Unspecified IPv6 long form — regression for bypass via 0:0:0:0:0:0:0:0 + {"[0:0:0:0:0:0:0:0] unspecified long form", "[0:0:0:0:0:0:0:0]:80", "unspecified IP"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := &fasthttp.Client{ReadTimeout: time.Second} + ConfigureDialer(client, false) + _, err := client.Dial(tt.addr) + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + }) + } +} + +// TestConfigureDialer_SSRFProxyBypass verifies that when an existingDial is set +// (proxy path), SSRF checks are intentionally skipped — the proxy owns routing. +func TestConfigureDialer_SSRFProxyBypass(t *testing.T) { + var proxyCalled bool + client := &fasthttp.Client{} + client.Dial = func(addr string) (net.Conn, error) { + proxyCalled = true + return nil, fmt.Errorf("proxy handled: %s", addr) + } + ConfigureDialer(client, false) + + _, err := client.Dial("10.0.0.1:80") + if !proxyCalled { + t.Error("expected proxy dial to be called for private IP") + } + if err == nil || !strings.Contains(err.Error(), "proxy handled") { + t.Errorf("expected proxy error, got %v", err) + } +} + +// TestConfigureDialer_SSRFZeroTimeout verifies that SSRF protection is active +// even when ReadTimeout is 0 (context.Background() is used instead of WithTimeout). +func TestConfigureDialer_SSRFZeroTimeout(t *testing.T) { + client := &fasthttp.Client{ReadTimeout: 0} + ConfigureDialer(client, false) + + _, err := client.Dial("169.254.169.254:80") + if err == nil { + t.Fatal("expected SSRF rejection with zero ReadTimeout, got nil") + } + if !strings.Contains(err.Error(), "link-local IP") { + t.Errorf("expected 'link-local IP' error, got %q", err.Error()) + } +} + +// TestConfigureDialer_SSRFMultiIPAllFail verifies that when all resolved IPs +// fail to connect, the last dial error is returned (not a generic message). +func TestConfigureDialer_SSRFMultiIPAllFail(t *testing.T) { + // 192.0.2.0/24 is TEST-NET-1 (RFC 5737) — documentation-only, never routed. + // A connection attempt to it will fail (refused or timeout) without any + // private-IP rejection, letting us exercise the lastErr return path. + client := &fasthttp.Client{ReadTimeout: 200 * time.Millisecond} + ConfigureDialer(client, false) + + _, err := client.Dial("192.0.2.1:9") + if err == nil { + t.Fatal("expected connection error for unroutable TEST-NET address") + } + // Must not be the generic "no usable address" sentinel — a real dial error + // was returned. + if strings.Contains(err.Error(), "no usable address resolved") { + t.Errorf("expected a real dial error, got generic sentinel: %v", err) + } +} + // TestConfigureDialer_DialError verifies that dial errors from the existing // dial function are properly propagated (not swallowed). func TestConfigureDialer_DialError(t *testing.T) { @@ -302,7 +400,7 @@ func TestConfigureDialer_DialError(t *testing.T) { return nil, expectedErr } - ConfigureDialer(client) + ConfigureDialer(client, false) req := fasthttp.AcquireRequest() resp := fasthttp.AcquireResponse() @@ -344,7 +442,7 @@ func TestStaleConnectionRetryIfErr_WrappedErrors(t *testing.T) { { name: "ErrConnectionClosed from fasthttp", err: fasthttp.ErrConnectionClosed, - wantRetry: false, // Not matched - this error appears AFTER the retry loop + wantRetry: true, // Explicitly matched to stay correct if future fasthttp versions surface it inside the retry loop }, } diff --git a/core/providers/utils/passthrough_stream.go b/core/providers/utils/passthrough_stream.go new file mode 100644 index 00000000000..3e7aacf9899 --- /dev/null +++ b/core/providers/utils/passthrough_stream.go @@ -0,0 +1,196 @@ +package utils + +import ( + "bytes" + "context" + "io" + "time" + + "github.com/maximhq/bifrost/core/schemas" + "github.com/valyala/fasthttp" +) + +// PassthroughStreamParams configures StreamPassthrough. +type PassthroughStreamParams struct { + StatusCode int + Headers map[string]string + Path string + // RawRequest is attached to the final chunk only (the streaming accumulator reads it there). + RawRequest []byte + // CancellationBody is forwarded to cancellation/timeout handlers. + CancellationBody []byte + StartTime time.Time + // UseTerminalDetector finalizes the stream early when a terminal marker (finishReason) + // appears in a framed event — for providers (Gemini/Vertex) that emit it before the HTTP + // body closes. + UseTerminalDetector bool + Logger schemas.Logger + // HasUsage is an optional cheap gjson presence check: it returns true only when an event + // carries a usage field worth parsing. When set, Observe is skipped (no full unmarshal) for + // events that fail it — the common case on long streams (content deltas, pings). When nil, + // every event is passed to Observe. + HasUsage func(event []byte) bool + // Observe is called once per complete SSE data event (JSON payload) as it streams, and + // returns the running usage (nil when the event adds nothing). The last non-nil value is + // attached to the final chunk. Implementations populate usage directly from the event — + // no full response body is retained. + Observe func(event []byte) *schemas.BifrostPassthroughUsage +} + +// StreamPassthrough runs the shared passthrough streaming loop. It forwards each raw upstream +// chunk to the client unchanged (byte-exact, unbounded — forwarding never depends on usage +// parsing), and in parallel frames complete SSE events into a bounded buffer, feeding each to +// params.Observe to build usage incrementally. On a terminal marker or EOF it emits the final +// chunk carrying RawRequest + the observed usage. No full response body is accumulated. +// +// This owns the idle-timeout wrapper, cancellation hookup, response release, and goroutine. +func StreamPassthrough( + ctx *schemas.BifrostContext, + postHookRunner schemas.PostHookRunner, + postHookSpanFinalizer func(context.Context), + resp *fasthttp.Response, + rawBodyStream io.Reader, + params PassthroughStreamParams, +) chan *schemas.BifrostStreamChunk { + // Wrap reader with idle timeout to detect stalled streams. + bodyStream, stopIdleTimeout := NewIdleTimeoutReader(rawBodyStream, rawBodyStream, GetStreamIdleTimeout(ctx), ctx) + // Cancellation must close the raw stream to unblock reads. + stopCancellation := SetupStreamCancellation(ctx, rawBodyStream, params.Logger) + + extraFields := schemas.BifrostResponseExtraFields{ + ProviderResponseHeaders: params.Headers, + PassthroughPath: params.Path, + } + + ch := make(chan *schemas.BifrostStreamChunk, schemas.DefaultStreamBufferSize) + go func() { + defer EnsureStreamFinalizerCalled(ctx, postHookSpanFinalizer) + defer func() { + if ctx.Err() == context.Canceled { + HandleStreamCancellation(ctx, postHookRunner, ch, params.Logger, postHookSpanFinalizer, params.CancellationBody) + } else if ctx.Err() == context.DeadlineExceeded { + HandleStreamTimeout(ctx, postHookRunner, ch, params.Logger, postHookSpanFinalizer, params.CancellationBody) + } + close(ch) + }() + defer ReleaseStreamingResponse(ctx, resp) + defer stopIdleTimeout() + defer stopCancellation() + + var pending bytes.Buffer + var usage *schemas.BifrostPassthroughUsage + + success := params.StatusCode >= 200 && params.StatusCode < 300 + + observe := func(payload []byte) (terminal bool) { + if len(bytes.TrimSpace(payload)) == 0 { + return false + } + // Cheap gjson gate: only fully parse events that actually carry usage. + if success && params.Observe != nil && (params.HasUsage == nil || params.HasUsage(payload)) { + if u := params.Observe(payload); u != nil { + usage = u + } + } + return params.UseTerminalDetector && isTerminalSSEPayload(payload) + } + + // drainFrames extracts every complete SSE event currently buffered and observes each. + // Returns true when a terminal event is seen. + drainFrames := func() bool { + for { + data := pending.Bytes() + idx, delimLen := findFirstSSEFrameDelimiter(data) + if idx < 0 { + break + } + frame := append([]byte(nil), data[:idx]...) + pending.Next(idx + delimLen) + if observe(extractSSEDataPayload(frame)) { + return true + } + } + // Bound the buffer. A single SSE event can be large — e.g. an + // image_generation.completed event carries the full base64 image with `usage` at + // its tail, so the whole event must be buffered to read usage. Match the native SSE + // scanner's per-line ceiling (sseMaxBufSize). Only when an undelimited event exceeds + // that do we drop to the last frame boundary (or reset) to stay bounded. + if pending.Len() > sseMaxBufSize { + drain := pending.Bytes() + if idx, delimLen := findLastSSEFrameDelimiter(drain); idx >= 0 { + pending.Next(idx + delimLen) + } else { + pending.Reset() + } + } + return false + } + + finalize := func() { + ctx.SetValue(schemas.BifrostContextKeyStreamEndIndicator, true) + extraFields.Latency = time.Since(params.StartTime).Milliseconds() + extraFields.RawRequest = params.RawRequest + ProcessAndSendResponse(ctx, postHookRunner, &schemas.BifrostResponse{ + PassthroughResponse: &schemas.BifrostPassthroughResponse{ + StatusCode: params.StatusCode, + Headers: params.Headers, + ExtraFields: extraFields, + PassthroughUsage: usage, + }, + }, ch, postHookSpanFinalizer) + } + + buf := make([]byte, 4096) + for { + n, readErr := bodyStream.Read(buf) + if n > 0 { + chunk := make([]byte, n) + copy(chunk, buf[:n]) + // Forward the raw chunk to the client unchanged. + ProcessAndSendResponse(ctx, postHookRunner, &schemas.BifrostResponse{ + PassthroughResponse: &schemas.BifrostPassthroughResponse{ + StatusCode: params.StatusCode, + Headers: params.Headers, + Body: chunk, + ExtraFields: extraFields, + }, + }, ch, postHookSpanFinalizer) + + pending.Write(chunk) + if drainFrames() { + finalize() + return + } + } + if readErr == io.EOF { + // Flush a trailing event not terminated by a delimiter. + if pending.Len() > 0 { + observe(extractSSEDataPayload(pending.Bytes())) + } + finalize() + return + } + if readErr != nil { + if ctx.Err() != nil { + return // let defer handle cancel/timeout + } + ctx.SetValue(schemas.BifrostContextKeyStreamEndIndicator, true) + extraFields.Latency = time.Since(params.StartTime).Milliseconds() + ProcessAndSendError(ctx, postHookRunner, readErr, ch, params.Logger, postHookSpanFinalizer) + return + } + } + }() + + return ch +} + +// isTerminalSSEPayload reports whether a framed SSE data payload signals stream completion +// via a finishReason/usage terminal marker. ([DONE] is handled by the SSE readers as EOF.) +func isTerminalSSEPayload(payload []byte) bool { + p := bytes.TrimSpace(payload) + if len(p) == 0 { + return false + } + return hasFinishReasonMarker(p) +} diff --git a/core/providers/utils/streamingclient_test.go b/core/providers/utils/streamingclient_test.go index 0ed78786753..2661b44ea25 100644 --- a/core/providers/utils/streamingclient_test.go +++ b/core/providers/utils/streamingclient_test.go @@ -24,7 +24,7 @@ func TestBuildStreamingClient_ZerosReadWriteTimeout(t *testing.T) { MaxConnWaitTimeout: 15 * time.Second, MaxConnsPerHost: 123, } - ConfigureDialer(base) + ConfigureDialer(base, false) stream := BuildStreamingClient(base) @@ -94,7 +94,7 @@ func TestBuildStreamingClient_LongStreamSurvives(t *testing.T) { ReadTimeout: 1 * time.Second, // would abort the stream without the fix WriteTimeout: 1 * time.Second, } - ConfigureDialer(base) + ConfigureDialer(base, false) stream := BuildStreamingClient(base) req := fasthttp.AcquireRequest() diff --git a/core/providers/utils/utils.go b/core/providers/utils/utils.go index 854a895bcf5..24e368e12a1 100644 --- a/core/providers/utils/utils.go +++ b/core/providers/utils/utils.go @@ -3,6 +3,7 @@ package utils import ( + "bufio" "bytes" "compress/gzip" "context" @@ -252,7 +253,7 @@ func ConfigureRetry(client *fasthttp.Client) *fasthttp.Client { // // Dead connections are detected within ~25s (10 + 5*3), before the 30s // MaxIdleConnDuration expires and the connection is reused. -func ConfigureDialer(client *fasthttp.Client) *fasthttp.Client { +func ConfigureDialer(client *fasthttp.Client, allowPrivateNetwork bool) *fasthttp.Client { // Configure stale-connection retry policy client.RetryIfErr = network.StaleConnectionRetryIfErr @@ -278,10 +279,53 @@ func ConfigureDialer(client *fasthttp.Client) *fasthttp.Client { // Preserve dial-timeout behavior conn, err = existingDialTimeout(addr, client.ReadTimeout) default: - conn, err = (&net.Dialer{ + // resolve DNS ourselves, reject private IPs, then dial + // the IP literal directly — closes the DNS rebinding window that exists + // between ValidateExternalURL (save time) and this connection. + host, port, splitErr := net.SplitHostPort(addr) + if splitErr != nil { + return nil, splitErr + } + // Bound DNS resolution with the same timeout that governs the TCP + resolveCtx := context.Background() + if client.ReadTimeout > 0 { + var cancel context.CancelFunc + resolveCtx, cancel = context.WithTimeout(resolveCtx, client.ReadTimeout) + defer cancel() + } + ips, resolveErr := net.DefaultResolver.LookupIP(resolveCtx, "ip", host) + if resolveErr != nil { + return nil, resolveErr + } + dialer := &net.Dialer{ Timeout: client.ReadTimeout, KeepAliveConfig: keepAliveCfg, - }).Dial("tcp", addr) + } + var lastErr error + for _, ip := range ips { + // Unspecified (0.0.0.0, ::) and link-local (169.254.x.x, fe80::) are always blocked + if ip.IsUnspecified() { + return nil, fmt.Errorf("connection to unspecified IP %s is not allowed", ip) + } + if network.IsLinkLocal(ip) { + return nil, fmt.Errorf("connection to link-local IP %s is not allowed", ip) + } + // RFC 1918 blocked unless operator explicitly opted in; loopback always allowed + if !ip.IsLoopback() && !allowPrivateNetwork && network.IsPrivateIP(ip) { + return nil, fmt.Errorf("connection to private IP %s is not allowed", ip) + } + conn, err = dialer.Dial("tcp", net.JoinHostPort(ip.String(), port)) + if err == nil { + break + } + lastErr = err + } + if conn == nil { + if lastErr != nil { + return nil, lastErr + } + return nil, fmt.Errorf("no usable address resolved for %s", host) + } } if err != nil { return nil, err @@ -499,6 +543,7 @@ func filterHeaders(headers map[string][]string) map[string][]string { var providerResponseFilterHeaders = map[string]bool{ "content-length": true, "content-encoding": true, + "content-type": true, "transfer-encoding": true, "connection": true, "keep-alive": true, @@ -506,6 +551,9 @@ var providerResponseFilterHeaders = map[string]bool{ "proxy-authenticate": true, "proxy-authorization": true, "authorization": true, + "x-goog-api-key": true, + "x-api-key": true, + "api-key": true, "cookie": true, "set-cookie": true, "set-cookie2": true, @@ -518,7 +566,6 @@ var providerResponseFilterHeaders = map[string]bool{ "server": true, "alt-svc": true, "strict-transport-security": true, - "content-type": true, "access-control-allow-origin": true, "access-control-allow-methods": true, "access-control-allow-headers": true, @@ -552,6 +599,32 @@ func ExtractProviderResponseHeaders(resp *fasthttp.Response) map[string]string { return headers } +// ExtractPassthroughProviderResponseHeaders extracts and filters response headers from a +// fasthttp response. Transport-level headers are excluded. +func ExtractPassthroughProviderResponseHeaders(resp *fasthttp.Response) map[string]string { + if resp == nil { + return nil + } + headers := make(map[string]string) + resp.Header.VisitAll(func(key, value []byte) { + k := string(key) + kLower := strings.ToLower(k) + if providerResponseFilterHeaders[kLower] && kLower != "content-type" { + return + } + v := string(value) + if existing, ok := headers[k]; ok && existing != "" { + headers[k] = existing + ", " + v + } else { + headers[k] = v + } + }) + if len(headers) == 0 { + return nil + } + return headers +} + // ExtractProviderResponseHeadersFromHTTP extracts and filters response headers // from a standard net/http response. Transport-level headers are excluded. // Used by providers like Bedrock that use net/http instead of fasthttp. @@ -993,21 +1066,55 @@ func DecompressStreamBody(resp *fasthttp.Response) (io.Reader, func()) { } } -// DrainNonSSEStreamResponse checks if the upstream response is a Server-Sent Events stream. -// If not SSE, drains the body to io.Discard to prevent bufio.Scanner buffer bloat on -// non-line-delimited data. Returns true if body was drained (caller should skip scanner). -// We intentionally do not touch valid SSE bodies here: callers must continue reading from -// the reader returned by DecompressStreamBody, and draining SSE in this helper would consume -// the stream before the scanner/manual event loop starts. -func DrainNonSSEStreamResponse(resp *fasthttp.Response) bool { +// Some OpenAI-compatible backends return valid SSE frames without Content-Type: +// text/event-stream. In that case, peek at the first field prefix without consuming +// it so the downstream SSE parser sees the full stream. +func DrainNonSSEStreamReader(resp *fasthttp.Response, reader io.Reader) (io.Reader, bool) { ct := strings.ToLower(string(resp.Header.ContentType())) if strings.Contains(ct, "text/event-stream") { + return reader, false + } + if reader == nil { + return nil, true + } + + br := bufio.NewReaderSize(reader, sseInitialBufSize) + if hasSSEPrefix(br) { + return br, false + } + + _, _ = io.Copy(io.Discard, br) + return nil, true +} + +func hasSSEPrefix(reader *bufio.Reader) bool { + first, err := reader.Peek(1) + if err != nil || len(first) == 0 { return false } - if bodyStream := resp.BodyStream(); bodyStream != nil { - _, _ = io.Copy(io.Discard, bodyStream) + switch first[0] { + case ':', '\n', '\r': + return true + case 'd': + return peekHasPrefix(reader, []byte("data:")) + case 'e': + return peekHasPrefix(reader, []byte("event:")) + case 'i': + return peekHasPrefix(reader, []byte("id:")) + case 'r': + return peekHasPrefix(reader, []byte("retry:")) + default: + return false } - return true +} + +func peekHasPrefix(reader *bufio.Reader, prefix []byte) bool { + n := min(reader.Buffered(), len(prefix)) + if n == 0 { + return false + } + peeked, err := reader.Peek(n) + return err == nil && bytes.Equal(peeked, prefix[:n]) } // MergeExtraParams merges extraParams into jsonMap, handling nested maps recursively. @@ -2941,8 +3048,8 @@ func completeDeferredSpan(ctx *schemas.BifrostContext, result *schemas.BifrostRe // CheckAndSetDefaultProvider checks if the default provider should be used based on the context. // It returns the default provider if it should be used, otherwise it returns an empty string. -// Checks if key selection is skipped, or if the available providers are set in the context -// and the default provider is in the list. +// Checks if key selection is skipped, if a resolved provider was selected by routing, +// or if the available providers are set in the context and the default provider is in the list. func CheckAndSetDefaultProvider(ctx *schemas.BifrostContext, defaultProvider schemas.ModelProvider) schemas.ModelProvider { if ctx != nil { if skip, ok := ctx.Value(schemas.BifrostContextKeySkipKeySelection).(bool); ok && skip { @@ -2953,6 +3060,10 @@ func CheckAndSetDefaultProvider(ctx *schemas.BifrostContext, defaultProvider sch if !ok || len(availableProviders) == 0 { return "" } + if resolvedProvider, ok := ctx.Value(schemas.BifrostContextKeyResolvedProvider).(schemas.ModelProvider); ok && slices.Contains(availableProviders, resolvedProvider) { + getLogger().Debug("[Provider] Using routing-resolved provider: %s (available: %v)", resolvedProvider, availableProviders) + return resolvedProvider + } getLogger().Debug("[Provider] Available providers: %v, checking %s", availableProviders, defaultProvider) if slices.Contains(availableProviders, defaultProvider) { return defaultProvider diff --git a/core/providers/utils/utils_test.go b/core/providers/utils/utils_test.go index a5d6ae7d0cb..66db0162716 100644 --- a/core/providers/utils/utils_test.go +++ b/core/providers/utils/utils_test.go @@ -7,8 +7,11 @@ import ( "encoding/json" "fmt" "io" + "net/http" + "net/http/httptest" "strings" "testing" + "time" "github.com/bytedance/sonic" "github.com/maximhq/bifrost/core/schemas" @@ -726,20 +729,17 @@ func TestCheckAndDecodeBody_Concurrent(t *testing.T) { } } -func TestDrainNonSSEStreamResponse_SSEDoesNotDrain(t *testing.T) { +func TestDrainNonSSEStreamReader_SSEWithoutContentTypeStillReadable(t *testing.T) { resp := fasthttp.AcquireResponse() defer fasthttp.ReleaseResponse(resp) - body := []byte("data: hello\n\n") - resp.Header.SetContentType("text/event-stream") - resp.SetBodyStream(bytes.NewReader(body), len(body)) - - drained := DrainNonSSEStreamResponse(resp) + body := []byte("event: response.created\n\ndata: {\"type\":\"response.completed\"}\n\n") + reader, drained := DrainNonSSEStreamReader(resp, bytes.NewReader(body)) if drained { - t.Fatal("expected SSE response to remain readable") + t.Fatal("expected SSE-looking response without content type to remain readable") } - remaining, err := io.ReadAll(resp.BodyStream()) + remaining, err := io.ReadAll(reader) if err != nil { t.Fatalf("failed to read SSE body after guard: %v", err) } @@ -748,52 +748,300 @@ func TestDrainNonSSEStreamResponse_SSEDoesNotDrain(t *testing.T) { } } -func TestDrainNonSSEStreamResponse_NonSSEDrains(t *testing.T) { +func TestDrainNonSSEStreamReader_GzipSSEWithoutContentTypeStillReadable(t *testing.T) { resp := fasthttp.AcquireResponse() defer fasthttp.ReleaseResponse(resp) - body := []byte(`{"error":"not stream"}`) - resp.Header.SetContentType("application/json") - resp.SetBodyStream(bytes.NewReader(body), len(body)) + body := []byte("data: {\"type\":\"response.completed\"}\n\n") + compressed := gzipCompress(body) + resp.Header.Set("Content-Encoding", "gzip") + resp.SetBodyStream(bytes.NewReader(compressed), len(compressed)) - drained := DrainNonSSEStreamResponse(resp) - if !drained { - t.Fatal("expected non-SSE response to be drained") + decompressed, releaseGzip := DecompressStreamBody(resp) + defer releaseGzip() + + reader, drained := DrainNonSSEStreamReader(resp, decompressed) + if drained { + t.Fatal("expected decompressed SSE-looking response without content type to remain readable") } - remaining, err := io.ReadAll(resp.BodyStream()) + remaining, err := io.ReadAll(reader) if err != nil { - t.Fatalf("failed to read body after drain: %v", err) + t.Fatalf("failed to read decompressed SSE body after guard: %v", err) } - if len(remaining) != 0 { - t.Fatalf("expected drained body to be empty, got %q", string(remaining)) + if string(remaining) != string(body) { + t.Fatalf("expected decompressed SSE body %q, got %q", string(body), string(remaining)) } } -func TestDrainNonSSEStreamResponse_GzipSSEStillReadable(t *testing.T) { +func TestDrainNonSSEStreamReader_JSONWithoutContentTypeDrains(t *testing.T) { resp := fasthttp.AcquireResponse() defer fasthttp.ReleaseResponse(resp) - body := []byte("data: hello\n\ndata: [DONE]\n\n") - compressed := gzipCompress(body) - resp.Header.SetContentType("text/event-stream") - resp.Header.Set("Content-Encoding", "gzip") - resp.SetBodyStream(bytes.NewReader(compressed), len(compressed)) + body := []byte(`{"error":"not stream"}`) + reader, drained := DrainNonSSEStreamReader(resp, bytes.NewReader(body)) + if !drained { + t.Fatal("expected JSON response without content type to be drained") + } + if reader != nil { + t.Fatal("expected drained response to return nil reader") + } +} + +func TestDrainNonSSEStreamReader_UppercaseSSEPrefixDrains(t *testing.T) { + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseResponse(resp) + + body := []byte("DATA: {\"type\":\"response.completed\"}\n\n") + reader, drained := DrainNonSSEStreamReader(resp, bytes.NewReader(body)) + if !drained { + t.Fatal("expected uppercase SSE-like prefix to be treated as non-SSE") + } + if reader != nil { + t.Fatal("expected drained response to return nil reader") + } +} + +func TestDrainNonSSEStreamReader_ShortReadSSEPrefix(t *testing.T) { + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseResponse(resp) - drained := DrainNonSSEStreamResponse(resp) + body := []byte("event: response.created\n\ndata: {}\n\n") + reader, drained := DrainNonSSEStreamReader(resp, &shortReadReader{data: body, chunkSize: 3}) if drained { - t.Fatal("expected gzip SSE response to remain readable") + t.Fatal("expected SSE stream with short-read prefix to remain readable") + } + + remaining, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("failed to read after short-read guard: %v", err) + } + if string(remaining) != string(body) { + t.Fatalf("expected body %q, got %q", body, remaining) + } +} + +func TestDrainNonSSEStreamReader_TinyOpenSSEPrefixReturnsPromptly(t *testing.T) { + tests := []struct { + name string + fragment string + }{ + {name: "comment", fragment: ":\n\n"}, + {name: "id field", fragment: "id:"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseResponse(resp) + + pr, pw := io.Pipe() + defer pr.Close() + + writeErr := make(chan error, 1) + go func() { + _, err := pw.Write([]byte(tt.fragment)) + writeErr <- err + }() + + result := make(chan struct { + reader io.Reader + drained bool + }, 1) + go func() { + reader, drained := DrainNonSSEStreamReader(resp, pr) + result <- struct { + reader io.Reader + drained bool + }{reader: reader, drained: drained} + }() + + var got struct { + reader io.Reader + drained bool + } + select { + case got = <-result: + case <-time.After(200 * time.Millisecond): + _ = pw.Close() + t.Fatal("DrainNonSSEStreamReader blocked waiting for a larger prefix") + } + + if got.drained { + _ = pw.Close() + t.Fatal("expected tiny SSE prefix to remain readable") + } + + preserved := make([]byte, len(tt.fragment)) + if _, err := io.ReadFull(got.reader, preserved); err != nil { + _ = pw.Close() + t.Fatalf("failed to read preserved SSE prefix: %v", err) + } + if string(preserved) != tt.fragment { + _ = pw.Close() + t.Fatalf("expected preserved prefix %q, got %q", tt.fragment, string(preserved)) + } + + if err := pw.Close(); err != nil { + t.Fatalf("failed to close pipe writer: %v", err) + } + if err := <-writeErr; err != nil { + t.Fatalf("failed to write prefix: %v", err) + } + }) + } +} + +func TestDrainNonSSEStreamReader_FragmentedFieldPrefixReturnsPromptly(t *testing.T) { + tests := []struct { + name string + first string + suffix string + }{ + {name: "data field", first: "d", suffix: "ata: {}\n\n"}, + {name: "event field", first: "e", suffix: "vent: response.completed\n\n"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseResponse(resp) + + pr, pw := io.Pipe() + defer pr.Close() + + firstWriteErr := make(chan error, 1) + go func() { + _, err := pw.Write([]byte(tt.first)) + firstWriteErr <- err + }() + + result := make(chan struct { + reader io.Reader + drained bool + }, 1) + go func() { + reader, drained := DrainNonSSEStreamReader(resp, pr) + result <- struct { + reader io.Reader + drained bool + }{reader: reader, drained: drained} + }() + + var got struct { + reader io.Reader + drained bool + } + select { + case got = <-result: + case <-time.After(200 * time.Millisecond): + _ = pw.Close() + t.Fatal("DrainNonSSEStreamReader blocked waiting for a full SSE field name") + } + if got.drained { + _ = pw.Close() + t.Fatal("expected fragmented SSE field prefix to remain readable") + } + if err := <-firstWriteErr; err != nil { + _ = pw.Close() + t.Fatalf("failed to write first byte: %v", err) + } + + suffixWriteErr := make(chan error, 1) + go func() { + _, err := pw.Write([]byte(tt.suffix)) + if err == nil { + err = pw.Close() + } + suffixWriteErr <- err + }() + + remaining, err := io.ReadAll(got.reader) + if err != nil { + t.Fatalf("failed to read preserved fragmented SSE stream: %v", err) + } + if string(remaining) != tt.first+tt.suffix { + t.Fatalf("expected preserved stream %q, got %q", tt.first+tt.suffix, string(remaining)) + } + if err := <-suffixWriteErr; err != nil { + t.Fatalf("failed to write suffix: %v", err) + } + }) + } +} + +// shortReadReader returns at most chunkSize bytes per Read call, simulating +// a network reader that delivers data in small segments (short reads). +type shortReadReader struct { + data []byte + chunkSize int + pos int +} + +func (r *shortReadReader) Read(p []byte) (int, error) { + if r.pos >= len(r.data) { + return 0, io.EOF + } + end := r.pos + r.chunkSize + if end > len(r.data) { + end = len(r.data) + } + n := copy(p, r.data[r.pos:end]) + r.pos += n + return n, nil +} + +// TestDrainNonSSEStreamReader_CodexNoContentType reproduces the original Codex hang: +// a real HTTP server streams valid SSE events but omits Content-Type: text/event-stream. +// Before the fix, DrainNonSSEStreamResponse would drain the body to /dev/null and the +// client would hang waiting for events that never arrived. +func TestDrainNonSSEStreamReader_CodexNoContentType(t *testing.T) { + const sseBody = "event: response.created\n\ndata: {\"type\":\"response.created\"}\n\nevent: response.completed\n\ndata: {\"type\":\"response.completed\"}\n\n" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Codex backend: valid SSE, but Content-Type is application/json — not text/event-stream. + // Setting it explicitly prevents Go's httptest from auto-detecting text/plain. + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-cache") + w.WriteHeader(http.StatusOK) + flusher, ok := w.(http.Flusher) + fmt.Fprint(w, sseBody) + if ok { + flusher.Flush() + } + })) + defer srv.Close() + + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + req.SetRequestURI(srv.URL) + req.Header.SetMethod(http.MethodGet) + resp.StreamBody = true + + if err := (&fasthttp.Client{}).Do(req, resp); err != nil { + t.Fatalf("request failed: %v", err) + } + if resp.StatusCode() != http.StatusOK { + t.Fatalf("unexpected status: %d", resp.StatusCode()) } + // Mirror the streaming goroutine path in openai.go reader, releaseGzip := DecompressStreamBody(resp) defer releaseGzip() - remaining, err := io.ReadAll(reader) + reader, drained := DrainNonSSEStreamReader(resp, reader) + if drained { + t.Fatal("SSE body without Content-Type was drained — reproduces the original Codex hang") + } + + all, err := io.ReadAll(reader) if err != nil { - t.Fatalf("failed to read decompressed SSE body: %v", err) + t.Fatalf("reading SSE body failed: %v", err) } - if string(remaining) != string(body) { - t.Fatalf("expected decompressed SSE body %q, got %q", string(body), string(remaining)) + if string(all) != sseBody { + t.Fatalf("SSE body corrupted\nwant: %q\n got: %q", sseBody, string(all)) } } @@ -1542,3 +1790,115 @@ func assertRange(t *testing.T, low, high, got int, label string) { t.Errorf("%s: got %d, want in [%d, %d]", label, got, low, high) } } + +// TestExtractProviderResponseHeaders_StripsProviderSecrets verifies that provider auth headers +// Bifrost injects upstream (and some upstreams echo back, e.g. Google's file-download 302) are +// never forwarded to clients via the response header map, while normal headers pass through. +// Regression test for the /genai_passthrough x-goog-api-key leak. +func TestExtractProviderResponseHeaders_StripsProviderSecrets(t *testing.T) { + resp := &fasthttp.Response{} + // Provider secrets that must be stripped (case-insensitive). + resp.Header.Set("x-goog-api-key", "AIzaSyEXAMPLE_SECRET") + resp.Header.Set("X-Api-Key", "sk-ant-secret") + resp.Header.Set("Api-Key", "azure-secret") + resp.Header.Set("Authorization", "Bearer secret-token") + // Benign headers that must be preserved. + resp.Header.Set("x-request-id", "req-123") + + headers := ExtractProviderResponseHeaders(resp) + + // fasthttp canonicalizes header keys, so look these up case-insensitively. + lookup := func(name string) (string, bool) { + for k, v := range headers { + if strings.EqualFold(k, name) { + return v, true + } + } + return "", false + } + + for _, secret := range []string{"x-goog-api-key", "x-api-key", "api-key", "authorization"} { + if _, ok := lookup(secret); ok { + t.Fatalf("provider secret header %q leaked in response headers: %v", secret, headers) + } + } + if v, ok := lookup("x-request-id"); !ok || v != "req-123" { + t.Fatalf("benign header x-request-id was dropped: %v", headers) + } +} + +// TestExtractPassthroughProviderResponseHeaders verifies that the passthrough +// variant preserves content-type while still blocking transport headers and +// provider secrets. Regression guard for the providerResponseFilterHeaders[kLower] && +// kLower != "content-type" carve-out. +func TestExtractPassthroughProviderResponseHeaders(t *testing.T) { + resp := &fasthttp.Response{} + // content-type must be forwarded. + resp.Header.Set("Content-Type", "application/json") + // transport headers that must still be stripped. + resp.Header.Set("Content-Encoding", "gzip") + resp.Header.Set("Transfer-Encoding", "chunked") + resp.Header.Set("Content-Length", "42") + // provider secrets that must still be stripped. + resp.Header.Set("x-goog-api-key", "AIzaSyEXAMPLE_SECRET") + resp.Header.Set("X-Api-Key", "sk-secret") + resp.Header.Set("Authorization", "Bearer token") + // benign headers that must be preserved. + resp.Header.Set("x-request-id", "req-456") + + headers := ExtractPassthroughProviderResponseHeaders(resp) + + lookup := func(name string) (string, bool) { + for k, v := range headers { + if strings.EqualFold(k, name) { + return v, true + } + } + return "", false + } + + // content-type must pass through. + if v, ok := lookup("content-type"); !ok || v != "application/json" { + t.Fatalf("content-type should be forwarded by passthrough extractor, got %q ok=%v", v, ok) + } + // transport headers must still be stripped. + for _, stripped := range []string{"content-encoding", "transfer-encoding", "content-length"} { + if _, ok := lookup(stripped); ok { + t.Fatalf("transport header %q should be stripped by passthrough extractor", stripped) + } + } + // provider secrets must still be stripped. + for _, secret := range []string{"x-goog-api-key", "x-api-key", "authorization"} { + if _, ok := lookup(secret); ok { + t.Fatalf("provider secret %q should be stripped by passthrough extractor", secret) + } + } + // benign header must pass through. + if v, ok := lookup("x-request-id"); !ok || v != "req-456" { + t.Fatalf("benign header x-request-id was dropped: %v", headers) + } +} + +// TestCheckAndSetDefaultProviderUsesResolvedProvider verifies routing-selected +// providers take precedence over the route default when still allowed. +func TestCheckAndSetDefaultProviderUsesResolvedProvider(t *testing.T) { + ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + ctx.SetValue(schemas.BifrostContextKeyAvailableProviders, []schemas.ModelProvider{schemas.Anthropic, schemas.Azure}) + ctx.SetValue(schemas.BifrostContextKeyResolvedProvider, schemas.Azure) + + if got := CheckAndSetDefaultProvider(ctx, schemas.Anthropic); got != schemas.Azure { + t.Fatalf("CheckAndSetDefaultProvider() = %s, want %s", got, schemas.Azure) + } +} + +// TestCheckAndSetDefaultProviderIgnoresDisallowedResolvedProvider verifies +// selected-provider context cannot bypass available-provider constraints. +func TestCheckAndSetDefaultProviderIgnoresDisallowedResolvedProvider(t *testing.T) { + ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + ctx.SetValue(schemas.BifrostContextKeyAvailableProviders, []schemas.ModelProvider{schemas.Anthropic}) + ctx.SetValue(schemas.BifrostContextKeyResolvedProvider, schemas.Azure) + + if got := CheckAndSetDefaultProvider(ctx, schemas.Anthropic); got != schemas.Anthropic { + t.Fatalf("CheckAndSetDefaultProvider() = %s, want %s", got, schemas.Anthropic) + } +} diff --git a/core/providers/vertex/vertex.go b/core/providers/vertex/vertex.go index 26e978b2636..8b89a6fb073 100644 --- a/core/providers/vertex/vertex.go +++ b/core/providers/vertex/vertex.go @@ -7,7 +7,6 @@ import ( "encoding/hex" "errors" "fmt" - "io" "net/http" "net/url" "regexp" @@ -106,7 +105,7 @@ func NewVertexProvider(config *schemas.ProviderConfig, logger schemas.Logger) (* ConnPoolStrategy: fasthttp.FIFO, } client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) - client = providerUtils.ConfigureDialer(client) + client = providerUtils.ConfigureDialer(client, config.NetworkConfig.AllowPrivateNetwork) client = providerUtils.ConfigureTLS(client, config.NetworkConfig, logger) streamingClient := providerUtils.BuildStreamingClient(client) return &VertexProvider{ @@ -3025,7 +3024,7 @@ func (provider *VertexProvider) Passthrough( removeVertexClient(key.VertexKeyConfig.AuthCredentials.GetValue()) } - headers := providerUtils.ExtractProviderResponseHeaders(resp) + headers := providerUtils.ExtractPassthroughProviderResponseHeaders(resp) ctx.SetValue(schemas.BifrostContextKeyProviderResponseHeaders, headers) body, err := providerUtils.CheckAndDecodeBody(resp) @@ -3033,6 +3032,11 @@ func (provider *VertexProvider) Passthrough( return nil, providerUtils.NewBifrostOperationError("failed to decode response body", err) } + var passthroughUsage *schemas.BifrostPassthroughUsage + if resp.StatusCode() >= 200 && resp.StatusCode() < 300 { + passthroughUsage = gemini.ExtractGeminiPassthroughUsage(req.Path, req.Body, body) + } + bifrostResponse := &schemas.BifrostPassthroughResponse{ StatusCode: resp.StatusCode(), Headers: headers, @@ -3040,7 +3044,10 @@ func (provider *VertexProvider) Passthrough( ExtraFields: schemas.BifrostResponseExtraFields{ Latency: latency.Milliseconds(), ProviderResponseHeaders: headers, + PassthroughPath: req.Path, + RawRequest: req.Body, }, + PassthroughUsage: passthroughUsage, } return bifrostResponse, nil @@ -3169,7 +3176,7 @@ func (provider *VertexProvider) PassthroughStream( removeVertexClient(key.VertexKeyConfig.AuthCredentials.GetValue()) } - headers := providerUtils.ExtractProviderResponseHeaders(resp) + headers := providerUtils.ExtractPassthroughProviderResponseHeaders(resp) ctx.SetValue(schemas.BifrostContextKeyProviderResponseHeaders, headers) bodyStream := resp.BodyStream() @@ -3180,91 +3187,22 @@ func (provider *VertexProvider) PassthroughStream( fmt.Errorf("provider returned an empty stream body")) } - // Set stream idle timeout from provider config. providerUtils.SetStreamIdleTimeoutIfEmpty(ctx, provider.networkConfig.StreamIdleTimeoutInSeconds) - - // Wrap body with idle timeout to detect stalled streams. - rawBodyStream := bodyStream - bodyStream, stopIdleTimeout := providerUtils.NewIdleTimeoutReader(bodyStream, rawBodyStream, providerUtils.GetStreamIdleTimeout(ctx), ctx) - - // Cancellation must close the raw stream to unblock reads. - stopCancellation := providerUtils.SetupStreamCancellation(ctx, rawBodyStream, provider.logger) - - extraFields := schemas.BifrostResponseExtraFields{} - statusCode := resp.StatusCode() - - ch := make(chan *schemas.BifrostStreamChunk, schemas.DefaultStreamBufferSize) - go func() { - defer providerUtils.EnsureStreamFinalizerCalled(ctx, postHookSpanFinalizer) - defer func() { - if ctx.Err() == context.Canceled { - providerUtils.HandleStreamCancellation(ctx, postHookRunner, ch, provider.logger, postHookSpanFinalizer, providerUtils.PassthroughJSONBody(fasthttpReq, req.Body)) - } else if ctx.Err() == context.DeadlineExceeded { - providerUtils.HandleStreamTimeout(ctx, postHookRunner, ch, provider.logger, postHookSpanFinalizer, providerUtils.PassthroughJSONBody(fasthttpReq, req.Body)) - } - close(ch) - }() - defer providerUtils.ReleaseStreamingResponse(ctx, resp) - defer stopIdleTimeout() - defer stopCancellation() - streamStart := time.Now() - - terminalDetector := &providerUtils.StreamTerminalDetector{} - buf := make([]byte, 4096) - for { - n, readErr := bodyStream.Read(buf) - if n > 0 { - chunk := make([]byte, n) - copy(chunk, buf[:n]) - providerUtils.ProcessAndSendResponse(ctx, postHookRunner, &schemas.BifrostResponse{ - PassthroughResponse: &schemas.BifrostPassthroughResponse{ - StatusCode: statusCode, - Headers: headers, - Body: chunk, - ExtraFields: extraFields, - }, - }, ch, postHookSpanFinalizer) - - // Vertex streamGenerateContent passthrough can emit terminal markers - // (finishReason) before the underlying HTTP body is closed. - // Finalize as success once this appears to avoid hanging clients. - if terminalDetector.ObserveChunk(chunk) { - ctx.SetValue(schemas.BifrostContextKeyStreamEndIndicator, true) - extraFields.Latency = time.Since(streamStart).Milliseconds() - providerUtils.ProcessAndSendResponse(ctx, postHookRunner, &schemas.BifrostResponse{ - PassthroughResponse: &schemas.BifrostPassthroughResponse{ - StatusCode: statusCode, - Headers: headers, - ExtraFields: extraFields, - }, - }, ch, postHookSpanFinalizer) - return - } - } - if readErr == io.EOF { - ctx.SetValue(schemas.BifrostContextKeyStreamEndIndicator, true) - extraFields.Latency = time.Since(streamStart).Milliseconds() - providerUtils.ProcessAndSendResponse(ctx, postHookRunner, &schemas.BifrostResponse{ - PassthroughResponse: &schemas.BifrostPassthroughResponse{ - StatusCode: statusCode, - Headers: headers, - ExtraFields: extraFields, - }, - }, ch, postHookSpanFinalizer) - return - } - if readErr != nil { - if ctx.Err() != nil { - return // let defer handle cancel/timeout - } - if readErr != io.EOF { - ctx.SetValue(schemas.BifrostContextKeyStreamEndIndicator, true) - extraFields.Latency = time.Since(streamStart).Milliseconds() - providerUtils.ProcessAndSendError(ctx, postHookRunner, readErr, ch, provider.logger, postHookSpanFinalizer) - } - return - } - } - }() - return ch, nil + return providerUtils.StreamPassthrough( + ctx, postHookRunner, postHookSpanFinalizer, resp, bodyStream, + providerUtils.PassthroughStreamParams{ + StatusCode: resp.StatusCode(), + Headers: headers, + Path: req.Path, + RawRequest: req.Body, + CancellationBody: providerUtils.PassthroughJSONBody(fasthttpReq, req.Body), + StartTime: time.Now(), + UseTerminalDetector: true, + Logger: provider.logger, + HasUsage: gemini.HasGeminiPassthroughUsage, + Observe: func(event []byte) *schemas.BifrostPassthroughUsage { + return gemini.ExtractGeminiPassthroughUsage(req.Path, req.Body, event) + }, + }, + ), nil } diff --git a/core/providers/vllm/vllm.go b/core/providers/vllm/vllm.go index 399c1bdc011..2b95ac15bc2 100644 --- a/core/providers/vllm/vllm.go +++ b/core/providers/vllm/vllm.go @@ -43,7 +43,7 @@ func NewVLLMProvider(config *schemas.ProviderConfig, logger schemas.Logger) (*VL } client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) - client = providerUtils.ConfigureDialer(client) + client = providerUtils.ConfigureDialer(client, config.NetworkConfig.AllowPrivateNetwork) client = providerUtils.ConfigureTLS(client, config.NetworkConfig, logger) streamingClient := providerUtils.BuildStreamingClient(client) config.NetworkConfig.BaseURL = strings.TrimRight(config.NetworkConfig.BaseURL, "/") diff --git a/core/providers/xai/xai.go b/core/providers/xai/xai.go index 1c9d777a974..cb093dfffdc 100644 --- a/core/providers/xai/xai.go +++ b/core/providers/xai/xai.go @@ -42,7 +42,7 @@ func NewXAIProvider(config *schemas.ProviderConfig, logger schemas.Logger) (*XAI // Configure proxy and retry policy client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) - client = providerUtils.ConfigureDialer(client) + client = providerUtils.ConfigureDialer(client, config.NetworkConfig.AllowPrivateNetwork) client = providerUtils.ConfigureTLS(client, config.NetworkConfig, logger) streamingClient := providerUtils.BuildStreamingClient(client) config.NetworkConfig.BaseURL = strings.TrimRight(config.NetworkConfig.BaseURL, "/") diff --git a/core/schemas/bifrost.go b/core/schemas/bifrost.go index fadfa088122..24f81f72fef 100644 --- a/core/schemas/bifrost.go +++ b/core/schemas/bifrost.go @@ -257,6 +257,7 @@ const ( BifrostContextKeyPromptsPluginName BifrostContextKey = "prompts-plugin-name" // string (name of the prompts plugin to use - set by bifrost - DO NOT SET THIS MANUALLY)) BifrostContextKeyIsEnterprise BifrostContextKey = "is-enterprise" // bool (set by bifrost - DO NOT SET THIS MANUALLY)) BifrostContextKeyAvailableProviders BifrostContextKey = "available-providers" // []ModelProvider (set by bifrost - DO NOT SET THIS MANUALLY)) + BifrostContextKeyResolvedProvider BifrostContextKey = "bifrost-resolved-provider" // ModelProvider (set by routing - DO NOT SET THIS MANUALLY)) BifrostContextKeyStoreRawRequestResponse BifrostContextKey = "bifrost-store-raw-request-response" // bool (per-request override — read by bifrost.go, never overwritten) BifrostContextKeyCaptureRawRequest BifrostContextKey = "bifrost-capture-raw-request" // bool (set by bifrost - DO NOT SET THIS MANUALLY) — true when providers should capture raw request bytes BifrostContextKeyCaptureRawResponse BifrostContextKey = "bifrost-capture-raw-response" // bool (set by bifrost - DO NOT SET THIS MANUALLY) — true when providers should capture raw response bytes @@ -1432,6 +1433,7 @@ type BifrostResponseExtraFields struct { ConvertedRequestType RequestType `json:"converted_request_type,omitempty"` DroppedCompatPluginParams []string `json:"dropped_compat_plugin_params,omitempty"` // params dropped by the compat plugin based on model catalog ProviderResponseHeaders map[string]string `json:"provider_response_headers,omitempty"` // HTTP response headers from the provider (filtered to exclude transport-level headers) + PassthroughPath string `json:"passthrough_path,omitempty"` // Stripped provider path for passthrough requests, e.g. "/v1/chat/completions" } type BifrostMCPResponseExtraFields struct { diff --git a/core/schemas/headers.go b/core/schemas/headers.go new file mode 100644 index 00000000000..dc9d8ffde6b --- /dev/null +++ b/core/schemas/headers.go @@ -0,0 +1,43 @@ +package schemas + +import "strings" + +// MatchHeaderPattern reports whether a lowercased header name matches a pattern. +// Supports exact match, "*" (all), and a single trailing-wildcard prefix (e.g. "x-custom-*"). +// The pattern is trimmed and lowercased before comparison; the header name is expected to +// already be lowercased by the caller. +func MatchHeaderPattern(headerName, pattern string) bool { + pattern = strings.ToLower(strings.TrimSpace(pattern)) + if pattern == "" { + return false + } + if pattern == "*" { + return true + } + if prefix, ok := strings.CutSuffix(pattern, "*"); ok { + return strings.HasPrefix(headerName, prefix) + } + return headerName == pattern +} + +// FilterHeaders returns the subset of headers whose (lowercased) keys match any of the +// given patterns (exact name or wildcard like "x-custom-*" or "*"). Header keys are +// expected to already be lowercased by the capture layer. Returns nil when nothing matches. +func FilterHeaders(headers map[string]string, patterns []string) map[string]string { + if len(headers) == 0 || len(patterns) == 0 { + return nil + } + out := make(map[string]string) + for name, value := range headers { + for _, pattern := range patterns { + if MatchHeaderPattern(name, pattern) { + out[name] = value + break + } + } + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/core/schemas/passthrough.go b/core/schemas/passthrough.go index 2da0028301b..ed743da3865 100644 --- a/core/schemas/passthrough.go +++ b/core/schemas/passthrough.go @@ -10,11 +10,42 @@ type BifrostPassthroughRequest struct { SafeHeaders map[string]string // client headers, auth already stripped } +// BifrostPassthroughUsage carries usage data extracted by the provider at stream +// completion. The pricing module converts this into cost using the existing compute +// functions — no new pricing logic is required. +type BifrostPassthroughUsage struct { + // Text / chat / responses / embeddings + LLMUsage *BifrostLLMUsage + ServiceTier *BifrostServiceTier // "priority" | "flex" | nil (default) + + // Image generation / edit / variation + ImageUsage *ImageUsage + ImageSize string // e.g. "1024x1024" + ImageQuality string // "low" | "medium" | "high" | "auto" + + // Speech TTS — character count from request body `input` field + AudioInputChars int + + // Transcription — token details or raw seconds as duration fallback + AudioSeconds *int + AudioTokenDetails *TranscriptionUsageInputTokenDetails + + // Video generation + VideoSeconds *int + + // Container creation (code interpreter session) — synthetic pricing identifier, + // e.g. "container-1g", or "container" when no memory limit is reported. Maps to + // costInput.containerIdentifierString for the flat per-session fee. + ContainerIdentifier string +} + type BifrostPassthroughResponse struct { - StatusCode int - Headers map[string]string - Body []byte - ExtraFields BifrostResponseExtraFields + StatusCode int + Headers map[string]string + Body []byte + ExtraFields BifrostResponseExtraFields + Path string // stripped provider path, e.g. "/v1/chat/completions" + PassthroughUsage *BifrostPassthroughUsage // usage extracted by the provider for billing — set on the unary response (non-streaming) or the final streaming chunk; nil when no billable usage could be extracted } type PassthroughLogParams struct { @@ -22,4 +53,5 @@ type PassthroughLogParams struct { Path string `json:"path"` // stripped path, e.g. "/v1/fine-tuning/jobs" RawQuery string `json:"raw_query"` // raw query string, no "?" StatusCode int `json:"status_code"` + Model string `json:"model,omitempty"` // model extracted from path or request body } diff --git a/core/schemas/provider.go b/core/schemas/provider.go index 4190fb0b828..d2060e8e38d 100644 --- a/core/schemas/provider.go +++ b/core/schemas/provider.go @@ -63,6 +63,7 @@ type NetworkConfig struct { MaxConnsPerHost int `json:"max_conns_per_host,omitempty"` // Max TCP connections per provider host (default: 5000) EnforceHTTP2 bool `json:"enforce_http2,omitempty"` // Force HTTP/2 on provider connections (relevant for net/http-based providers like Bedrock) BetaHeaderOverrides map[string]bool `json:"beta_header_overrides,omitempty"` // Override default beta header support per provider (keys are prefixes like "redact-thinking-") + AllowPrivateNetwork bool `json:"allow_private_network,omitempty"` // Allow connections to RFC 1918 private IPs (for k8s pods, LAN deployments). Link-local (169.254.x.x) is always blocked. } // UnmarshalJSON customizes JSON unmarshaling for NetworkConfig. @@ -86,6 +87,7 @@ func (nc *NetworkConfig) UnmarshalJSON(data []byte) error { MaxConnsPerHost int `json:"max_conns_per_host,omitempty"` EnforceHTTP2 bool `json:"enforce_http2,omitempty"` BetaHeaderOverrides map[string]bool `json:"beta_header_overrides,omitempty"` + AllowPrivateNetwork bool `json:"allow_private_network,omitempty"` } var alias NetworkConfigAlias @@ -104,6 +106,7 @@ func (nc *NetworkConfig) UnmarshalJSON(data []byte) error { nc.MaxConnsPerHost = alias.MaxConnsPerHost nc.EnforceHTTP2 = alias.EnforceHTTP2 nc.BetaHeaderOverrides = alias.BetaHeaderOverrides + nc.AllowPrivateNetwork = alias.AllowPrivateNetwork // Parse RetryBackoffInitial: string → ParseDuration, integer → milliseconds (legacy) if len(alias.RetryBackoffInitial) > 0 && string(alias.RetryBackoffInitial) != "null" { @@ -175,6 +178,7 @@ func (nc NetworkConfig) MarshalJSON() ([]byte, error) { MaxConnsPerHost int `json:"max_conns_per_host,omitempty"` EnforceHTTP2 bool `json:"enforce_http2,omitempty"` BetaHeaderOverrides map[string]bool `json:"beta_header_overrides,omitempty"` + AllowPrivateNetwork bool `json:"allow_private_network,omitempty"` } alias := NetworkConfigAlias{ @@ -190,6 +194,7 @@ func (nc NetworkConfig) MarshalJSON() ([]byte, error) { MaxConnsPerHost: nc.MaxConnsPerHost, EnforceHTTP2: nc.EnforceHTTP2, BetaHeaderOverrides: nc.BetaHeaderOverrides, + AllowPrivateNetwork: nc.AllowPrivateNetwork, } if nc.CACertPEM != nil { alias.CACertPEM = EnvVarAsString(nc.CACertPEM) diff --git a/core/schemas/responses.go b/core/schemas/responses.go index 0db5f034e8c..93daa3989b4 100644 --- a/core/schemas/responses.go +++ b/core/schemas/responses.go @@ -713,6 +713,29 @@ type ResponsesResponseUsage struct { TotalTokens int `json:"total_tokens"` // Total number of tokens used Cost *BifrostCost `json:"cost,omitempty"` // Only for the providers which support cost calculation Iterations []ResponsesResponseUsage `json:"iterations,omitempty"` // iterations field is sent by anthropic + + // xAI-specific usage fields + NumSourcesUsed *int `json:"num_sources_used,omitempty"` + NumServerSideToolsUsed *int `json:"num_server_side_tools_used,omitempty"` + CostInUsdTicks *int64 `json:"cost_in_usd_ticks,omitempty"` + ServerSideToolUsageDetails *ResponsesServerSideToolUsageDetails `json:"server_side_tool_usage_details,omitempty"` + ContextDetails *ResponsesContextDetails `json:"context_details,omitempty"` +} + +// ResponsesServerSideToolUsageDetails holds per-tool call counts returned by xAI. +type ResponsesServerSideToolUsageDetails struct { + WebSearchCalls int `json:"web_search_calls"` + XSearchCalls int `json:"x_search_calls"` + CodeInterpreterCalls int `json:"code_interpreter_calls"` + FileSearchCalls int `json:"file_search_calls"` + MCPCalls int `json:"mcp_calls"` + DocumentSearchCalls int `json:"document_search_calls"` +} + +// ResponsesContextDetails holds the per-context token breakdown returned by xAI. +type ResponsesContextDetails struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` } type ResponsesResponseInputTokens struct { @@ -814,6 +837,7 @@ const ( ResponsesMessageTypeReasoning ResponsesMessageType = "reasoning" ResponsesMessageTypeItemReference ResponsesMessageType = "item_reference" ResponsesMessageTypeRefusal ResponsesMessageType = "refusal" + ResponsesMessageTypeCompaction ResponsesMessageType = "compaction" ) // ResponsesMessage is a union type that can contain different types of input items @@ -870,8 +894,9 @@ func (rc ResponsesMessageContent) MarshalJSON() ([]byte, error) { if rc.ContentBlocks != nil { return MarshalSorted(rc.ContentBlocks) } - // If both are nil, return null - return MarshalSorted(nil) + // Empty content: emit "" rather than null. The OpenAI Responses API rejects + // null content (it must be a string or array), and "" is a valid string. + return MarshalSorted("") } // UnmarshalJSON implements custom JSON unmarshalling for ResponsesMessageContent. @@ -1634,6 +1659,7 @@ const ( ResponsesToolTypeMemory ResponsesToolType = "memory" ResponsesToolTypeToolSearch ResponsesToolType = "tool_search" ResponsesToolTypeNamespace ResponsesToolType = "namespace" + ResponsesToolTypeXSearch ResponsesToolType = "x_search" ) // normalizeResponsesToolType maps versioned/provider-specific tool type strings @@ -1669,7 +1695,7 @@ func normalizeResponsesToolType(t ResponsesToolType) ResponsesToolType { // ResponsesTool represents a tool type ResponsesTool struct { - Type ResponsesToolType `json:"type"` // "function" | "file_search" | "computer_use_preview" | "web_search" | "web_search_2025_08_26" | "mcp" | "code_interpreter" | "image_generation" | "local_shell" | "custom" | "web_search_preview" | "web_search_preview_2025_03_11" + Type ResponsesToolType `json:"type"` // "function" | "file_search" | "computer_use_preview" | "web_search" | "web_search_2025_08_26" | "mcp" | "code_interpreter" | "image_generation" | "local_shell" | "custom" | "web_search_preview" | "web_search_preview_2025_03_11" | "x_search" Name *string `json:"name,omitempty"` // Common name field (Function, Custom tools) Description *string `json:"description,omitempty"` // Common description field (Function, Custom tools) @@ -1697,6 +1723,7 @@ type ResponsesTool struct { *ResponsesToolWebSearchPreview *ResponsesToolToolSearch *ResponsesToolNamespace + *ResponsesToolXSearch } // mergeJSONFields merges all top-level fields from src into dst using sjson, @@ -1831,6 +1858,10 @@ func (t ResponsesTool) MarshalJSON() ([]byte, error) { if t.ResponsesToolNamespace != nil { typeBytes, err = MarshalSorted(t.ResponsesToolNamespace) } + case ResponsesToolTypeXSearch: + if t.ResponsesToolXSearch != nil { + typeBytes, err = MarshalSorted(t.ResponsesToolXSearch) + } } if err != nil { return nil, err @@ -2005,6 +2036,13 @@ func (t *ResponsesTool) UnmarshalJSON(data []byte) error { return err } t.ResponsesToolNamespace = &namespaceTool + + case ResponsesToolTypeXSearch: + var xSearchTool ResponsesToolXSearch + if err := Unmarshal(data, &xSearchTool); err != nil { + return err + } + t.ResponsesToolXSearch = &xSearchTool } return nil @@ -2431,6 +2469,26 @@ type ResponsesToolNamespace struct { Tools []ResponsesTool `json:"tools,omitempty"` } +// ResponsesToolXSearch represents the xAI-native x_search server-side tool. +// All fields are optional; when omitted xAI searches without restrictions. +// See https://docs.x.ai/developers/tools/x-search#x-search-parameters +type ResponsesToolXSearch struct { + // AllowedXHandles restricts search to posts from these X accounts (max 10). + // Mutually exclusive with ExcludedXHandles. + AllowedXHandles []string `json:"allowed_x_handles,omitempty"` + // ExcludedXHandles excludes posts from these X accounts from results. + // Mutually exclusive with AllowedXHandles. + ExcludedXHandles []string `json:"excluded_x_handles,omitempty"` + // FromDate is the start date for tweet search (ISO 8601 date or datetime string). + FromDate *string `json:"from_date,omitempty"` + // ToDate is the end date for tweet search (ISO 8601 date or datetime string). + ToDate *string `json:"to_date,omitempty"` + // EnableImageUnderstanding controls whether images in tweets are analyzed. + EnableImageUnderstanding *bool `json:"enable_image_understanding,omitempty"` + // EnableVideoUnderstanding controls whether videos in tweets are analyzed. + EnableVideoUnderstanding *bool `json:"enable_video_understanding,omitempty"` +} + // ======================================================= Streaming Structs ======================================================= type ResponsesStreamResponseType string diff --git a/core/schemas/responses_test.go b/core/schemas/responses_test.go index e4dbf276763..ef673eabe0d 100644 --- a/core/schemas/responses_test.go +++ b/core/schemas/responses_test.go @@ -111,6 +111,41 @@ func TestBifrostResponsesResponseUnmarshalTimestamps(t *testing.T) { }) } +// TestResponsesMessageContentEmptyMarshalsToEmptyString verifies that empty +// content serializes as "" rather than null, since the OpenAI Responses API +// rejects null content. +func TestResponsesMessageContentEmptyMarshalsToEmptyString(t *testing.T) { + encoded, err := MarshalSorted(ResponsesMessageContent{}) + if err != nil { + t.Fatalf("marshal empty content: %v", err) + } + if string(encoded) != `""` { + t.Fatalf("expected empty content to marshal to \"\", got %s", encoded) + } + + str := "hello" + encodedStr, err := MarshalSorted(ResponsesMessageContent{ContentStr: &str}) + if err != nil { + t.Fatalf("marshal string content: %v", err) + } + if string(encodedStr) != `"hello"` { + t.Fatalf("expected string content to round-trip, got %s", encodedStr) + } + + role := ResponsesInputMessageRoleUser + msg := ResponsesMessage{Role: &role, Content: &ResponsesMessageContent{}} + encodedMsg, err := MarshalSorted(msg) + if err != nil { + t.Fatalf("marshal message with empty content: %v", err) + } + if strings.Contains(string(encodedMsg), `"content":null`) { + t.Fatalf("expected no null content in message, got %s", encodedMsg) + } + if !strings.Contains(string(encodedMsg), `"content":""`) { + t.Fatalf("expected empty-string content in message, got %s", encodedMsg) + } +} + func TestResponsesMessagePreservesOpenAIPhase(t *testing.T) { raw := []byte(`{"id":"msg_123","type":"message","status":"in_progress","content":[],"phase":"final_answer","role":"assistant"}`) diff --git a/core/schemas/trace.go b/core/schemas/trace.go index 173da7591fb..0141f7b748a 100644 --- a/core/schemas/trace.go +++ b/core/schemas/trace.go @@ -9,16 +9,17 @@ import ( // Trace represents a distributed trace that captures the full lifecycle of a request type Trace struct { - RequestID string // Request ID for the trace - TraceID string // Unique identifier for this trace - ParentID string // Parent trace ID from incoming W3C traceparent header - RootSpan *Span // The root span of this trace - Spans []*Span // All spans in this trace - StartTime time.Time // When the trace started - EndTime time.Time // When the trace completed - Attributes map[string]any // Additional attributes for the trace - PluginLogs []PluginLogEntry // Plugin log entries accumulated during request processing - mu sync.Mutex // Mutex for thread-safe span operations + RequestID string // Request ID for the trace + TraceID string // Unique identifier for this trace + ParentID string // Parent trace ID from incoming W3C traceparent header + RootSpan *Span // The root span of this trace + Spans []*Span // All spans in this trace + StartTime time.Time // When the trace started + EndTime time.Time // When the trace completed + Attributes map[string]any // Additional attributes for the trace + RequestHeaders map[string]string // Lowercased request headers, populated only when a connector opts in + PluginLogs []PluginLogEntry // Plugin log entries accumulated during request processing + mu sync.Mutex // Mutex for thread-safe span operations } // AddSpan adds a span to the trace in a thread-safe manner @@ -54,6 +55,13 @@ func (t *Trace) SetRequestID(requestID string) { t.RequestID = requestID } +// SetRequestHeaders sets the captured request headers for the trace. +func (t *Trace) SetRequestHeaders(headers map[string]string) { + t.mu.Lock() + defer t.mu.Unlock() + t.RequestHeaders = headers +} + // Reset clears the trace for reuse from pool func (t *Trace) Reset() { t.mu.Lock() @@ -69,6 +77,7 @@ func (t *Trace) Reset() { t.StartTime = time.Time{} t.EndTime = time.Time{} t.Attributes = nil + t.RequestHeaders = nil for i := range t.PluginLogs { t.PluginLogs[i] = PluginLogEntry{} } @@ -223,20 +232,20 @@ const ( // AttrEmbeddingsDimensionCount is the OTel spec key for embedding dimensions // (Bifrost historically emitted AttrDimensions = gen_ai.request.dimensions). AttrEmbeddingsDimensionCount = "gen_ai.embeddings.dimension.count" - AttrSeed = "gen_ai.request.seed" - AttrSuffix = "gen_ai.request.suffix" - AttrDimensions = "gen_ai.request.dimensions" // legacy: replaced by AttrEmbeddingsDimensionCount - AttrEncodingFormat = "gen_ai.request.encoding_format" // legacy: singular form; replaced by AttrEncodingFormats (string[]) - AttrEncodingFormats = "gen_ai.request.encoding_formats" - AttrLanguage = "gen_ai.request.language" - AttrPrompt = "gen_ai.request.prompt" - AttrResponseFormat = "gen_ai.request.response_format" - AttrFormat = "gen_ai.request.format" - AttrVoice = "gen_ai.request.voice" - AttrMultiVoiceConfig = "gen_ai.request.multi_voice_config" - AttrInstructions = "gen_ai.request.instructions" - AttrSpeed = "gen_ai.request.speed" - AttrMessageCount = "gen_ai.request.message_count" + AttrSeed = "gen_ai.request.seed" + AttrSuffix = "gen_ai.request.suffix" + AttrDimensions = "gen_ai.request.dimensions" // legacy: replaced by AttrEmbeddingsDimensionCount + AttrEncodingFormat = "gen_ai.request.encoding_format" // legacy: singular form; replaced by AttrEncodingFormats (string[]) + AttrEncodingFormats = "gen_ai.request.encoding_formats" + AttrLanguage = "gen_ai.request.language" + AttrPrompt = "gen_ai.request.prompt" + AttrResponseFormat = "gen_ai.request.response_format" + AttrFormat = "gen_ai.request.format" + AttrVoice = "gen_ai.request.voice" + AttrMultiVoiceConfig = "gen_ai.request.multi_voice_config" + AttrInstructions = "gen_ai.request.instructions" + AttrSpeed = "gen_ai.request.speed" + AttrMessageCount = "gen_ai.request.message_count" // Response Attributes AttrResponseID = "gen_ai.response.id" @@ -419,11 +428,11 @@ const ( AttrOutputTokenDetailsSearch = "gen_ai.usage.output_token_details.num_search_queries" // Tool execution attributes (OTel GenAI spec) used on MCP tool spans. - AttrToolName = "gen_ai.tool.name" - AttrToolCallID = "gen_ai.tool.call.id" - AttrToolCallArguments = "gen_ai.tool.call.arguments" - AttrToolCallResult = "gen_ai.tool.call.result" - AttrToolType = "gen_ai.tool.type" + AttrToolName = "gen_ai.tool.name" + AttrToolCallID = "gen_ai.tool.call.id" + AttrToolCallArguments = "gen_ai.tool.call.arguments" + AttrToolCallResult = "gen_ai.tool.call.result" + AttrToolType = "gen_ai.tool.type" // ===================================================================== // Bifrost-namespaced attributes (bifrost.*) @@ -436,20 +445,20 @@ const ( // The corresponding legacy gen_ai.* emissions are tagged "// legacy:" at their // call sites and will be removed once dashboards migrate over. // ===================================================================== - AttrBifrostProviderName = "bifrost.provider.name" - AttrBifrostRequestID = "bifrost.request.id" - AttrBifrostVirtualKeyID = "bifrost.virtual_key.id" - AttrBifrostVirtualKeyName = "bifrost.virtual_key.name" - AttrBifrostSelectedKeyID = "bifrost.selected_key.id" - AttrBifrostSelectedKeyName = "bifrost.selected_key.name" - AttrBifrostRoutingRuleID = "bifrost.routing_rule.id" - AttrBifrostRoutingRuleName = "bifrost.routing_rule.name" - AttrBifrostTeamID = "bifrost.team.id" - AttrBifrostTeamName = "bifrost.team.name" - AttrBifrostCustomerID = "bifrost.customer.id" - AttrBifrostCustomerName = "bifrost.customer.name" - AttrBifrostRetries = "bifrost.retries" - AttrBifrostFallbackIndex = "bifrost.fallback_index" + AttrBifrostProviderName = "bifrost.provider.name" + AttrBifrostRequestID = "bifrost.request.id" + AttrBifrostVirtualKeyID = "bifrost.virtual_key.id" + AttrBifrostVirtualKeyName = "bifrost.virtual_key.name" + AttrBifrostSelectedKeyID = "bifrost.selected_key.id" + AttrBifrostSelectedKeyName = "bifrost.selected_key.name" + AttrBifrostRoutingRuleID = "bifrost.routing_rule.id" + AttrBifrostRoutingRuleName = "bifrost.routing_rule.name" + AttrBifrostTeamID = "bifrost.team.id" + AttrBifrostTeamName = "bifrost.team.name" + AttrBifrostCustomerID = "bifrost.customer.id" + AttrBifrostCustomerName = "bifrost.customer.name" + AttrBifrostRetries = "bifrost.retries" + AttrBifrostFallbackIndex = "bifrost.fallback_index" AttrBifrostStopSequencesJoined = "bifrost.request.stop_sequences" // OTel general semconv (no gen_ai prefix). Emitted alongside the legacy diff --git a/core/utils.go b/core/utils.go index 6444bf73e98..3993b2dd11f 100644 --- a/core/utils.go +++ b/core/utils.go @@ -16,6 +16,7 @@ import ( "time" "github.com/maximhq/bifrost/core/mcp" + "github.com/maximhq/bifrost/core/network" "github.com/maximhq/bifrost/core/schemas" ) @@ -508,65 +509,16 @@ func ValidateExternalURL(urlStr string, allowPrivateNetwork bool) error { if ip.IsUnspecified() { return fmt.Errorf("unspecified IP addresses are not allowed") } - if isLinkLocal(ip) { + if network.IsLinkLocal(ip) { return fmt.Errorf("link-local IP addresses are not allowed") } - if !allowPrivateNetwork && isPrivateIP(ip) { + if !allowPrivateNetwork && network.IsPrivateIP(ip) { return fmt.Errorf("private IP addresses are not allowed") } } return nil } -// isLocalhost checks if a hostname is localhost or a loopback address -func isLocalhost(hostname string) bool { - return hostname == "localhost" || - hostname == "127.0.0.1" || - hostname == "::1" || - hostname == "0.0.0.0" || - hostname == "::" -} - -// isPrivateIP checks if an IP address is in a private range -func isPrivateIP(ip net.IP) bool { - // Private IPv4 ranges - privateRanges := []string{ - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - "169.254.0.0/16", // Link-local - "127.0.0.0/8", // Loopback - } - for _, cidr := range privateRanges { - _, subnet, _ := net.ParseCIDR(cidr) - if subnet.Contains(ip) { - return true - } - } - // Check for private IPv6 - if ip.To4() == nil { - // Check for IPv6 loopback and link-local - if ip.IsLoopback() || ip.IsLinkLocalUnicast() { - return true - } - // Check for IPv6 unique local addresses (fc00::/7) - if len(ip) == 16 && (ip[0]&0xfe) == 0xfc { - return true - } - } - return false -} - -// isLinkLocal reports whether ip is a link-local address (169.254.x.x or fe80::). -// These are always blocked — they include cloud metadata endpoints (e.g. 169.254.169.254). -func isLinkLocal(ip net.IP) bool { - if ip.To4() != nil { - _, subnet, _ := net.ParseCIDR("169.254.0.0/16") - return subnet.Contains(ip) - } - return ip.IsLinkLocalUnicast() -} - // sanitizeSpanName sanitizes a span name to remove capital letters and spaces to make it a valid span name func sanitizeSpanName(name string) string { return strings.ToLower(strings.ReplaceAll(name, " ", "-")) diff --git a/core/utils_test.go b/core/utils_test.go new file mode 100644 index 00000000000..15d010e719b --- /dev/null +++ b/core/utils_test.go @@ -0,0 +1,275 @@ +package bifrost + +import ( + "net" + "strings" + "testing" + + "github.com/maximhq/bifrost/core/network" +) + +func TestValidateExternalURL(t *testing.T) { + tests := []struct { + name string + url string + allowPrivateNetwork bool + wantErr bool + errMsg string + }{ + // Valid URLs + { + name: "valid https URL", + url: "https://api.openai.com", + wantErr: false, + }, + { + name: "valid http URL", + url: "http://api.openai.com", + wantErr: false, + }, + { + name: "valid https URL with path", + url: "https://api.openai.com/v1", + wantErr: false, + }, + { + name: "valid https URL with port", + url: "https://api.openai.com:443", + wantErr: false, + }, + + // Empty / malformed + { + name: "empty URL", + url: "", + wantErr: true, + errMsg: "URL cannot be empty", + }, + { + name: "no scheme", + url: "api.openai.com", + wantErr: true, + errMsg: "only https and http schemes are allowed", + }, + { + name: "ftp scheme", + url: "ftp://api.openai.com", + wantErr: true, + errMsg: "only https and http schemes are allowed", + }, + { + name: "file scheme", + url: "file:///etc/passwd", + wantErr: true, + errMsg: "only https and http schemes are allowed", + }, + { + name: "missing hostname", + url: "http://", + wantErr: true, + errMsg: "URL must have a hostname", + }, + + // Localhost / loopback — allowed for local deployments (Ollama, vLLM, SGL) + { + name: "localhost", + url: "http://localhost", + wantErr: false, + }, + { + name: "localhost with port", + url: "http://localhost:8080", + wantErr: false, + }, + { + name: "loopback 127.0.0.1", + url: "http://127.0.0.1", + wantErr: false, + }, + { + name: "loopback ::1", + url: "http://[::1]", + wantErr: false, + }, + { + name: "all-zeros 0.0.0.0", + url: "http://0.0.0.0", + wantErr: true, + errMsg: "unspecified IP addresses are not allowed", + }, + + // Private IP ranges (RFC 1918) + { + name: "private 10.x.x.x", + url: "http://10.0.0.1", + wantErr: true, + errMsg: "private IP addresses are not allowed", + }, + { + name: "private 172.16.x.x", + url: "http://172.16.0.1", + wantErr: true, + errMsg: "private IP addresses are not allowed", + }, + { + name: "private 192.168.x.x", + url: "http://192.168.1.1", + wantErr: true, + errMsg: "private IP addresses are not allowed", + }, + + // Link-local / cloud metadata — always blocked even with AllowPrivateNetwork + { + name: "AWS metadata service", + url: "http://169.254.169.254", + wantErr: true, + errMsg: "link-local IP addresses are not allowed", + }, + { + name: "AWS metadata with path", + url: "http://169.254.169.254/latest/meta-data/", + wantErr: true, + errMsg: "link-local IP addresses are not allowed", + }, + { + name: "link-local 169.254.x.x", + url: "http://169.254.1.1", + wantErr: true, + errMsg: "link-local IP addresses are not allowed", + }, + + // Query-parameter injection (the PoC vector from the advisory) + { + name: "query param injection targeting metadata service", + url: "http://169.254.169.254/latest/meta-data/iam/security-credentials/role?x=", + wantErr: true, + errMsg: "link-local IP addresses are not allowed", + }, + { + name: "query param injection targeting internal host", + url: "http://10.0.0.1/arbitrary/path?x=", + wantErr: true, + errMsg: "private IP addresses are not allowed", + }, + + // AllowPrivateNetwork=true: RFC 1918 allowed, link-local still blocked + { + name: "allow_private_network permits 10.x.x.x", + url: "http://10.0.0.5:8000", + allowPrivateNetwork: true, + wantErr: false, + }, + { + name: "allow_private_network permits 192.168.x.x", + url: "http://192.168.1.50:11434", + allowPrivateNetwork: true, + wantErr: false, + }, + { + name: "allow_private_network still blocks 169.254.169.254", + url: "http://169.254.169.254", + allowPrivateNetwork: true, + wantErr: true, + errMsg: "link-local IP addresses are not allowed", + }, + { + name: "allow_private_network still blocks 0.0.0.0", + url: "http://0.0.0.0", + allowPrivateNetwork: true, + wantErr: true, + errMsg: "unspecified IP addresses are not allowed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateExternalURL(tt.url, tt.allowPrivateNetwork) + if tt.wantErr { + if err == nil { + t.Errorf("expected error containing %q, got nil", tt.errMsg) + return + } + if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("expected error containing %q, got %q", tt.errMsg, err.Error()) + } + } else { + if err != nil { + t.Errorf("expected no error, got %q", err.Error()) + } + } + }) + } +} + +func TestIsLocalhost(t *testing.T) { + tests := []struct { + hostname string + want bool + }{ + {"localhost", true}, + {"127.0.0.1", true}, + {"::1", true}, + {"0.0.0.0", true}, + {"::", true}, + {"api.openai.com", false}, + {"10.0.0.1", false}, // private but not localhost — handled by IsPrivateIP + {"169.254.169.254", false}, + } + + for _, tt := range tests { + t.Run(tt.hostname, func(t *testing.T) { + if got := network.IsLocalhost(tt.hostname); got != tt.want { + t.Errorf("IsLocalhost(%q) = %v, want %v", tt.hostname, got, tt.want) + } + }) + } +} + +func TestIsPrivateIP(t *testing.T) { + tests := []struct { + ip string + want bool + }{ + // Private IPv4 (RFC 1918) + {"10.0.0.1", true}, + {"10.255.255.255", true}, + {"172.16.0.1", true}, + {"172.31.255.255", true}, + {"192.168.0.1", true}, + {"192.168.255.255", true}, + // Link-local + {"169.254.0.1", true}, + {"169.254.169.254", true}, + // Loopback + {"127.0.0.1", true}, + {"127.255.255.255", true}, + // Public IPv4 + {"8.8.8.8", false}, + {"1.1.1.1", false}, + {"104.21.0.1", false}, + // Private IPv6 + {"::1", true}, // loopback + {"fe80::1", true}, // link-local + {"fc00::1", true}, // unique local + {"fd00::1", true}, // unique local + // Public IPv6 + {"2606:4700::1", false}, + // Unspecified addresses (fail-closed) + {"0.0.0.0", true}, // IPv4 unspecified + {"0:0:0:0:0:0:0:0", true}, // IPv6 unspecified long form + {"::", true}, // IPv6 unspecified short form + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + ip := net.ParseIP(tt.ip) + if ip == nil { + t.Fatalf("failed to parse IP %q", tt.ip) + } + if got := network.IsPrivateIP(ip); got != tt.want { + t.Errorf("IsPrivateIP(%q) = %v, want %v", tt.ip, got, tt.want) + } + }) + } +} + diff --git a/docs/deployment-guides/config-json/governance.mdx b/docs/deployment-guides/config-json/governance.mdx index 4c5851be420..e039e254d8a 100644 --- a/docs/deployment-guides/config-json/governance.mdx +++ b/docs/deployment-guides/config-json/governance.mdx @@ -158,6 +158,52 @@ Attach a rate limit to a virtual key via `virtual_keys[].rate_limit_id`, or to a --- +## Model Limits + +`governance.model_configs` applies budgets and rate limits keyed on a model name, an optional provider, and a scope. This is the same data the **Budget & Limits → Model Limits** UI manages. + +```json +{ + "governance": { + "model_configs": [ + { + "id": "mc-gpt4o-global", + "model_name": "gpt-4o", + "provider": "openai", + "budget_id": "budget-production", + "rate_limit_id": "rl-platform-hourly" + }, + { + "id": "mc-openai-provider", + "model_name": "*", + "provider": "openai", + "scope": "global", + "budget_id": "budget-production" + }, + { + "id": "mc-vk-dev-toplevel", + "model_name": "*", + "scope": "virtual_key", + "scope_id": "vk-dev-all", + "budget_id": "budget-dev" + } + ] + } +} +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `id` | Yes | Unique identifier | +| `model_name` | Yes | Model name, or `"*"` to match all models | +| `provider` | No | Provider name; omit to apply across all providers | +| `scope` | No | `"global"` (default, all traffic) or `"virtual_key"` (one VK) | +| `scope_id` | Conditional | Required when `scope` is `"virtual_key"` — the virtual key `id` | +| `budget_id` | No | References a `governance.budgets` entry | +| `rate_limit_id` | No | References a `governance.rate_limits` entry | + +--- + ## Routing Rules Routing rules dynamically select the provider and model for each request based on a [CEL](https://cel.dev) expression. They are evaluated in priority order before the request is dispatched. diff --git a/docs/deployment-guides/helm/governance.mdx b/docs/deployment-guides/helm/governance.mdx index 554d9dc3c8f..bf63e0022a3 100644 --- a/docs/deployment-guides/helm/governance.mdx +++ b/docs/deployment-guides/helm/governance.mdx @@ -252,23 +252,51 @@ curl http://localhost:8080/v1/chat/completions \ --- -## Model Configs +## Model Limits -Apply budgets and rate limits at the model level, independent of virtual keys: +Apply budgets and rate limits at the model level. Each entry is keyed on `model_name` (use `"*"` for all models), an optional `provider`, and a `scope` that determines who the limit applies to. + +| Field | Default | Description | +|-------|---------|-------------| +| `id` | — | Unique identifier | +| `model_name` | — | Model name, or `"*"` to match all models | +| `provider` | _(all)_ | Provider name; omit to cover all providers | +| `scope` | `"global"` | `"global"` (all traffic) or `"virtual_key"` (one VK) | +| `scope_id` | — | Required when `scope` is `"virtual_key"` — the virtual key ID | +| `budget_id` | — | References a `governance.budgets` entry | +| `rate_limit_id` | — | References a `governance.rate_limits` entry | ```yaml bifrost: governance: modelConfigs: - - id: "model-gpt4o" + # Global cap on a specific model across all traffic + - id: "mc-gpt4o-global" model_name: "gpt-4o" provider: "openai" budget_id: "budget-production" rate_limit_id: "rate-limit-high" - - id: "model-claude" - model_name: "claude-3-5-sonnet-20241022" + # Global provider-level budget (all models, all traffic, openai only) + - id: "mc-openai-provider" + model_name: "*" + provider: "openai" + budget_id: "budget-production" + + # VK-scoped top-level budget (all models, all providers, one VK) + - id: "mc-vk-dev-toplevel" + model_name: "*" + scope: "virtual_key" + scope_id: "vk-dev-all" + budget_id: "budget-dev" + + # VK-scoped per-provider budget (all models, anthropic only, one VK) + - id: "mc-vk-dev-anthropic" + model_name: "*" provider: "anthropic" + scope: "virtual_key" + scope_id: "vk-dev-all" + budget_id: "budget-testing" rate_limit_id: "rate-limit-standard" ``` diff --git a/docs/docs.json b/docs/docs.json index a9de874380e..dd8342aa351 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -227,6 +227,7 @@ "features/governance/virtual-keys", "features/governance/routing", "features/governance/budget-and-limits", + "features/governance/model-limits", "features/governance/mcp-tools", "features/governance/required-headers" ] diff --git a/docs/enterprise/access-profiles.mdx b/docs/enterprise/access-profiles.mdx index cf9c3bdea9d..432de6a3287 100644 --- a/docs/enterprise/access-profiles.mdx +++ b/docs/enterprise/access-profiles.mdx @@ -171,6 +171,12 @@ When you edit an existing profile, the action bar shows **Save** (template-only By default, propagation **preserves usage**: existing users keep their accumulated budget and rate-limit counters where the reset durations match. +### Extend individual budgets for a user + +Access profile budgets apply uniformly to everyone assigned the template. When you need a one-off override for a specific user — without touching the template for the rest of the role — you can attach an additional per-model budget or rate limit directly to that user via [Model Limits](/features/governance/model-limits). + +For example, if Alice is on the Engineering profile but you want to add a separate $20/month cap on Claude Opus just for her, you can create a user-scoped model limit for Alice without changing what the Engineering profile gives everyone else. These limits stack independently alongside the profile budget; both must pass for a request to be allowed, and they are not affected by profile propagation. See [Give one user an individual per-model budget](#give-one-user-an-individual-per-model-budget) for steps for the above. + ### Edit, duplicate, delete Each row action exposes: @@ -226,6 +232,28 @@ Profiles can be cloned into new templates, propagated to user copies one field s 2. Open the propagate dialog. Check **MCP Tool Groups**, **MCP Servers**, and **MCP Tool Overrides**. Leave budgets and rate limits unchecked. 3. Click **Propagate**. Budgets and rate limits are not touched; only MCP access changes flow through. +### Give one user an individual per-model budget + +1. Navigate to **Budget & Limits → Model Limits** and click **Add Model Limit**. +2. Select a **Provider** and **Model Name**, set **Scope** to `User`, and pick the target user. +3. Add one or more budget lines and any rate limits. Click **Create Limit**. + +Or via the API: + +```bash +curl -X POST "http://localhost:8080/api/governance/model-configs" \ + -H "Content-Type: application/json" \ + -d '{ + "model_name": "claude-opus-4-8", + "provider": "anthropic", + "scope": "user", + "scope_id": "", + "budgets": [ + { "max_limit": 20.00, "reset_duration": "1M" } + ] + }' +``` + --- ## Next steps diff --git a/docs/features/governance/model-limits.mdx b/docs/features/governance/model-limits.mdx new file mode 100644 index 00000000000..95b21e474ae --- /dev/null +++ b/docs/features/governance/model-limits.mdx @@ -0,0 +1,377 @@ +--- +title: "Model Limits" +description: "Set budget and rate limits at the model level — globally or per virtual key, optionally filtered to a specific provider — from a single unified interface." +icon: "sliders" +--- + +## Overview + +Model limits let you enforce spending caps and rate limits keyed on a specific model (or all models), an optional provider, and a **scope** that determines who the limit applies to. + +They are the unified control plane for all model-level governance in Bifrost: + +- **Global provider budgets** — cap what OpenAI (or any provider) can spend across all traffic +- **Virtual key top-level budgets** — limit how much a specific virtual key can spend across all its providers +- **Virtual key per-provider budgets** — limit what a virtual key can spend on a single provider +- **Per-model limits** — enforce fine-grained caps on individual models for any of the above scopes + + +The `user` scope is available in Bifrost Enterprise. Support for **customer** and **team** scopes is coming soon. + + +--- + +## Scope system + +Every model limit has a **scope** that determines the audience it applies to. + +| Scope | Who it applies to | Scope Target required? | +|-------|-------------------|----------------------| +| `global` | All traffic through Bifrost | No | +| `virtual_key` | All requests made with a specific virtual key | Yes — the virtual key ID | +| `user` | All requests made by a specific user (Enterprise only) | Yes — the user ID | + +**Scope + model name combinations:** + +| model_name | provider | scope | What it governs | +|------------|----------|-------|-----------------| +| `*` (All Models) | `openai` | `global` | Global OpenAI provider budget | +| `*` (All Models) | _(none)_ | `virtual_key` | That VK's top-level cross-provider budget | +| `*` (All Models) | `anthropic` | `virtual_key` | That VK's Anthropic-only budget | +| `gpt-4o` | `openai` | `global` | Hard cap on gpt-4o usage across all traffic | +| `claude-3-5-sonnet-20241022` | _(none)_ | `virtual_key` | Per-VK cap on a specific model | + +--- + +## Configuration + + + + +Navigate to **Budget & Limits → Model Limits** in the Bifrost dashboard. + +### Table view + +The table shows all configured model limits with their current usage. Use the toolbar to find what you need: + +- **Search** — filter by model name +- **Scope** dropdown — show only `global` or `virtual_key` limits +- **Provider** dropdown — show only limits for a specific provider + +The **Scope Target** column links directly back to the parent entity (e.g. clicking a virtual key badge takes you to that VK). + +![Model Limits Table](../../media/ui-model-limits-table.png) + +### Adding a model limit + +Click **Add Model Limit** to open the configuration sheet. + +1. **Provider** — select a specific provider or leave as _All Providers_ +2. **Model Name** — search and select a model, or pick _All Models_ to cover every model for the chosen provider/scope +3. **Scope** — choose `Global` or `Virtual Key` +4. **Scope Target** — appears when scope is `Virtual Key`; select the target virtual key +5. **Budget** — add one or more budget lines, each with a dollar cap and reset duration. Multiple budgets per limit are supported (e.g. `$50/day` + `$500/month`). +6. **Rate Limits** — optionally set token and/or request limits with their own reset durations + +Click **Create Limit** to save. + +![Model Limit Sheet](../../media/ui-model-limits-sheet.png) + + +Model name and scope are locked after creation. To change them, delete the limit and recreate it. + + + + + +### List model limits + +```bash +curl "http://localhost:8080/api/governance/model-configs" \ + -H "Content-Type: application/json" +``` + +With filters: + +```bash +curl "http://localhost:8080/api/governance/model-configs?scope=virtual_key&provider=openai&limit=25&offset=0&search=gpt" \ + -H "Content-Type: application/json" +``` + +**Query parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `limit` | integer | Page size | +| `offset` | integer | Page offset | +| `search` | string | Filter by model name (case-insensitive) | +| `scope` | string | Filter by scope (`global`, `virtual_key`) | +| `provider` | string | Filter by provider name | +| `from_memory` | boolean | Read from in-memory cache (faster, may lag DB by one poll cycle) | + +**Response:** + +```json +{ + "model_configs": [ + { + "id": "mc_abc123", + "model_name": "*", + "provider": "openai", + "scope": "global", + "scope_id": null, + "scope_name": null, + "calendar_aligned": false, + "budgets": [ + { + "id": "b_xyz", + "max_limit": 500.00, + "current_usage": 42.10, + "reset_duration": "1M", + "last_reset": "2026-06-01T00:00:00Z" + } + ], + "rate_limit": null, + "created_at": "2026-05-01T10:00:00Z", + "updated_at": "2026-06-01T00:00:00Z" + } + ], + "total_count": 1 +} +``` + +### Create a model limit + +```bash +curl -X POST "http://localhost:8080/api/governance/model-configs" \ + -H "Content-Type: application/json" \ + -d '{ + "model_name": "gpt-4o", + "provider": "openai", + "scope": "global", + "budgets": [ + { "max_limit": 200.00, "reset_duration": "1d" }, + { "max_limit": 2000.00, "reset_duration": "1M" } + ], + "rate_limit": { + "request_max_limit": 1000, + "request_reset_duration": "1h" + } + }' +``` + +**Request fields:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `model_name` | string | Yes | Model name, or `*` for all models | +| `provider` | string | No | Provider name; omit to cover all providers | +| `scope` | string | No | `global` (default) or `virtual_key` | +| `scope_id` | string | Conditional | Required when `scope` is not `global` | +| `budgets` | array | No | One or more budget lines (each needs `max_limit` + `reset_duration`) | +| `rate_limit` | object | No | Token and/or request rate limits | + +### Update a model limit + +Send the full desired set of budgets — the server reconciles additions, updates, and removals. Send an empty `budgets` array to remove all budgets. + +```bash +curl -X PUT "http://localhost:8080/api/governance/model-configs/{mc_id}" \ + -H "Content-Type: application/json" \ + -d '{ + "budgets": [ + { "max_limit": 300.00, "reset_duration": "1d" }, + { "max_limit": 3000.00, "reset_duration": "1M" } + ] + }' +``` + +### Delete a model limit + +```bash +curl -X DELETE "http://localhost:8080/api/governance/model-configs/{mc_id}" +``` + + + + +Model limits are declared under `governance.model_configs`. Each entry references budgets and rate limits by ID from the sibling `governance.budgets` and `governance.rate_limits` arrays. + +```json +{ + "governance": { + "model_configs": [ + { + "id": "mc-openai-global", + "model_name": "*", + "provider": "openai", + "scope": "global", + "budget_ids": ["b-openai-daily", "b-openai-monthly"] + }, + { + "id": "mc-gpt4o-vk", + "model_name": "gpt-4o", + "provider": "openai", + "scope": "virtual_key", + "scope_id": "vk-production", + "budget_ids": ["b-gpt4o-daily"], + "rate_limit_id": "rl-gpt4o" + } + ], + "budgets": [ + { + "id": "b-openai-daily", + "max_limit": 50.00, + "reset_duration": "1d" + }, + { + "id": "b-openai-monthly", + "max_limit": 1000.00, + "reset_duration": "1M" + }, + { + "id": "b-gpt4o-daily", + "max_limit": 50.00, + "reset_duration": "1d" + } + ], + "rate_limits": [ + { + "id": "rl-gpt4o", + "request_max_limit": 500, + "request_reset_duration": "1h", + "token_max_limit": 500000, + "token_reset_duration": "1h" + } + ] + } +} +``` + +**`model_configs` fields:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | string | Yes | Unique identifier | +| `model_name` | string | Yes | Model name, or `*` for all models | +| `provider` | string | No | Provider name; omit to apply to all providers | +| `scope` | string | No | `global` (default) or `virtual_key` | +| `scope_id` | string | Conditional | Required when `scope` is not `global` | +| `budget_ids` | string[] | No | List of `governance.budgets` IDs to attach. Supports multiple budgets (e.g. daily + monthly). Replaces `budget_id`. | +| `budget_id` | string | No | Deprecated — single budget reference. Use `budget_ids` instead. | +| `rate_limit_id` | string | No | References a `governance.rate_limits` entry | + + + + +--- + +## Examples + +### Global provider cap + +Prevent OpenAI from exceeding $1,000/month regardless of which virtual key triggered the request: + +```bash +curl -X POST "http://localhost:8080/api/governance/model-configs" \ + -H "Content-Type: application/json" \ + -d '{ + "model_name": "*", + "provider": "openai", + "scope": "global", + "budgets": [ + { "max_limit": 1000.00, "reset_duration": "1M" } + ] + }' +``` + +This is also manageable from the **Providers** page → **Governance** tab per provider, which writes to the same underlying entry. + +--- + +### Virtual key top-level budget + +Cap the total spend for a virtual key across all its providers: + +```bash +curl -X POST "http://localhost:8080/api/governance/model-configs" \ + -H "Content-Type: application/json" \ + -d '{ + "model_name": "*", + "scope": "virtual_key", + "scope_id": "vk-staging-team", + "budgets": [ + { "max_limit": 200.00, "reset_duration": "1M" } + ] + }' +``` + +--- + +### Virtual key per-provider budget + +Let the staging VK use Anthropic up to $50/month independently of its OpenAI spend: + +```bash +curl -X POST "http://localhost:8080/api/governance/model-configs" \ + -H "Content-Type: application/json" \ + -d '{ + "model_name": "*", + "provider": "anthropic", + "scope": "virtual_key", + "scope_id": "vk-staging-team", + "budgets": [ + { "max_limit": 50.00, "reset_duration": "1M" } + ] + }' +``` + +These VK governance limits are also editable through the **Virtual Keys** page → provider governance section. + +--- + +### Multi-budget daily + monthly cap + +Protect against both runaway daily spikes and monthly overruns on a single model: + +```bash +curl -X POST "http://localhost:8080/api/governance/model-configs" \ + -H "Content-Type: application/json" \ + -d '{ + "model_name": "gpt-4o", + "provider": "openai", + "scope": "global", + "budgets": [ + { "max_limit": 30.00, "reset_duration": "1d" }, + { "max_limit": 500.00, "reset_duration": "1M" } + ] + }' +``` + +All budgets must pass for a request to be allowed — a spike that exhausts the daily cap blocks further requests until it resets, even if the monthly cap has room remaining. + +--- + +## How limits interact + +When a request arrives, Bifrost checks every applicable model limit **independently**. All must pass: + +``` +Request: VK "staging" → openai → gpt-4o + +Checks run in order: + 1. Global gpt-4o limit (if any) + 2. Global openai limit (if any) + 3. VK "staging" top-level limit (if any) + 4. VK "staging" → openai limit (if any) +``` + +If any single limit is exhausted, the request is blocked. Costs are deducted from **all** matching limits after a successful response. + +--- + +## Next Steps + +- **[Budget & Limits](./budget-and-limits)** — Budgets at the virtual key, team, and customer hierarchy level +- **[Virtual Keys](./virtual-keys)** — Create and manage virtual keys with provider configs +- **[Routing](./routing)** — Automatic failover when a limit is exhausted diff --git a/docs/features/observability/otel.mdx b/docs/features/observability/otel.mdx index d33cddb74c0..edaf84dee10 100644 --- a/docs/features/observability/otel.mdx +++ b/docs/features/observability/otel.mdx @@ -115,7 +115,7 @@ func main() { // Initialize OTel plugin otelPlugin, err := otel.Init(ctx, &otel.Config{ ServiceName: "bifrost", - CollectorURL: "http://localhost:4318", + CollectorURL: "http://localhost:4318/v1/traces", TraceType: otel.TraceTypeGenAIExtension, Protocol: otel.ProtocolHTTP, Headers: map[string]string{ @@ -153,7 +153,7 @@ For Gateway mode, configure via `config.json`: "name": "otel", "config": { "service_name": "bifrost", - "collector_url": "http://localhost:4318", + "collector_url": "http://localhost:4318/v1/traces", "trace_type": "genai_extension", "protocol": "http", "headers": { @@ -165,7 +165,7 @@ For Gateway mode, configure via `config.json`: } ``` -If you need to connect to an OTEL collector that requires TLS, configure `tls_ca_cert`: +If you need to connect to an OTEL collector that requires TLS, configure `tls_ca_cert` and set insecure mode to `false`: ```json { @@ -178,6 +178,7 @@ If you need to connect to an OTEL collector that requires TLS, configure `tls_ca "collector_url": "localhost:4317", "trace_type": "genai_extension", "protocol": "grpc", + "insecure": false, "tls_ca_cert": "/path/to/your/ca.cert", "headers": { "Authorization": "env.OTEL_API_KEY" @@ -189,6 +190,65 @@ If you need to connect to an OTEL collector that requires TLS, configure `tls_ca ``` + + +For Gateway mode, configure via `config.json`: + +```json +{ + "plugins": [ + { + "enabled": true, + "name": "otel", + "config": { + "profiles": [ + { + "service_name": "bifrost", + "enabled": true, + "collector_url": "http://localhost:4318/v1/traces", + "trace_type": "genai_extension", + "protocol": "http", + "headers": { + "Authorization": "env.OTEL_API_KEY" + } + } + ] + } + } + ] +} +``` + +If you need to connect to an OTEL collector that requires TLS, configure `tls_ca_cert` and set insecure mode to `false`: + +```json +{ + "plugins": [ + { + "enabled": true, + "name": "otel", + "config": { + "profiles": [ + { + "service_name": "bifrost", + "enabled": true, + "collector_url": "localhost:4317", + "trace_type": "genai_extension", + "protocol": "grpc", + "insecure": false, + "tls_ca_cert": "/path/to/your/ca.cert", + "headers": { + "Authorization": "env.OTEL_API_KEY" + } + } + ] + } + } + ] +} +``` + + --- @@ -221,12 +281,12 @@ services: tempo: image: grafana/tempo:latest container_name: tempo - command: [ "-config.file=/etc/tempo.yaml" ] + command: ["-target=all", "-config.file=/etc/tempo.yaml"] configs: - source: tempo-config target: /etc/tempo.yaml ports: - - "3200:3200" # tempo HTTP API + - "3200:3200" # Tempo HTTP API expose: - "4317" # OTLP gRPC (internal) volumes: @@ -236,14 +296,14 @@ services: prometheus: image: prom/prometheus:latest container_name: prometheus - depends_on: - - otel-collector command: - "--config.file=/etc/prometheus/prometheus.yml" - "--storage.tsdb.path=/prometheus" - "--web.console.libraries=/usr/share/prometheus/console_libraries" - "--web.console.templates=/usr/share/prometheus/consoles" - "--web.enable-remote-write-receiver" + - "--enable-feature=exemplar-storage" + - "--enable-feature=native-histograms" ports: - "9090:9090" volumes: @@ -251,6 +311,8 @@ services: configs: - source: prometheus-config target: /etc/prometheus/prometheus.yml + depends_on: + - otel-collector restart: unless-stopped grafana: @@ -264,9 +326,8 @@ services: GF_SECURITY_ADMIN_PASSWORD: admin GF_AUTH_ANONYMOUS_ENABLED: "true" GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer - GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: "grafana-pyroscope-app,grafana-exploretraces-app,grafana-metricsdrilldown-app" - GF_PLUGINS_ENABLE_ALPHA: "true" GF_INSTALL_PLUGINS: "" + GF_FEATURE_TOGGLES_ENABLE: traceqlEditor ports: - "4000:3000" volumes: @@ -296,12 +357,12 @@ configs: namespace: otel const_labels: source: otelcol - + otlp/tempo: endpoint: tempo:4317 tls: insecure: true - + debug: verbosity: detailed @@ -346,7 +407,7 @@ configs: protocols: grpc: endpoint: 0.0.0.0:4317 - + ingester: max_block_duration: 5m trace_idle_period: 10s @@ -716,7 +777,7 @@ Uses HTTP/1.1 or HTTP/2 with JSON or Protobuf encoding: ```json { - "collector_url": "http://localhost:4318", + "collector_url": "http://localhost:4318/v1/traces", "protocol": "http" } ``` @@ -778,8 +839,37 @@ The OTel plugin supports **push-based metrics export** via OTLP, which is essent ] } ``` + + + + +```json +{ + "plugins": [ + { + "enabled": true, + "name": "otel", + "config": { + "profiles": [ + { + "service_name": "bifrost", + "enabled": true, + "collector_url": "http://otel-collector:4318/v1/traces", + "trace_type": "genai_extension", + "protocol": "http", + "metrics_enabled": true, + "metrics_endpoint": "http://otel-collector:4318/v1/metrics", + "metrics_push_interval": 15 + } + ] + } + } + ] +} +``` + ```json @@ -801,8 +891,36 @@ The OTel plugin supports **push-based metrics export** via OTLP, which is essent ] } ``` + + + +```json +{ + "plugins": [ + { + "enabled": true, + "name": "otel", + "config": { + "profiles": [ + { + "service_name": "bifrost", + "enabled": true, + "collector_url": "otel-collector:4317", + "trace_type": "genai_extension", + "protocol": "grpc", + "metrics_enabled": true, + "metrics_endpoint": "otel-collector:4317", + "metrics_push_interval": 15 + } + ] + } + } + ] +} +``` + ### Pushed Metrics diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index dd5b690eccd..482e5fd0b87 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -32548,6 +32548,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -32588,6 +32593,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Configuration updated successfully", @@ -32630,6 +32640,11 @@ "tags": [ "Configuration" ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -32652,6 +32667,11 @@ "tags": [ "Configuration" ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -32786,6 +32806,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Proxy configuration updated successfully", @@ -32828,6 +32853,11 @@ "tags": [ "Configuration" ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Pricing sync triggered successfully", @@ -32901,6 +32931,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -33099,6 +33134,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "User created successfully", @@ -33277,6 +33317,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "User deleted successfully", @@ -33329,6 +33374,11 @@ "tags": [ "Users" ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -33423,6 +33473,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Role assigned successfully", @@ -33486,6 +33541,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -33591,6 +33651,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Teams updated successfully", @@ -33655,6 +33720,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -33775,6 +33845,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -33875,6 +33950,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Team created successfully", @@ -33946,6 +34026,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -34039,6 +34124,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Team updated successfully", @@ -34108,6 +34198,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Team deleted successfully", @@ -34161,6 +34256,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -34264,6 +34364,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Member added successfully", @@ -34336,6 +34441,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Member removed successfully", @@ -34388,6 +34498,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Login successful", @@ -34450,6 +34565,11 @@ "tags": [ "Session" ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Logout successful", @@ -34489,6 +34609,11 @@ "tags": [ "Session" ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -34521,6 +34646,11 @@ "tags": [ "Session" ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Ticket issued successfully", @@ -34569,6 +34699,11 @@ "tags": [ "Providers" ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -34609,6 +34744,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Provider added successfully", @@ -34672,6 +34812,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -34743,6 +34888,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Provider updated successfully", @@ -34794,6 +34944,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Provider deleted successfully", @@ -34857,6 +35012,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -34928,6 +35088,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Key created successfully", @@ -35010,6 +35175,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -35090,6 +35260,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Key updated successfully", @@ -35160,6 +35335,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Key deleted successfully", @@ -35212,6 +35392,11 @@ "tags": [ "Providers" ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -35296,6 +35481,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -35404,6 +35594,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -35498,6 +35693,11 @@ "tags": [ "Providers" ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -35557,6 +35757,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -35590,6 +35795,11 @@ "tags": [ "Plugins" ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -35630,6 +35840,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "201": { "description": "Plugin created successfully", @@ -35691,6 +35906,11 @@ "tags": [ "Plugins" ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Built-in plugin names retrieved successfully", @@ -35743,93 +35963,103 @@ } } ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Plugin" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BifrostError" - } - } - } - }, - "404": { - "description": "Plugin not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BifrostError" - } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BifrostError" - } - } - } - } - } - }, - "put": { - "operationId": "updatePlugin", - "summary": "Update a plugin", - "description": "Updates a plugin's configuration. Will reload or stop the plugin based on enabled status.\nThe response `actualName` field shows the plugin name from GetName() (used as the map key),\nwhich may differ from the display name (`name`).\n", - "tags": [ - "Plugins" - ], - "parameters": [ + "security": [ { - "name": "name", - "in": "path", - "required": true, - "description": "Plugin display name (the config field `name`, not the internal `actualName` from GetName())", - "schema": { - "type": "string" - } + "ManagementBearerAuth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdatePluginRequest" - } - } - } - }, "responses": { "200": { - "description": "Plugin updated successfully", + "description": "Successful response", "content": { "application/json": { "schema": { - "type": "object", - "description": "Plugin operation response", - "properties": { - "message": { - "type": "string" - }, - "plugin": { - "$ref": "#/components/schemas/Plugin" - } - } + "$ref": "#/components/schemas/Plugin" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BifrostError" + } + } + } + }, + "404": { + "description": "Plugin not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BifrostError" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BifrostError" + } + } + } + } + } + }, + "put": { + "operationId": "updatePlugin", + "summary": "Update a plugin", + "description": "Updates a plugin's configuration. Will reload or stop the plugin based on enabled status.\nThe response `actualName` field shows the plugin name from GetName() (used as the map key),\nwhich may differ from the display name (`name`).\n", + "tags": [ + "Plugins" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "Plugin display name (the config field `name`, not the internal `actualName` from GetName())", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePluginRequest" + } + } + } + }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Plugin updated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Plugin operation response", + "properties": { + "message": { + "type": "string" + }, + "plugin": { + "$ref": "#/components/schemas/Plugin" + } + } } } } @@ -35884,6 +36114,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Plugin deleted successfully", @@ -36345,6 +36580,11 @@ "tags": [ "MCP" ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -36900,6 +37140,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "MCP client added successfully", @@ -37145,6 +37390,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "MCP client updated successfully", @@ -37196,6 +37446,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "MCP client removed successfully", @@ -37249,6 +37504,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "MCP client reconnected successfully", @@ -37303,6 +37563,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "MCP client connected successfully with OAuth", @@ -37355,6 +37620,11 @@ "tags": [ "MCP" ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Sessions visible to the caller", @@ -37408,6 +37678,11 @@ "description": "Session / credential / flow row ID" } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "204": { "description": "Revoked" @@ -37464,6 +37739,11 @@ "description": "Session row ID (OAuth token or header credential)" } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Fresh flow opened", @@ -37514,7 +37794,7 @@ "/api/mcp/per-user-headers/flows/{id}": { "get": { "summary": "Get per-user-headers submission flow", - "description": "Returns the pending submission flow row plus the live MCP client's schema\n(required header names + optional admin header names). Used by the\n`/workspace/mcp-sessions/auth?flow=&kind=headers` landing page to\nrender the values form.\n\nAuthorization accepts either a dashboard session OR a short-lived\n`mcp_headers_auth` temp token bound to the flow ID (carried in the URL\nfragment by the auth-landing page link).\n", + "description": "Returns the pending submission flow row plus the live MCP client's schema\n(required header names + optional admin header names). Used by the\n`/workspace/mcp-sessions/auth?flow=&kind=headers` landing page to\nrender the values form.\n\nRequires management API authentication via `Authorization: Bearer `.\n", "operationId": "getPerUserHeadersFlow", "tags": [ "MCP" @@ -37530,6 +37810,11 @@ "description": "Flow row ID" } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Flow detail", @@ -37601,6 +37886,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Credential persisted", @@ -37680,6 +37970,11 @@ "description": "Credential row primary key" } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "204": { "description": "Revoked" @@ -37721,7 +38016,7 @@ "get": { "operationId": "getPerUserOauthFlow", "summary": "Get per-user OAuth flow detail", - "description": "Returns the pending OAuth flow row metadata: which MCP client is being\nauthorized, which identity (user / VK / session) the resulting token\nwill be bound to, and whether an active token already exists for that\nbinding (`has_active_token`).\n\nAuthorization accepts either a dashboard session OR a short-lived\n`mcp_auth` temp token bound to the flow ID (carried in the URL fragment\nby the auth-landing page link).\n", + "description": "Returns the pending OAuth flow row metadata: which MCP client is being\nauthorized, which identity (user / VK / session) the resulting token\nwill be bound to, and whether an active token already exists for that\nbinding (`has_active_token`).\n\nRequires management API authentication via `Authorization: Bearer `.\n", "tags": [ "OAuth" ], @@ -37736,6 +38031,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Flow detail", @@ -37799,6 +38099,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Authorize URL", @@ -37918,6 +38223,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "OAuth authorization successful. Returns HTML page that closes the authorization window.", @@ -37961,6 +38271,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "OAuth config status retrieved successfully", @@ -38014,6 +38329,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "OAuth token revoked successfully", @@ -38153,6 +38473,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -38193,6 +38518,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Virtual key created successfully", @@ -38237,13 +38567,7 @@ ], "security": [ { - "VirtualKeyAuth": [] - }, - { - "BearerAuth": [] - }, - { - "ApiKeyAuth": [] + "ManagementBearerAuth": [] } ], "responses": { @@ -38313,6 +38637,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -38382,6 +38711,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Virtual key updated successfully", @@ -38443,6 +38777,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Virtual key deleted successfully", @@ -38504,6 +38843,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -38544,6 +38888,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Team created successfully", @@ -38606,6 +38955,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -38672,6 +39026,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Team updated successfully", @@ -38733,6 +39092,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Team deleted successfully", @@ -38786,6 +39150,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -38826,6 +39195,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Customer created successfully", @@ -38888,6 +39262,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -38954,6 +39333,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Customer updated successfully", @@ -39015,6 +39399,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Customer deleted successfully", @@ -39068,6 +39457,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -39111,6 +39505,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -39161,6 +39560,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -39201,6 +39605,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Routing rule created successfully", @@ -39254,6 +39663,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -39320,6 +39734,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Routing rule updated successfully", @@ -39381,6 +39800,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Routing rule deleted successfully", @@ -39423,6 +39847,11 @@ "tags": [ "Governance" ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -39463,6 +39892,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Model config created successfully", @@ -39516,6 +39950,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -39582,6 +40021,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Model config updated successfully", @@ -39643,6 +40087,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Model config deleted successfully", @@ -39685,6 +40134,11 @@ "tags": [ "Governance" ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -39738,6 +40192,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Provider governance updated successfully", @@ -39799,6 +40258,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Provider governance deleted successfully", @@ -39883,6 +40347,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -39923,6 +40392,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "201": { "description": "Pricing override created successfully", @@ -39986,6 +40460,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Pricing override updated successfully", @@ -40047,6 +40526,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Pricing override deleted successfully", @@ -40079,6 +40563,11 @@ "tags": [ "RBAC" ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -40188,6 +40677,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Role created", @@ -40283,6 +40777,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -40401,6 +40900,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Role updated", @@ -40498,6 +41002,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Role deleted", @@ -40563,6 +41072,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -40648,6 +41162,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Permissions updated", @@ -40698,6 +41217,11 @@ "tags": [ "RBAC" ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -40747,6 +41271,11 @@ "tags": [ "RBAC" ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -40796,6 +41325,11 @@ "tags": [ "RBAC" ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -40886,6 +41420,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -41539,6 +42078,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "201": { "description": "Profile created", @@ -41903,6 +42447,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -42590,6 +43139,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Profile updated", @@ -42955,6 +43509,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Profile deleted", @@ -43008,6 +43567,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Profile is active.", @@ -43362,6 +43926,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Profile is inactive.", @@ -43737,6 +44306,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "201": { "description": "Cloned profile created.", @@ -44155,6 +44729,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Propagation result.", @@ -44272,6 +44851,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Roles attached.", @@ -44342,6 +44926,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Role detached.", @@ -44391,6 +44980,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -44477,6 +45071,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -44584,6 +45183,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -44691,6 +45295,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -44778,6 +45387,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -44872,6 +45486,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Profile detached.", @@ -44956,6 +45575,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Virtual key created.", @@ -45025,6 +45649,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Virtual key deleted.", @@ -45089,6 +45718,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -45309,6 +45943,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "201": { "description": "Group created", @@ -45442,6 +46081,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -45658,6 +46302,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Group updated", @@ -45793,6 +46442,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Group deleted", @@ -46028,6 +46682,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -46078,6 +46737,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Logs deleted successfully", @@ -46131,6 +46795,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -46216,6 +46885,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Session logs retrieved successfully", @@ -46317,6 +46991,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Session summary retrieved successfully", @@ -46536,6 +47215,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -46726,6 +47410,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -46948,6 +47637,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -47170,6 +47864,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -47397,6 +48096,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -47635,6 +48339,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -47861,6 +48570,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -48087,6 +48801,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -48325,6 +49044,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -48584,6 +49308,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Dimension cost histogram retrieved successfully", @@ -48838,6 +49567,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Dimension token histogram retrieved successfully", @@ -49103,6 +49837,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Dimension latency histogram retrieved successfully", @@ -49207,6 +49946,11 @@ "tags": [ "Logging" ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -49236,6 +49980,11 @@ "tags": [ "Logging" ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -49451,6 +50200,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Model rankings retrieved successfully", @@ -49572,6 +50326,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Costs recalculated successfully", @@ -49749,6 +50508,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -49927,6 +50691,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "MCP tool logs deleted successfully", @@ -49980,6 +50749,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "MCP tool log found", @@ -50198,6 +50972,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -50259,6 +51038,11 @@ "tags": [ "Logging" ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -50413,6 +51197,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "MCP histogram retrieved successfully", @@ -50571,6 +51360,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "MCP cost histogram retrieved successfully", @@ -50720,6 +51514,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "MCP top tools retrieved successfully", @@ -50784,6 +51583,11 @@ "tags": [ "Prompt Repository" ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -50869,6 +51673,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Folder created", @@ -50952,6 +51761,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -51052,6 +51866,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Folder updated", @@ -51143,6 +51962,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Folder deleted", @@ -51195,6 +52019,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -51485,6 +52314,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Prompt created", @@ -51773,6 +52607,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -52078,6 +52917,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Prompt updated", @@ -52364,6 +53208,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Prompt deleted", @@ -52417,6 +53266,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -52560,6 +53414,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Version created", @@ -52672,6 +53531,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -52782,6 +53646,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Version deleted", @@ -52835,6 +53704,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -52983,6 +53857,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Session created", @@ -53098,6 +53977,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -53250,6 +54134,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Session updated", @@ -53363,6 +54252,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Session deleted", @@ -53434,6 +54328,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Session renamed", @@ -53567,6 +54466,11 @@ } } }, + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Version created from session", @@ -53679,6 +54583,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Cache cleared successfully", @@ -53732,6 +54641,11 @@ } } ], + "security": [ + { + "ManagementBearerAuth": [] + } + ], "responses": { "200": { "description": "Cache cleared successfully", @@ -53948,6 +54862,11 @@ "scheme": "bearer", "description": "Bearer token authentication. Use your provider API key or Bifrost authentication token.\nVirtual keys (prefixed with `sk-bf-`) can also be passed here.\n" }, + "ManagementBearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "Management API authentication for `/api/*` endpoints. Use the `Authorization` header with `Bearer `.\nVirtual keys, dashboard/user/session tokens, and `x-api-key` headers are not supported on management APIs.\n" + }, "BasicAuth": { "type": "http", "scheme": "basic", @@ -65837,6 +66756,11 @@ "ca_cert_pem": { "type": "string", "description": "PEM-encoded CA certificate to trust for provider endpoint connections (e.g. self-signed or internal CA)" + }, + "allow_private_network": { + "type": "boolean", + "default": false, + "description": "Allow connections to RFC 1918 private IPs (10.x, 172.16.x, 192.168.x). Enable for providers on a k8s pod network, LAN, or private VPC. Loopback addresses (localhost, 127.0.0.1, ::1) remain allowed regardless of this setting. Link-local addresses (169.254.x.x) are always blocked." } } }, diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index 37d0dc4fad7..87365baf72d 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -1002,6 +1002,12 @@ components: description: | Bearer token authentication. Use your provider API key or Bifrost authentication token. Virtual keys (prefixed with `sk-bf-`) can also be passed here. + ManagementBearerAuth: + type: http + scheme: bearer + description: | + Management API authentication for `/api/*` endpoints. Use the `Authorization` header with `Bearer `. + Virtual keys, dashboard/user/session tokens, and `x-api-key` headers are not supported on management APIs. BasicAuth: type: http scheme: basic @@ -1021,6 +1027,15 @@ components: description: | API key authentication via the `x-api-key` header. Virtual keys (prefixed with `sk-bf-`) can also be passed here. + SessionCookieAuth: + type: apiKey + in: cookie + name: token + description: | + Dashboard session authentication via the HTTPOnly `token` cookie that is + set by `POST /api/session/login`. Used by the Bifrost UI for same-origin + requests in place of an `Authorization` header. Accepted on session + endpoints (`logout`, `ws-ticket`) as an alternative to the management API key. parameters: AsyncJobId: diff --git a/docs/openapi/paths/management/accessprofiles.yaml b/docs/openapi/paths/management/accessprofiles.yaml index de716c51a29..a4507e9a35e 100644 --- a/docs/openapi/paths/management/accessprofiles.yaml +++ b/docs/openapi/paths/management/accessprofiles.yaml @@ -29,6 +29,8 @@ access-profiles: in: query schema: type: boolean + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -53,6 +55,8 @@ access-profiles: application/json: schema: $ref: '../../schemas/management/accessprofiles.yaml#/CreateAccessProfileRequest' + security: + - ManagementBearerAuth: [] responses: '201': description: Profile created @@ -80,6 +84,8 @@ access-profiles-by-id: required: true schema: type: integer + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -113,6 +119,8 @@ access-profiles-by-id: application/json: schema: $ref: '../../schemas/management/accessprofiles.yaml#/UpdateAccessProfileRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Profile updated @@ -141,6 +149,8 @@ access-profiles-by-id: required: true schema: type: integer + security: + - ManagementBearerAuth: [] responses: '200': description: Profile deleted @@ -168,6 +178,8 @@ access-profiles-activate: required: true schema: type: integer + security: + - ManagementBearerAuth: [] responses: '200': description: Profile is active. @@ -193,6 +205,8 @@ access-profiles-deactivate: required: true schema: type: integer + security: + - ManagementBearerAuth: [] responses: '200': description: Profile is inactive. @@ -224,6 +238,8 @@ access-profiles-clone: application/json: schema: $ref: '../../schemas/management/accessprofiles.yaml#/CloneProfileRequest' + security: + - ManagementBearerAuth: [] responses: '201': description: Cloned profile created. @@ -261,6 +277,8 @@ access-profiles-propagate: application/json: schema: $ref: '../../schemas/management/accessprofiles.yaml#/PropagateRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Propagation result. @@ -296,6 +314,8 @@ access-profiles-roles: application/json: schema: $ref: '../../schemas/management/accessprofiles.yaml#/AttachRolesRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Roles attached. @@ -327,6 +347,8 @@ access-profiles-role-by-id: required: true schema: type: integer + security: + - ManagementBearerAuth: [] responses: '200': description: Role detached. @@ -351,6 +373,8 @@ access-profiles-versions: required: true schema: type: integer + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -378,6 +402,8 @@ access-profiles-version-by-number: required: true schema: type: integer + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -419,6 +445,8 @@ access-profiles-audit-logs-by-id: in: query schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -452,6 +480,8 @@ access-profiles-audit-logs-all: in: query schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -474,6 +504,8 @@ users-access-profiles: required: true schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -502,6 +534,8 @@ users-access-profile-by-id: required: true schema: type: integer + security: + - ManagementBearerAuth: [] responses: '200': description: Profile detached. @@ -537,6 +571,8 @@ users-access-profile-virtual-keys: application/json: schema: $ref: '../../schemas/management/accessprofiles.yaml#/AddVirtualKeyRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Virtual key created. @@ -566,6 +602,8 @@ users-access-profile-virtual-key-by-id: required: true schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Virtual key deleted. diff --git a/docs/openapi/paths/management/cache.yaml b/docs/openapi/paths/management/cache.yaml index 29c9d5609d0..bf65addd94d 100644 --- a/docs/openapi/paths/management/cache.yaml +++ b/docs/openapi/paths/management/cache.yaml @@ -15,6 +15,8 @@ clear-by-cache-id: description: Storage ID of the cache entry to delete schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Cache cleared successfully @@ -41,6 +43,8 @@ clear-by-cache-key: description: Cache key to clear schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Cache cleared successfully diff --git a/docs/openapi/paths/management/config.yaml b/docs/openapi/paths/management/config.yaml index e267ce0438f..09554e879fa 100644 --- a/docs/openapi/paths/management/config.yaml +++ b/docs/openapi/paths/management/config.yaml @@ -14,6 +14,8 @@ config: schema: type: string enum: ["true", "false"] + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -38,6 +40,8 @@ config: application/json: schema: $ref: '../../schemas/management/config.yaml#/UpdateConfigRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Configuration updated successfully @@ -57,6 +61,8 @@ version: description: Returns the current Bifrost version information. tags: - Configuration + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -72,6 +78,8 @@ proxy-config: description: Retrieves the current global proxy configuration. tags: - Configuration + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -100,6 +108,8 @@ proxy-config: application/json: schema: $ref: '../../schemas/management/config.yaml#/ProxyConfig' + security: + - ManagementBearerAuth: [] responses: '200': description: Proxy configuration updated successfully @@ -119,6 +129,8 @@ force-sync-pricing: description: Triggers an immediate pricing sync and resets the pricing sync timer. tags: - Configuration + security: + - ManagementBearerAuth: [] responses: '200': description: Pricing sync triggered successfully diff --git a/docs/openapi/paths/management/governance.yaml b/docs/openapi/paths/management/governance.yaml index 89995077cad..cebacce5580 100644 --- a/docs/openapi/paths/management/governance.yaml +++ b/docs/openapi/paths/management/governance.yaml @@ -65,6 +65,8 @@ virtual-keys: schema: type: boolean default: false + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -87,6 +89,8 @@ virtual-keys: application/json: schema: $ref: '../../schemas/management/governance.yaml#/CreateVirtualKeyRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Virtual key created successfully @@ -110,6 +114,8 @@ virtual-keys-quota: The virtual key value itself (provided via header) is the credential. tags: - Governance + # Self-service: the virtual key value is the credential, accepted via the + # x-bf-vk / Authorization: Bearer / x-api-key headers. Not admin-protected. security: - VirtualKeyAuth: [] - BearerAuth: [] @@ -153,6 +159,8 @@ virtual-keys-by-id: schema: type: boolean default: false + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -193,6 +201,8 @@ virtual-keys-by-id: application/json: schema: $ref: '../../schemas/management/governance.yaml#/UpdateVirtualKeyRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Virtual key updated successfully @@ -224,6 +234,8 @@ virtual-keys-by-id: description: Virtual key ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Virtual key deleted successfully @@ -261,6 +273,8 @@ teams: schema: type: boolean default: false + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -283,6 +297,8 @@ teams: application/json: schema: $ref: '../../schemas/management/governance.yaml#/CreateTeamRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Team created successfully @@ -315,6 +331,8 @@ teams-by-id: schema: type: boolean default: false + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -353,6 +371,8 @@ teams-by-id: application/json: schema: $ref: '../../schemas/management/governance.yaml#/UpdateTeamRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Team updated successfully @@ -384,6 +404,8 @@ teams-by-id: description: Team ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Team deleted successfully @@ -416,6 +438,8 @@ customers: schema: type: boolean default: false + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -438,6 +462,8 @@ customers: application/json: schema: $ref: '../../schemas/management/governance.yaml#/CreateCustomerRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Customer created successfully @@ -470,6 +496,8 @@ customers-by-id: schema: type: boolean default: false + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -508,6 +536,8 @@ customers-by-id: application/json: schema: $ref: '../../schemas/management/governance.yaml#/UpdateCustomerRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Customer updated successfully @@ -539,6 +569,8 @@ customers-by-id: description: Customer ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Customer deleted successfully @@ -571,6 +603,8 @@ budgets: schema: type: boolean default: false + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -595,6 +629,8 @@ rate-limits: schema: type: boolean default: false + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -625,6 +661,8 @@ routing-rules: description: Filter routing rules by scope ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -647,6 +685,8 @@ routing-rules: application/json: schema: $ref: '../../schemas/management/governance.yaml#/CreateRoutingRuleRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Routing rule created successfully @@ -673,6 +713,8 @@ routing-rules-by-id: description: Routing rule ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -711,6 +753,8 @@ routing-rules-by-id: application/json: schema: $ref: '../../schemas/management/governance.yaml#/UpdateRoutingRuleRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Routing rule updated successfully @@ -742,6 +786,8 @@ routing-rules-by-id: description: Routing rule ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Routing rule deleted successfully @@ -763,10 +809,47 @@ routing-rules-by-id: model-configs: get: operationId: listModelConfigs - summary: List model configs - description: Returns a list of all model configurations with their budget and rate limit settings. + summary: List model limits + description: Returns a paginated list of model limits with their budget and rate limit settings. tags: - Governance + security: + - ManagementBearerAuth: [] + parameters: + - name: limit + in: query + description: Maximum number of results to return + schema: + type: integer + minimum: 1 + - name: offset + in: query + description: Number of results to skip (for pagination) + schema: + type: integer + minimum: 0 + - name: search + in: query + description: Case-insensitive filter by model name + schema: + type: string + - name: scope + in: query + description: Filter by scope (`global` or `virtual_key`) + schema: + type: string + enum: [global, virtual_key] + - name: provider + in: query + description: Filter by provider name + schema: + type: string + - name: from_memory + in: query + description: If true, returns data from in-memory cache (faster, may lag DB by one poll cycle) + schema: + type: boolean + default: false responses: '200': description: Successful response @@ -789,6 +872,8 @@ model-configs: application/json: schema: $ref: '../../schemas/management/governance.yaml#/CreateModelConfigRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Model config created successfully @@ -815,6 +900,8 @@ model-configs-by-id: description: Model config ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -853,6 +940,8 @@ model-configs-by-id: application/json: schema: $ref: '../../schemas/management/governance.yaml#/UpdateModelConfigRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Model config updated successfully @@ -884,6 +973,8 @@ model-configs-by-id: description: Model config ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Model config deleted successfully @@ -909,6 +1000,8 @@ provider-governance: description: Returns a list of all providers with their governance settings (budget and rate limits). tags: - Governance + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -939,6 +1032,8 @@ provider-governance-by-name: application/json: schema: $ref: '../../schemas/management/governance.yaml#/UpdateProviderGovernanceRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Provider governance updated successfully @@ -970,6 +1065,8 @@ provider-governance-by-name: description: Provider name schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Provider governance deleted successfully @@ -1022,6 +1119,8 @@ pricing-overrides: description: Filter by provider key ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -1044,6 +1143,8 @@ pricing-overrides: application/json: schema: $ref: '../../schemas/management/governance.yaml#/CreatePricingOverrideRequest' + security: + - ManagementBearerAuth: [] responses: '201': description: Pricing override created successfully @@ -1076,6 +1177,8 @@ pricing-overrides-by-id: application/json: schema: $ref: '../../schemas/management/governance.yaml#/UpdatePricingOverrideRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Pricing override updated successfully @@ -1107,6 +1210,8 @@ pricing-overrides-by-id: description: Pricing override ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Pricing override deleted successfully diff --git a/docs/openapi/paths/management/logging.yaml b/docs/openapi/paths/management/logging.yaml index bdec15bf331..227395ab39d 100644 --- a/docs/openapi/paths/management/logging.yaml +++ b/docs/openapi/paths/management/logging.yaml @@ -126,6 +126,8 @@ logs: type: string enum: [asc, desc] default: desc + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -150,6 +152,8 @@ logs: application/json: schema: $ref: '../../schemas/management/logging.yaml#/DeleteLogsRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Logs deleted successfully @@ -262,6 +266,8 @@ logs-stats: description: Search in request/response content schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -281,6 +287,8 @@ logs-dropped: description: Returns the number of dropped requests. tags: - Logging + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -296,6 +304,8 @@ logs-filterdata: description: Returns all unique filter data from logs (models, keys, virtual keys). tags: - Logging + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -320,6 +330,8 @@ logs-by-id: description: Log entry ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -363,6 +375,8 @@ logs-histogram: - $ref: '#/_histogram-parameters/max_cost' - $ref: '#/_histogram-parameters/missing_cost_only' - $ref: '#/_histogram-parameters/content_search' + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -401,6 +415,8 @@ logs-histogram-tokens: - $ref: '#/_histogram-parameters/max_cost' - $ref: '#/_histogram-parameters/missing_cost_only' - $ref: '#/_histogram-parameters/content_search' + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -439,6 +455,8 @@ logs-histogram-cost: - $ref: '#/_histogram-parameters/max_cost' - $ref: '#/_histogram-parameters/missing_cost_only' - $ref: '#/_histogram-parameters/content_search' + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -477,6 +495,8 @@ logs-histogram-models: - $ref: '#/_histogram-parameters/max_cost' - $ref: '#/_histogram-parameters/missing_cost_only' - $ref: '#/_histogram-parameters/content_search' + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -515,6 +535,8 @@ logs-histogram-latency: - $ref: '#/_histogram-parameters/max_cost' - $ref: '#/_histogram-parameters/missing_cost_only' - $ref: '#/_histogram-parameters/content_search' + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -553,6 +575,8 @@ logs-histogram-cost-by-provider: - $ref: '#/_histogram-parameters/max_cost' - $ref: '#/_histogram-parameters/missing_cost_only' - $ref: '#/_histogram-parameters/content_search' + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -591,6 +615,8 @@ logs-histogram-tokens-by-provider: - $ref: '#/_histogram-parameters/max_cost' - $ref: '#/_histogram-parameters/missing_cost_only' - $ref: '#/_histogram-parameters/content_search' + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -629,6 +655,8 @@ logs-histogram-latency-by-provider: - $ref: '#/_histogram-parameters/max_cost' - $ref: '#/_histogram-parameters/missing_cost_only' - $ref: '#/_histogram-parameters/content_search' + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -769,6 +797,8 @@ logs-recalculate-cost: application/json: schema: $ref: '../../schemas/management/logging.yaml#/RecalculateCostRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Costs recalculated successfully @@ -870,6 +900,8 @@ mcp-logs: type: string enum: [asc, desc] default: desc + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -894,6 +926,8 @@ mcp-logs: application/json: schema: $ref: '../../schemas/management/logging.yaml#/DeleteMCPLogsRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: MCP tool logs deleted successfully @@ -920,6 +954,8 @@ mcp-logs-by-id: description: MCP tool log ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: MCP tool log found @@ -995,6 +1031,8 @@ mcp-logs-stats: description: Search in tool arguments and results schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -1014,6 +1052,8 @@ mcp-logs-filterdata: description: Returns all unique filter data from MCP tool logs (tool names, server labels). tags: - Logging + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -1060,6 +1100,8 @@ logs-sessions-by-id: type: string enum: [asc, desc] default: asc + security: + - ManagementBearerAuth: [] responses: '200': description: Session logs retrieved successfully @@ -1086,6 +1128,8 @@ logs-sessions-summary-by-id: description: Parent request ID identifying the session schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Session summary retrieved successfully @@ -1127,6 +1171,8 @@ logs-rankings: - $ref: '#/_histogram-parameters/max_cost' - $ref: '#/_histogram-parameters/missing_cost_only' - $ref: '#/_histogram-parameters/content_search' + security: + - ManagementBearerAuth: [] responses: '200': description: Model rankings retrieved successfully @@ -1174,6 +1220,8 @@ logs-histogram-cost-by-dimension: - $ref: '#/_histogram-parameters/max_cost' - $ref: '#/_histogram-parameters/missing_cost_only' - $ref: '#/_histogram-parameters/content_search' + security: + - ManagementBearerAuth: [] responses: '200': description: Dimension cost histogram retrieved successfully @@ -1220,6 +1268,8 @@ logs-histogram-tokens-by-dimension: - $ref: '#/_histogram-parameters/max_cost' - $ref: '#/_histogram-parameters/missing_cost_only' - $ref: '#/_histogram-parameters/content_search' + security: + - ManagementBearerAuth: [] responses: '200': description: Dimension token histogram retrieved successfully @@ -1266,6 +1316,8 @@ logs-histogram-latency-by-dimension: - $ref: '#/_histogram-parameters/max_cost' - $ref: '#/_histogram-parameters/missing_cost_only' - $ref: '#/_histogram-parameters/content_search' + security: + - ManagementBearerAuth: [] responses: '200': description: Dimension latency histogram retrieved successfully @@ -1338,6 +1390,8 @@ mcp-logs-histogram: description: Search in tool arguments and results schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: MCP histogram retrieved successfully @@ -1410,6 +1464,8 @@ mcp-logs-histogram-cost: description: Search in tool arguments and results schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: MCP cost histogram retrieved successfully @@ -1482,6 +1538,8 @@ mcp-logs-histogram-top-tools: description: Search in tool arguments and results schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: MCP top tools retrieved successfully diff --git a/docs/openapi/paths/management/mcp.yaml b/docs/openapi/paths/management/mcp.yaml index 95528056dfd..ef483c19e5d 100644 --- a/docs/openapi/paths/management/mcp.yaml +++ b/docs/openapi/paths/management/mcp.yaml @@ -79,6 +79,8 @@ clients: description: Returns a list of all configured MCP clients with their tools and connection state. tags: - MCP + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -106,6 +108,8 @@ client: application/json: schema: $ref: '../../schemas/management/mcp.yaml#/MCPClientCreateRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: MCP client added successfully @@ -142,6 +146,8 @@ client-by-id: application/json: schema: $ref: '../../schemas/management/mcp.yaml#/MCPClientUpdateRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: MCP client updated successfully @@ -167,6 +173,8 @@ client-by-id: description: MCP client ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: MCP client removed successfully @@ -193,6 +201,8 @@ client-reconnect: description: MCP client ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: MCP client reconnected successfully @@ -224,6 +234,8 @@ client-complete-oauth: description: MCP client ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: MCP client connected successfully with OAuth @@ -258,6 +270,8 @@ sessions: flow is suppressed to avoid duplicate entries. operationId: listMcpSessions tags: [MCP] + security: + - ManagementBearerAuth: [] responses: '200': description: Sessions visible to the caller @@ -297,6 +311,8 @@ session-by-id: schema: type: string description: Session / credential / flow row ID + security: + - ManagementBearerAuth: [] responses: '204': description: Revoked @@ -335,6 +351,8 @@ session-reauth: schema: type: string description: Session row ID (OAuth token or header credential) + security: + - ManagementBearerAuth: [] responses: '200': description: Fresh flow opened @@ -362,9 +380,7 @@ per-user-headers-flow: `/workspace/mcp-sessions/auth?flow=&kind=headers` landing page to render the values form. - Authorization accepts either a dashboard session OR a short-lived - `mcp_headers_auth` temp token bound to the flow ID (carried in the URL - fragment by the auth-landing page link). + Requires management API authentication via `Authorization: Bearer `. operationId: getPerUserHeadersFlow tags: [MCP] parameters: @@ -374,6 +390,8 @@ per-user-headers-flow: schema: type: string description: Flow row ID + security: + - ManagementBearerAuth: [] responses: '200': description: Flow detail @@ -412,6 +430,8 @@ per-user-headers-flow: application/json: schema: $ref: '../../schemas/management/mcp.yaml#/MCPHeadersSubmitRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Credential persisted @@ -452,6 +472,8 @@ per-user-headers-credential: schema: type: string description: Credential row primary key + security: + - ManagementBearerAuth: [] responses: '204': description: Revoked diff --git a/docs/openapi/paths/management/mcptoolgroups.yaml b/docs/openapi/paths/management/mcptoolgroups.yaml index 0e2cc1c80d7..d3576485e28 100644 --- a/docs/openapi/paths/management/mcptoolgroups.yaml +++ b/docs/openapi/paths/management/mcptoolgroups.yaml @@ -22,6 +22,8 @@ mcp-tool-groups: description: Search by name. schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -47,6 +49,8 @@ mcp-tool-groups: application/json: schema: $ref: '../../schemas/management/mcptoolgroups.yaml#/CreateMCPToolGroupRequest' + security: + - ManagementBearerAuth: [] responses: '201': description: Group created @@ -71,6 +75,8 @@ mcp-tool-groups-by-id: required: true schema: type: integer + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -103,6 +109,8 @@ mcp-tool-groups-by-id: application/json: schema: $ref: '../../schemas/management/mcptoolgroups.yaml#/UpdateMCPToolGroupRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Group updated @@ -129,6 +137,8 @@ mcp-tool-groups-by-id: required: true schema: type: integer + security: + - ManagementBearerAuth: [] responses: '200': description: Group deleted diff --git a/docs/openapi/paths/management/oauth.yaml b/docs/openapi/paths/management/oauth.yaml index 6566b929e33..b78965d3f35 100644 --- a/docs/openapi/paths/management/oauth.yaml +++ b/docs/openapi/paths/management/oauth.yaml @@ -36,6 +36,10 @@ oauth-callback: description: Error description if authorization failed schema: type: string + # Public: hit by the browser after the upstream OAuth provider redirects + # back, with no cookie/bearer context. Whitelisted server-side; the `state` + # query parameter provides CSRF protection. + security: [] responses: '200': description: OAuth authorization successful. Returns HTML page that closes the authorization window. @@ -67,6 +71,8 @@ oauth-config-status: description: OAuth config ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: OAuth config status retrieved successfully @@ -99,6 +105,8 @@ oauth-config-by-id: description: OAuth config ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: OAuth token revoked successfully @@ -127,9 +135,7 @@ per-user-oauth-flow-detail: will be bound to, and whether an active token already exists for that binding (`has_active_token`). - Authorization accepts either a dashboard session OR a short-lived - `mcp_auth` temp token bound to the flow ID (carried in the URL fragment - by the auth-landing page link). + Requires management API authentication via `Authorization: Bearer `. tags: - OAuth parameters: @@ -139,6 +145,8 @@ per-user-oauth-flow-detail: description: Flow row ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Flow detail @@ -176,6 +184,8 @@ per-user-oauth-flow-start: description: Flow row ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Authorize URL @@ -208,8 +218,7 @@ per-user-oauth-flow-start: $ref: '../../openapi.yaml#/components/responses/InternalError' # ─── Removed: OAuth-server endpoints (RFC 7591/8414 surface) ──────────────── -# Bifrost is not an OAuth Authorization Server. MCP clients identify -# themselves via request headers (x-bf-vk, x-bf-mcp-session-id, SSO); -# upstream OAuth happens via the per-user-oauth-flow-* endpoints above. +# Bifrost is not an OAuth Authorization Server. Upstream OAuth happens via +# the per-user-oauth-flow-* endpoints above. # Legacy block intentionally removed: diff --git a/docs/openapi/paths/management/plugins.yaml b/docs/openapi/paths/management/plugins.yaml index fd9c76e93a2..5ad6d5d0f0e 100644 --- a/docs/openapi/paths/management/plugins.yaml +++ b/docs/openapi/paths/management/plugins.yaml @@ -7,6 +7,8 @@ plugins-builtins: Use this to discover which plugins can be enabled without supplying a custom binary. tags: - Plugins + security: + - ManagementBearerAuth: [] responses: '200': description: Built-in plugin names retrieved successfully @@ -34,6 +36,8 @@ plugins: The `types` array in the status shows which interfaces the plugin implements (llm, mcp, http). tags: - Plugins + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -56,6 +60,8 @@ plugins: application/json: schema: $ref: '../../schemas/management/plugins.yaml#/CreatePluginRequest' + security: + - ManagementBearerAuth: [] responses: '201': description: Plugin created successfully @@ -92,6 +98,8 @@ plugins: description: Plugin display name (the config field `name`, not the internal `actualName` from GetName()) schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -132,6 +140,8 @@ plugins: application/json: schema: $ref: '../../schemas/management/plugins.yaml#/UpdatePluginRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Plugin updated successfully @@ -163,6 +173,8 @@ plugins: description: Plugin display name (the config field `name`, not the internal `actualName` from GetName()) schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Plugin deleted successfully diff --git a/docs/openapi/paths/management/prompts.yaml b/docs/openapi/paths/management/prompts.yaml index 1409d7cec9a..951677c0ff4 100644 --- a/docs/openapi/paths/management/prompts.yaml +++ b/docs/openapi/paths/management/prompts.yaml @@ -7,6 +7,8 @@ folders: description: Returns all prompt folders. tags: - Prompt Repository + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -34,6 +36,8 @@ folders: application/json: schema: $ref: '../../schemas/management/prompts.yaml#/CreateFolderRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Folder created @@ -62,6 +66,8 @@ folders-by-id: required: true schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -99,6 +105,8 @@ folders-by-id: application/json: schema: $ref: '../../schemas/management/prompts.yaml#/UpdateFolderRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Folder updated @@ -132,6 +140,8 @@ folders-by-id: required: true schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Folder deleted @@ -161,6 +171,8 @@ prompts: description: Filter by folder ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -188,6 +200,8 @@ prompts: application/json: schema: $ref: '../../schemas/management/prompts.yaml#/CreatePromptRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Prompt created @@ -216,6 +230,8 @@ prompts-by-id: required: true schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -253,6 +269,8 @@ prompts-by-id: application/json: schema: $ref: '../../schemas/management/prompts.yaml#/UpdatePromptRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Prompt updated @@ -280,6 +298,8 @@ prompts-by-id: required: true schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Prompt deleted @@ -310,6 +330,8 @@ prompt-versions: description: Prompt ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -344,6 +366,8 @@ prompt-versions: application/json: schema: $ref: '../../schemas/management/prompts.yaml#/CreateVersionRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Version created @@ -373,6 +397,8 @@ versions-by-id: description: Version ID schema: type: integer + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -405,6 +431,8 @@ versions-by-id: description: Version ID schema: type: integer + security: + - ManagementBearerAuth: [] responses: '200': description: Version deleted @@ -435,6 +463,8 @@ prompt-sessions: description: Prompt ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -469,6 +499,8 @@ prompt-sessions: application/json: schema: $ref: '../../schemas/management/prompts.yaml#/CreateSessionRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Session created @@ -498,6 +530,8 @@ sessions-by-id: description: Session ID schema: type: integer + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -536,6 +570,8 @@ sessions-by-id: application/json: schema: $ref: '../../schemas/management/prompts.yaml#/UpdateSessionRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Session updated @@ -564,6 +600,8 @@ sessions-by-id: description: Session ID schema: type: integer + security: + - ManagementBearerAuth: [] responses: '200': description: Session deleted @@ -600,6 +638,8 @@ sessions-rename: application/json: schema: $ref: '../../schemas/management/prompts.yaml#/RenameSessionRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Session renamed @@ -635,6 +675,8 @@ sessions-commit: application/json: schema: $ref: '../../schemas/management/prompts.yaml#/CommitSessionRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Version created from session diff --git a/docs/openapi/paths/management/providers.yaml b/docs/openapi/paths/management/providers.yaml index 0125f02f309..ed14ad7eedd 100644 --- a/docs/openapi/paths/management/providers.yaml +++ b/docs/openapi/paths/management/providers.yaml @@ -5,6 +5,8 @@ providers: description: Returns a list of all configured providers with their configurations and status. tags: - Providers + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -27,6 +29,8 @@ providers: application/json: schema: $ref: '../../schemas/management/providers.yaml#/AddProviderRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Provider added successfully @@ -59,6 +63,8 @@ providers-by-name: description: Provider name schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -98,6 +104,8 @@ providers-by-name: application/json: schema: $ref: '../../schemas/management/providers.yaml#/UpdateProviderRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Provider updated successfully @@ -123,6 +131,8 @@ providers-by-name: description: Provider name schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Provider deleted successfully @@ -155,6 +165,8 @@ provider-keys: description: Provider name schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -195,6 +207,8 @@ provider-keys: application/json: schema: $ref: '../../schemas/management/providers.yaml#/Key' + security: + - ManagementBearerAuth: [] responses: '200': description: Key created successfully @@ -239,6 +253,8 @@ provider-key-by-id: description: Key ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -285,6 +301,8 @@ provider-key-by-id: application/json: schema: $ref: '../../schemas/management/providers.yaml#/Key' + security: + - ManagementBearerAuth: [] responses: '200': description: Key updated successfully @@ -322,6 +340,8 @@ provider-key-by-id: description: Key ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Key deleted successfully @@ -347,6 +367,8 @@ keys: description: Returns a list of all configured API keys across all providers. tags: - Providers + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -399,6 +421,8 @@ models: schema: type: boolean default: false + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -449,6 +473,8 @@ models-details: schema: type: boolean default: false + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -466,6 +492,8 @@ models-parameters: description: Returns the available parameter definitions for models. tags: - Providers + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -500,6 +528,8 @@ models-base: description: Maximum number of results to return schema: type: integer + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response diff --git a/docs/openapi/paths/management/rbac.yaml b/docs/openapi/paths/management/rbac.yaml index 208d201bcfb..64d7799b125 100644 --- a/docs/openapi/paths/management/rbac.yaml +++ b/docs/openapi/paths/management/rbac.yaml @@ -7,6 +7,8 @@ roles: description: Returns all roles visible to the caller, scoped by data access control. tags: - RBAC + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -31,6 +33,8 @@ roles: application/json: schema: $ref: '../../schemas/management/rbac.yaml#/CreateRoleRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Role created @@ -55,6 +59,8 @@ roles-by-id: required: true schema: type: integer + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -87,6 +93,8 @@ roles-by-id: application/json: schema: $ref: '../../schemas/management/rbac.yaml#/UpdateRoleRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Role updated @@ -114,6 +122,8 @@ roles-by-id: required: true schema: type: integer + security: + - ManagementBearerAuth: [] responses: '200': description: Role deleted @@ -148,6 +158,8 @@ roles-permissions: required: true schema: type: integer + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -180,6 +192,8 @@ roles-permissions: application/json: schema: $ref: '../../schemas/management/rbac.yaml#/UpdateRolePermissionsRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Permissions updated @@ -201,6 +215,8 @@ resources: description: Returns the set of resource names that permissions can target. tags: - RBAC + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -218,6 +234,8 @@ operations: description: Returns the set of operation names that permissions can grant. tags: - RBAC + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -235,6 +253,8 @@ permissions: description: Returns every (resource, operation) pair that can be granted to a role. tags: - RBAC + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response diff --git a/docs/openapi/paths/management/session.yaml b/docs/openapi/paths/management/session.yaml index b0084c3be04..63e414a004e 100644 --- a/docs/openapi/paths/management/session.yaml +++ b/docs/openapi/paths/management/session.yaml @@ -13,6 +13,9 @@ login: application/json: schema: $ref: '../../schemas/management/session.yaml#/LoginRequest' + # Public: exchanges username/password for a session token. Whitelisted + # server-side (systemWhitelistedRoutes), so no bearer token is required. + security: [] responses: '200': description: Login successful @@ -44,6 +47,11 @@ logout: description: Logs out the current user and invalidates the session token. tags: - Session + # Authenticated like every management endpoint (management API key), and the + # dashboard additionally calls it with its HTTPOnly session cookie. + security: + - ManagementBearerAuth: [] + - SessionCookieAuth: [] responses: '200': description: Logout successful @@ -65,6 +73,9 @@ is-auth-enabled: description: Returns whether authentication is enabled and if the current token is valid. tags: - Session + # Public: callable before login so the UI can decide whether to prompt for + # credentials. Whitelisted server-side (systemWhitelistedRoutes). + security: [] responses: '200': description: Successful response @@ -84,6 +95,12 @@ ws-ticket: The ticket can be used as a query parameter when upgrading to WebSocket. tags: - Session + # Authenticated like every management endpoint (management API key), and the + # dashboard additionally calls it with its HTTPOnly session cookie before + # opening a WebSocket connection. + security: + - ManagementBearerAuth: [] + - SessionCookieAuth: [] responses: '200': description: Ticket issued successfully diff --git a/docs/openapi/paths/management/users.yaml b/docs/openapi/paths/management/users.yaml index 0268daed7f7..89d49828c76 100644 --- a/docs/openapi/paths/management/users.yaml +++ b/docs/openapi/paths/management/users.yaml @@ -26,6 +26,8 @@ users: description: Search by name or email schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -48,6 +50,8 @@ users: application/json: schema: $ref: '../../schemas/management/users.yaml#/CreateUserRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: User created successfully @@ -88,6 +92,8 @@ users-email-virtual-keys: schema: type: string format: email + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -123,6 +129,8 @@ users-by-id: description: User ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: User deleted successfully @@ -155,6 +163,8 @@ users-me-permissions: permissions associated with the user's assigned role. tags: - Users + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -199,6 +209,8 @@ users-role: application/json: schema: $ref: '../../schemas/management/users.yaml#/AssignUserRoleRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Role assigned successfully @@ -231,6 +243,8 @@ users-teams: description: User ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -270,6 +284,8 @@ users-teams: application/json: schema: $ref: '../../schemas/management/users.yaml#/UpdateUserTeamsRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Teams updated successfully @@ -322,6 +338,8 @@ teams: description: Search by team name schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -344,6 +362,8 @@ teams: application/json: schema: $ref: '../../schemas/management/users.yaml#/CreateTeamRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Team created successfully @@ -376,6 +396,8 @@ teams-by-id: description: Team ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -411,6 +433,8 @@ teams-by-id: application/json: schema: $ref: '../../schemas/management/users.yaml#/UpdateTeamRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Team updated successfully @@ -446,6 +470,8 @@ teams-by-id: description: Team ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Team deleted successfully @@ -478,6 +504,8 @@ team-members: description: Team ID schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Successful response @@ -515,6 +543,8 @@ team-members: application/json: schema: $ref: '../../schemas/management/users.yaml#/AddTeamMemberRequest' + security: + - ManagementBearerAuth: [] responses: '200': description: Member added successfully @@ -557,6 +587,8 @@ team-member-by-id: description: User ID to remove schema: type: string + security: + - ManagementBearerAuth: [] responses: '200': description: Member removed successfully diff --git a/docs/openapi/schemas/management/governance.yaml b/docs/openapi/schemas/management/governance.yaml index 918f88ae5ef..74f6dc66f71 100644 --- a/docs/openapi/schemas/management/governance.yaml +++ b/docs/openapi/schemas/management/governance.yaml @@ -999,13 +999,35 @@ ModelConfig: description: Unique identifier for the model config model_name: type: string - description: Name of the model + description: Name of the model. Use `*` to match all models. provider: type: string description: Provider name (optional - applies to all providers if not specified) + scope: + type: string + description: Scope where this limit applies. `global` covers all traffic; `virtual_key` scopes to a single virtual key; `user` scopes to a single user (Enterprise only). + enum: [global, virtual_key, user] + default: global + scope_id: + type: string + nullable: true + description: ID of the scope target (e.g. virtual key ID or user ID). Required when scope is not `global`. + scope_name: + type: string + nullable: true + description: Resolved human-readable name of the scope target (read-only, set by server). + calendar_aligned: + type: boolean + description: When true, all budgets reset at clean calendar boundaries (midnight UTC for day, Monday for week, 1st for month, Jan 1 for year). + default: false + budgets: + type: array + description: Budget configurations for this model limit. Each entry must have a unique reset_duration. + items: + $ref: '#/Budget' budget: $ref: '#/Budget' - description: Budget configuration for this model + description: "Deprecated: use `budgets`. Returns the first budget for backward compatibility." rate_limit: $ref: '#/RateLimit' description: Rate limit configuration for this model @@ -1035,9 +1057,9 @@ ListModelConfigsResponse: type: array items: $ref: '#/ModelConfig' - count: + total_count: type: integer - description: Number of model configs returned + description: Total number of model configs matching the filter (used for pagination) CreateModelConfigRequest: type: object @@ -1047,20 +1069,30 @@ CreateModelConfigRequest: properties: model_name: type: string - description: Name of the model (required) + description: Name of the model (required). Use `*` to match all models. provider: type: string description: Provider name (optional - applies to all providers if not specified) - budget: - $ref: '#/CreateBudgetRequest' - description: Budget configuration + scope: + type: string + description: Scope where this limit applies. Defaults to `global`. + enum: [global, virtual_key, user] + default: global + scope_id: + type: string + description: ID of the scope target (e.g. virtual key ID or user ID). Required when scope is not `global`. + budgets: + type: array + description: Budget lines for this limit. Each entry must have a unique reset_duration. + items: + $ref: '#/CreateBudgetRequest' rate_limit: $ref: '#/CreateRateLimitRequest' description: Rate limit configuration UpdateModelConfigRequest: type: object - description: Request to update an existing model config + description: Request to update an existing model config. Scope and scope_id are identity fields and cannot be changed. properties: model_name: type: string @@ -1068,9 +1100,11 @@ UpdateModelConfigRequest: provider: type: string description: Provider name - budget: - $ref: '#/UpdateBudgetRequest' - description: Budget configuration + budgets: + type: array + description: Full desired set of budgets (reconciled server-side). Send an empty array to remove all budgets. + items: + $ref: '#/CreateBudgetRequest' rate_limit: $ref: '#/UpdateRateLimitRequest' description: Rate limit configuration @@ -1098,12 +1132,21 @@ ProviderGovernanceResponse: provider: type: string description: Provider name + budgets: + type: array + description: Budget configurations for this provider. Each entry has a unique reset_duration. + items: + $ref: '#/Budget' budget: $ref: '#/Budget' - description: Budget configuration + description: "Deprecated: use `budgets`. Returns the first budget for backward compatibility." rate_limit: $ref: '#/RateLimit' description: Rate limit configuration + calendar_aligned: + type: boolean + description: When true, all budgets reset at clean calendar boundaries (midnight UTC for day, Monday for week, 1st for month, Jan 1 for year). + default: false ListProviderGovernanceResponse: type: object @@ -1121,12 +1164,25 @@ UpdateProviderGovernanceRequest: type: object description: Request to update provider governance settings properties: + budgets: + type: array + nullable: true + description: > + Full desired set of budgets. Pointer-to-slice semantics apply: + omitting the field leaves budgets unchanged; sending an empty array `[]` removes all budgets; + sending a non-empty array replaces all existing budgets with the provided set. + items: + $ref: '#/CreateBudgetRequest' budget: $ref: '#/UpdateBudgetRequest' - description: Budget configuration + description: "Deprecated: use `budgets`." rate_limit: $ref: '#/UpdateRateLimitRequest' description: Rate limit configuration + calendar_aligned: + type: boolean + nullable: true + description: When true, all budgets reset at clean calendar boundaries. Omit to leave unchanged. # Pricing Overrides diff --git a/docs/openapi/schemas/management/providers.yaml b/docs/openapi/schemas/management/providers.yaml index 2bc180a787d..f8336163ffc 100644 --- a/docs/openapi/schemas/management/providers.yaml +++ b/docs/openapi/schemas/management/providers.yaml @@ -37,6 +37,10 @@ NetworkConfig: ca_cert_pem: type: string description: PEM-encoded CA certificate to trust for provider endpoint connections (e.g. self-signed or internal CA) + allow_private_network: + type: boolean + default: false + description: Allow connections to RFC 1918 private IPs (10.x, 172.16.x, 192.168.x). Enable for providers on a k8s pod network, LAN, or private VPC. Loopback addresses (localhost, 127.0.0.1, ::1) remain allowed regardless of this setting. Link-local addresses (169.254.x.x) are always blocked. ConcurrencyAndBufferSize: type: object diff --git a/docs/providers/request-options.mdx b/docs/providers/request-options.mdx index d8629c3d6df..7fa85b3fef9 100644 --- a/docs/providers/request-options.mdx +++ b/docs/providers/request-options.mdx @@ -521,8 +521,13 @@ This flag affects only what is written to the log record (messages, params, tool Enable passthrough mode for extra parameters. When enabled, any parameters in `extra_params` (or provider-specific extra parameter fields) will be merged directly into the request sent to the provider. +How parameters are collected depends on the entrypoint: + +- Standard inference routes such as `/v1/chat/completions`, `/v1/responses`, `/v1/embeddings`, and `/v1/images/generations` collect all unknown top-level JSON fields as extra parameters. +- OpenAI integration routes under `/openai` preserve the OpenAI request shape, so provider-specific fields must be put under `extra_params`. + - + ```bash curl --location 'http://localhost:8080/v1/chat/completions' \ --header 'x-bf-passthrough-extra-params: true' \ @@ -538,13 +543,31 @@ curl --location 'http://localhost:8080/v1/chat/completions' \ }' ```` + + +```bash +curl --location 'http://localhost:8080/openai/chat/completions' \ +--header 'x-bf-passthrough-extra-params: true' \ +--header 'Content-Type: application/json' \ +--data '{ + "model": "openai/gpt-4o-mini", + "messages": [{"role": "user", "content": "Hello!"}], + "extra_params": { + "custom_param": "value", + "nested_param": { + "a": "value", + "b": 123 + } + } +}' +```` ```go -ctx := context.Background() -ctx = context.WithValue(ctx, schemas.BifrostContextKeyPassthroughExtraParams, true) +ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) +ctx.SetValue(schemas.BifrostContextKeyPassthroughExtraParams, true) -response, err := client.ChatCompletionRequest(schemas.NewBifrostContext(ctx, schemas.NoDeadline), &schemas.BifrostChatRequest{ +response, err := client.ChatCompletionRequest(ctx, &schemas.BifrostChatRequest{ Provider: schemas.OpenAI, Model: "gpt-4o-mini", Input: messages, diff --git a/docs/providers/supported-providers/anthropic.mdx b/docs/providers/supported-providers/anthropic.mdx index 5c81715b257..115661e95a1 100644 --- a/docs/providers/supported-providers/anthropic.mdx +++ b/docs/providers/supported-providers/anthropic.mdx @@ -72,25 +72,7 @@ Configure Anthropic as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "anthropic", - "keys": [ - { - "name": "anthropic-key-1", - "value": "env.ANTHROPIC_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/cerebras.mdx b/docs/providers/supported-providers/cerebras.mdx index 424993bae27..2b06ddc7805 100644 --- a/docs/providers/supported-providers/cerebras.mdx +++ b/docs/providers/supported-providers/cerebras.mdx @@ -72,25 +72,7 @@ Configure Cerebras as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "cerebras", - "keys": [ - { - "name": "cerebras-key-1", - "value": "env.CEREBRAS_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/cohere.mdx b/docs/providers/supported-providers/cohere.mdx index 8c8202ab743..4f596f2b3bb 100644 --- a/docs/providers/supported-providers/cohere.mdx +++ b/docs/providers/supported-providers/cohere.mdx @@ -72,25 +72,7 @@ Configure Cohere as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "cohere", - "keys": [ - { - "name": "cohere-key-1", - "value": "env.COHERE_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/elevenlabs.mdx b/docs/providers/supported-providers/elevenlabs.mdx index fc3324cf917..f286d4388da 100644 --- a/docs/providers/supported-providers/elevenlabs.mdx +++ b/docs/providers/supported-providers/elevenlabs.mdx @@ -74,25 +74,7 @@ Configure ElevenLabs as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "elevenlabs", - "keys": [ - { - "name": "elevenlabs-key-1", - "value": "env.ELEVENLABS_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/fireworks.mdx b/docs/providers/supported-providers/fireworks.mdx index e757dcb6e90..4220f9fe953 100644 --- a/docs/providers/supported-providers/fireworks.mdx +++ b/docs/providers/supported-providers/fireworks.mdx @@ -75,25 +75,7 @@ Configure Fireworks as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "fireworks", - "keys": [ - { - "name": "fireworks-key-1", - "value": "env.FIREWORKS_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/gemini.mdx b/docs/providers/supported-providers/gemini.mdx index c41f8914de0..e2c910ee761 100644 --- a/docs/providers/supported-providers/gemini.mdx +++ b/docs/providers/supported-providers/gemini.mdx @@ -73,25 +73,7 @@ Configure Gemini as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "gemini", - "keys": [ - { - "name": "gemini-key-1", - "value": "env.GEMINI_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). @@ -943,4 +925,4 @@ Bifrost supports the following content modalities through Gemini: **Behavior**: System instructions become `systemInstruction` field (separate from messages), not included in message array **Impact**: Structure differs from OpenAI's system message approach **Code**: `responses.go:34-46` - \ No newline at end of file + diff --git a/docs/providers/supported-providers/groq.mdx b/docs/providers/supported-providers/groq.mdx index 37bcc461b43..9bb52c7801e 100644 --- a/docs/providers/supported-providers/groq.mdx +++ b/docs/providers/supported-providers/groq.mdx @@ -74,25 +74,7 @@ Configure Groq as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "groq", - "keys": [ - { - "name": "groq-key-1", - "value": "env.GROQ_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/huggingface.mdx b/docs/providers/supported-providers/huggingface.mdx index c2708300650..396c54ac53f 100644 --- a/docs/providers/supported-providers/huggingface.mdx +++ b/docs/providers/supported-providers/huggingface.mdx @@ -83,25 +83,7 @@ Configure Hugging Face as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "huggingface", - "keys": [ - { - "name": "huggingface-key-1", - "value": "env.HUGGINGFACE_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/mistral.mdx b/docs/providers/supported-providers/mistral.mdx index e4918e11f37..e48833a66b6 100644 --- a/docs/providers/supported-providers/mistral.mdx +++ b/docs/providers/supported-providers/mistral.mdx @@ -79,25 +79,7 @@ Configure Mistral as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "mistral", - "keys": [ - { - "name": "mistral-key-1", - "value": "env.MISTRAL_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/nebius.mdx b/docs/providers/supported-providers/nebius.mdx index 71fe4299f31..dad778d5a22 100644 --- a/docs/providers/supported-providers/nebius.mdx +++ b/docs/providers/supported-providers/nebius.mdx @@ -72,25 +72,7 @@ Configure Nebius as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "nebius", - "keys": [ - { - "name": "nebius-key-1", - "value": "env.NEBIUS_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/ollama.mdx b/docs/providers/supported-providers/ollama.mdx index 2a0f47da4aa..b43e3c2cfd8 100644 --- a/docs/providers/supported-providers/ollama.mdx +++ b/docs/providers/supported-providers/ollama.mdx @@ -161,28 +161,7 @@ Configure Ollama as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "ollama", - "keys": [ - { - "name": "ollama-local", - "value": "", - "models": [ - "*" - ], - "weight": 1.0, - "ollama_key_config": { - "url": "http://localhost:11434" - } - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/openai.mdx b/docs/providers/supported-providers/openai.mdx index e17cda5fca9..3b3d0657c5f 100644 --- a/docs/providers/supported-providers/openai.mdx +++ b/docs/providers/supported-providers/openai.mdx @@ -69,28 +69,7 @@ Configure OpenAI as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "openai", - "keys": [ - { - "name": "openai-key-1", - "value": "env.OPENAI_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ], - "openai_config": { - "disable_store": false - } -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/openrouter.mdx b/docs/providers/supported-providers/openrouter.mdx index 4adab2ee25a..e6e2f0949a7 100644 --- a/docs/providers/supported-providers/openrouter.mdx +++ b/docs/providers/supported-providers/openrouter.mdx @@ -74,25 +74,7 @@ Configure OpenRouter as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "openrouter", - "keys": [ - { - "name": "openrouter-key-1", - "value": "env.OPENROUTER_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/parasail.mdx b/docs/providers/supported-providers/parasail.mdx index 606ff5f5a2b..8630e94ff5f 100644 --- a/docs/providers/supported-providers/parasail.mdx +++ b/docs/providers/supported-providers/parasail.mdx @@ -72,25 +72,7 @@ Configure Parasail as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "parasail", - "keys": [ - { - "name": "parasail-key-1", - "value": "env.PARASAIL_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/perplexity.mdx b/docs/providers/supported-providers/perplexity.mdx index c232572ca59..3e6d36601df 100644 --- a/docs/providers/supported-providers/perplexity.mdx +++ b/docs/providers/supported-providers/perplexity.mdx @@ -72,25 +72,7 @@ Configure Perplexity as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "perplexity", - "keys": [ - { - "name": "perplexity-key-1", - "value": "env.PERPLEXITY_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/replicate.mdx b/docs/providers/supported-providers/replicate.mdx index 6ad0b7e0981..33478fc178b 100644 --- a/docs/providers/supported-providers/replicate.mdx +++ b/docs/providers/supported-providers/replicate.mdx @@ -81,28 +81,7 @@ Configure Replicate as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "replicate", - "keys": [ - { - "name": "replicate-key-1", - "value": "env.REPLICATE_API_TOKEN", - "models": [ - "*" - ], - "weight": 1.0, - "replicate_key_config": { - "use_deployments_endpoint": false - } - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/runway.mdx b/docs/providers/supported-providers/runway.mdx index 84184724073..106c2dfaf6e 100644 --- a/docs/providers/supported-providers/runway.mdx +++ b/docs/providers/supported-providers/runway.mdx @@ -108,25 +108,7 @@ Configure Runway as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "runway", - "keys": [ - { - "name": "runway-key-1", - "value": "env.RUNWAY_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/sgl.mdx b/docs/providers/supported-providers/sgl.mdx index 9a530422157..7ff7aa4ad2a 100644 --- a/docs/providers/supported-providers/sgl.mdx +++ b/docs/providers/supported-providers/sgl.mdx @@ -78,28 +78,7 @@ Configure SGLang as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "sgl", - "keys": [ - { - "name": "sgl-local", - "value": "", - "models": [ - "*" - ], - "weight": 1.0, - "sgl_key_config": { - "url": "http://localhost:8000" - } - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/vllm.mdx b/docs/providers/supported-providers/vllm.mdx index 5f0977e3e76..cd78deeb648 100644 --- a/docs/providers/supported-providers/vllm.mdx +++ b/docs/providers/supported-providers/vllm.mdx @@ -79,29 +79,7 @@ Configure vLLM as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "vllm", - "keys": [ - { - "name": "vllm-local", - "value": "", - "models": [ - "meta-llama/Llama-3.2-1B-Instruct" - ], - "weight": 1.0, - "vllm_key_config": { - "url": "http://localhost:8000", - "model_name": "meta-llama/Llama-3.2-1B-Instruct" - } - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/docs/providers/supported-providers/xai.mdx b/docs/providers/supported-providers/xai.mdx index 01c0435955c..753de7453c5 100644 --- a/docs/providers/supported-providers/xai.mdx +++ b/docs/providers/supported-providers/xai.mdx @@ -73,25 +73,7 @@ Configure xAI as a provider. - -```bash -curl --location 'http://localhost:8080/api/providers' \ ---header 'Content-Type: application/json' \ ---data '{ - "provider": "xai", - "keys": [ - { - "name": "xai-key-1", - "value": "env.XAI_API_KEY", - "models": [ - "*" - ], - "weight": 1.0 - } - ] -}' -``` - +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). diff --git a/examples/configs/withotel/config.json b/examples/configs/withotel/config.json index 2dd55a86cea..ace87fcd8aa 100644 --- a/examples/configs/withotel/config.json +++ b/examples/configs/withotel/config.json @@ -45,4 +45,4 @@ } } ] -} +} \ No newline at end of file diff --git a/framework/changelog.md b/framework/changelog.md index 8a5b7e7f2c5..04caea49206 100644 --- a/framework/changelog.md +++ b/framework/changelog.md @@ -1,3 +1,3 @@ - feat: file scheme (file://) support for pricing URL sync - feat: paginated virtual key fetch to handle large numbers of keys -- fix: preserve non-pricing model pool entries across pricing reloads +- fix: preserve non-pricing model pool entries across pricing reloads \ No newline at end of file diff --git a/framework/configstore/clientconfig.go b/framework/configstore/clientconfig.go index f6b933d08f0..80874ee6745 100644 --- a/framework/configstore/clientconfig.go +++ b/framework/configstore/clientconfig.go @@ -982,9 +982,22 @@ func GenerateCustomerHash(c tables.TableCustomer) (string, error) { // Hash Name hash.Write([]byte(c.Name)) - // Hash BudgetID + // Collect budget IDs from both sources so config-file context (BudgetID) and + // DB context (Budgets) produce the same hash for the same logical state. + seen := make(map[string]bool, len(c.Budgets)+1) if c.BudgetID != nil { - hash.Write([]byte("budgetID:" + *c.BudgetID)) + seen[*c.BudgetID] = true + } + for _, b := range c.Budgets { + seen[b.ID] = true + } + budgetIDs := make([]string, 0, len(seen)) + for id := range seen { + budgetIDs = append(budgetIDs, id) + } + sort.Strings(budgetIDs) + for _, id := range budgetIDs { + hash.Write([]byte("budgetID:" + id)) } return hex.EncodeToString(hash.Sum(nil)), nil @@ -1068,12 +1081,25 @@ func GenerateTeamHash(t tables.TableTeam) (string, error) { // This is used to detect changes to model configs between config.json and database. // Skips: CreatedAt, UpdatedAt, and relationship objects (dynamic fields) func GenerateModelConfigHash(m tables.TableModelConfig) (string, error) { + // Normalize an empty scope to "global" so a config.json entry that omits scope + // hashes identically to the defaulted DB row. + scope := m.Scope + if scope == "" { + scope = tables.ModelConfigScopeGlobal + } hash := sha256.New() writeHashField(hash, "id", m.ID) writeHashField(hash, "model_name", m.ModelName) writeHashField(hash, "provider", derefStr(m.Provider)) + writeHashField(hash, "scope", scope) + writeHashField(hash, "scope_id", derefStr(m.ScopeID)) writeHashField(hash, "budget_id", derefStr(m.BudgetID)) writeHashField(hash, "rate_limit_id", derefStr(m.RateLimitID)) + sortedBudgetIDs := append([]string(nil), m.BudgetIDs...) + sort.Strings(sortedBudgetIDs) + for _, id := range sortedBudgetIDs { + writeHashField(hash, "budget_ids", id) + } return hex.EncodeToString(hash.Sum(nil)), nil } diff --git a/framework/configstore/migrations.go b/framework/configstore/migrations.go index 7392a56b94a..967a86b3c4f 100644 --- a/framework/configstore/migrations.go +++ b/framework/configstore/migrations.go @@ -188,6 +188,7 @@ type legacyBudgetTeam struct { // TableName returns the governance_teams table name for legacyBudgetTeam. func (legacyBudgetTeam) TableName() string { return "governance_teams" } + // sqliteColumnInfo holds the information about a SQLite column. type sqliteColumnInfo struct { Name string `gorm:"column:name"` @@ -828,6 +829,27 @@ func triggerMigrations(ctx context.Context, db *gorm.DB) error { if err := migrationAddAdditionalAttributesToPricing(ctx, db); err != nil { return err } + if err := migrationAddModelConfigScopeColumns(ctx, db); err != nil { + return err + } + if err := migrationMigrateProviderGovernanceToModelConfigs(ctx, db); err != nil { + return err + } + if err := migrationAddBudgetModelConfigIDColumn(ctx, db); err != nil { + return err + } + if err := migrationAddModelConfigCalendarAlignedColumn(ctx, db); err != nil { + return err + } + if err := migrationMigrateVirtualKeyGovernanceToModelConfigs(ctx, db); err != nil { + return err + } + if err := migrationAddCustomerCalendarAlignedColumn(ctx, db); err != nil { + return err + } + if err := migrationAddCustomerBudgetsToBudgetsTable(ctx, db); err != nil { + return err + } return nil } @@ -3832,6 +3854,451 @@ func migrationAddProviderGovernanceColumns(ctx context.Context, db *gorm.DB) err return nil } +// migrationAddModelConfigScopeColumns adds the scope and scope_id columns to +// governance_model_configs and swaps the unique index from (model_name, provider) +// to (scope, scope_id, model_name, provider). Existing rows are backfilled to the +// "global" scope, preserving pre-scope behavior. The new index is created before +// the old one is dropped so uniqueness is never unenforced during the migration. +func migrationAddModelConfigScopeColumns(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_model_config_scope_columns", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + migrator := tx.Migrator() + modelConfig := &tables.TableModelConfig{} + + // Add scope column (NOT NULL DEFAULT 'global' backfills existing rows). + if !migrator.HasColumn(modelConfig, "scope") { + if err := migrator.AddColumn(modelConfig, "scope"); err != nil { + return fmt.Errorf("failed to add scope column: %w", err) + } + } + // Add scope_id column (nullable). + if !migrator.HasColumn(modelConfig, "scope_id") { + if err := migrator.AddColumn(modelConfig, "scope_id"); err != nil { + return fmt.Errorf("failed to add scope_id column: %w", err) + } + } + // Belt-and-suspenders backfill in case the column default did not populate + // existing rows on this dialect. + if err := tx.Exec("UPDATE governance_model_configs SET scope = ? WHERE scope IS NULL OR scope = ''", tables.ModelConfigScopeGlobal).Error; err != nil { + return fmt.Errorf("failed to backfill scope: %w", err) + } + + // Create the new composite unique index BEFORE dropping the old one. The + // composite index is strictly more selective, so already-unique rows stay + // unique under it; this ordering avoids any window where uniqueness is + // unenforced. CreateIndex reads the struct tags so it is dialect-safe. + if !migrator.HasIndex(modelConfig, "idx_model_scope_provider") { + if err := migrator.CreateIndex(modelConfig, "idx_model_scope_provider"); err != nil { + return fmt.Errorf("failed to create idx_model_scope_provider: %w", err) + } + } + // Drop the now-superseded (model_name, provider) unique index. + if migrator.HasIndex(modelConfig, "idx_model_provider") { + if err := migrator.DropIndex(modelConfig, "idx_model_provider"); err != nil { + return fmt.Errorf("failed to drop idx_model_provider: %w", err) + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + return fmt.Errorf("add_model_config_scope_columns is non-rollbackable: scope-aware rows and the previous uniqueness invariant cannot be restored safely") + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error while running add model config scope columns migration: %s", err.Error()) + } + return nil +} + +// migrationMigrateProviderGovernanceToModelConfigs folds provider-level governance +// (config_providers.budget_id / rate_limit_id) into governance_model_configs as +// (scope='global', provider=, model_name='*') "all models on this provider" rows, +// reusing the same budget/rate-limit rows. It then NULLs the provider FKs so the old +// provider-governance enforcement path goes inert (single source of truth = model_configs). +func migrationMigrateProviderGovernanceToModelConfigs(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "migrate_provider_governance_to_model_configs", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + + // Guard: only run once the model-config table + scope columns exist. + if !tx.Migrator().HasTable(&tables.TableModelConfig{}) || !tx.Migrator().HasColumn(&tables.TableModelConfig{}, "scope") { + return nil + } + + var providers []tables.TableProvider + if err := tx.Where("budget_id IS NOT NULL OR rate_limit_id IS NOT NULL").Find(&providers).Error; err != nil { + return fmt.Errorf("failed to load providers with governance: %w", err) + } + + now := time.Now() + for i := range providers { + p := &providers[i] + + // Idempotency: skip if a global all-models row already exists for this provider. + var existing int64 + if err := tx.Model(&tables.TableModelConfig{}). + Where("scope = ? AND model_name = ? AND provider = ?", tables.ModelConfigScopeGlobal, tables.ModelConfigAllModels, p.Name). + Count(&existing).Error; err != nil { + return fmt.Errorf("failed to check existing wildcard config for provider %q: %w", p.Name, err) + } + if existing == 0 { + providerName := p.Name + mc := tables.TableModelConfig{ + ID: uuid.NewString(), + ModelName: tables.ModelConfigAllModels, + Provider: &providerName, + Scope: tables.ModelConfigScopeGlobal, + BudgetID: p.BudgetID, + RateLimitID: p.RateLimitID, + CreatedAt: now, + UpdatedAt: now, + } + if err := tx.Create(&mc).Error; err != nil { + return fmt.Errorf("failed to create wildcard model config for provider %q: %w", p.Name, err) + } + } + + // Detach governance from the provider (FK rows are reused by the model config above). + if err := tx.Model(&tables.TableProvider{}).Where("name = ?", p.Name). + Updates(map[string]any{"budget_id": nil, "rate_limit_id": nil}).Error; err != nil { + return fmt.Errorf("failed to clear governance FKs for provider %q: %w", p.Name, err) + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + + // Nothing to reverse if the model-config table/scope columns are gone. + if !tx.Migrator().HasTable(&tables.TableModelConfig{}) || !tx.Migrator().HasColumn(&tables.TableModelConfig{}, "scope") { + return nil + } + + // Reverse provider-level wildcards: + // (scope='global', scope_id IS NULL, model_name='*', provider IS NOT NULL). + var wildcards []tables.TableModelConfig + if err := tx.Where( + "scope = ? AND scope_id IS NULL AND model_name = ? AND provider IS NOT NULL", + tables.ModelConfigScopeGlobal, tables.ModelConfigAllModels, + ).Find(&wildcards).Error; err != nil { + return fmt.Errorf("failed to load provider wildcard configs: %w", err) + } + + for i := range wildcards { + mc := &wildcards[i] + // Re-attach the budget/rate-limit FK rows to the provider row. + if err := tx.Model(&tables.TableProvider{}).Where("name = ?", *mc.Provider). + Updates(map[string]any{"budget_id": mc.BudgetID, "rate_limit_id": mc.RateLimitID}).Error; err != nil { + return fmt.Errorf("failed to restore governance FKs for provider %q: %w", *mc.Provider, err) + } + // Drop the wildcard model config; its FK rows now live on the provider again. + if err := tx.Delete(&tables.TableModelConfig{}, "id = ?", mc.ID).Error; err != nil { + return fmt.Errorf("failed to delete wildcard config for provider %q: %w", *mc.Provider, err) + } + } + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error while running migrate provider governance to model configs migration: %s", err.Error()) + } + return nil +} + +// migrationAddBudgetModelConfigIDColumn adds governance_budgets.model_config_id and +// backfills it from the legacy single governance_model_configs.budget_id, inverting +// budget ownership so a model config can own multiple budgets via the FK. +func migrationAddBudgetModelConfigIDColumn(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_budget_model_config_id_column", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mig := tx.Migrator() + + if !mig.HasColumn(&tables.TableBudget{}, "model_config_id") { + if err := mig.AddColumn(&tables.TableBudget{}, "model_config_id"); err != nil { + return fmt.Errorf("failed to add model_config_id column: %w", err) + } + } + + // Backfill from the legacy single budget_id. Idempotent via the IS NULL guard. + if !mig.HasColumn(&tables.TableModelConfig{}, "budget_id") { + return nil + } + var mcs []tables.TableModelConfig + if err := tx.Where("budget_id IS NOT NULL").Find(&mcs).Error; err != nil { + return fmt.Errorf("failed to load model configs with budgets: %w", err) + } + for i := range mcs { + mc := &mcs[i] + if mc.BudgetID == nil { + continue + } + if err := tx.Exec( + "UPDATE governance_budgets SET model_config_id = ? WHERE id = ? AND model_config_id IS NULL", + mc.ID, *mc.BudgetID, + ).Error; err != nil { + return fmt.Errorf("failed to backfill model_config_id for budget %q: %w", *mc.BudgetID, err) + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + return fmt.Errorf("add_budget_model_config_id_column is non-rollbackable: dropping model_config_id would permanently lose multi-budget ownership data that cannot be recovered from the legacy single budget_id column") + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error while running add budget model_config_id column migration: %s", err.Error()) + } + return nil +} + +// ensureVKModelConfig returns the ID of the VK-scoped model config for the given +// (vkID, provider) pair, creating it if absent. +func ensureVKModelConfig(tx *gorm.DB, vkID string, provider *string, calendarAligned bool, now time.Time) (string, error) { + q := tx.Model(&tables.TableModelConfig{}). + Where("scope = ? AND scope_id = ? AND model_name = ?", + tables.ModelConfigScopeVirtualKey, vkID, tables.ModelConfigAllModels) + if provider == nil { + q = q.Where("provider IS NULL") + } else { + q = q.Where("provider = ?", *provider) + } + var existing []tables.TableModelConfig + if err := q.Limit(1).Find(&existing).Error; err != nil { + return "", fmt.Errorf("failed to look up VK model config: %w", err) + } + if len(existing) > 0 { + return existing[0].ID, nil + } + mc := tables.TableModelConfig{ + ID: uuid.NewString(), + ModelName: tables.ModelConfigAllModels, + Provider: provider, + Scope: tables.ModelConfigScopeVirtualKey, + ScopeID: &vkID, + CalendarAligned: calendarAligned, + CreatedAt: now, + UpdatedAt: now, + } + if err := tx.Create(&mc).Error; err != nil { + return "", fmt.Errorf("failed to create VK model config: %w", err) + } + return mc.ID, nil +} + +// migrationMigrateVirtualKeyGovernanceToModelConfigs folds VK-level governance into +// model_configs as VK-scoped all-models wildcard rows: +// - VK top-level budgets/rate-limit -> (scope=virtual_key, scope_id=vk, model_name='*', provider=NULL) +// - per-provider-config budgets/rate-limit -> (..., provider=) +func migrationMigrateVirtualKeyGovernanceToModelConfigs(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "migrate_virtual_key_governance_to_model_configs", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + + // Required tables/columns must exist (scope columns + the new owner FK). + if !tx.Migrator().HasTable(&tables.TableModelConfig{}) || + !tx.Migrator().HasColumn(&tables.TableModelConfig{}, "scope") || + !tx.Migrator().HasColumn(&tables.TableBudget{}, "model_config_id") { + return nil + } + + var vks []tables.TableVirtualKey + if err := tx.Preload("Budgets").Preload("ProviderConfigs").Preload("ProviderConfigs.Budgets"). + Find(&vks).Error; err != nil { + return fmt.Errorf("failed to load virtual keys: %w", err) + } + + now := time.Now() + for i := range vks { + vk := &vks[i] + + // VK top-level governance -> all-providers wildcard. + if len(vk.Budgets) > 0 || vk.RateLimitID != nil { + mcID, err := ensureVKModelConfig(tx, vk.ID, nil, vk.CalendarAligned, now) + if err != nil { + return err + } + for _, b := range vk.Budgets { + if err := tx.Exec( + "UPDATE governance_budgets SET model_config_id = ?, virtual_key_id = NULL WHERE id = ? AND model_config_id IS NULL", + mcID, b.ID, + ).Error; err != nil { + return fmt.Errorf("failed to reparent VK budget %q: %w", b.ID, err) + } + } + if vk.RateLimitID != nil { + if err := tx.Exec("UPDATE governance_model_configs SET rate_limit_id = ? WHERE id = ?", *vk.RateLimitID, mcID).Error; err != nil { + return fmt.Errorf("failed to move VK rate limit to model config: %w", err) + } + if err := tx.Exec("UPDATE governance_virtual_keys SET rate_limit_id = NULL WHERE id = ?", vk.ID).Error; err != nil { + return fmt.Errorf("failed to clear VK rate limit FK: %w", err) + } + } + } + + // Per-provider-config governance -> provider-specific wildcard. + for j := range vk.ProviderConfigs { + pc := &vk.ProviderConfigs[j] + if len(pc.Budgets) == 0 && pc.RateLimitID == nil { + continue + } + provider := pc.Provider + mcID, err := ensureVKModelConfig(tx, vk.ID, &provider, vk.CalendarAligned, now) + if err != nil { + return err + } + for _, b := range pc.Budgets { + if err := tx.Exec( + "UPDATE governance_budgets SET model_config_id = ?, provider_config_id = NULL WHERE id = ? AND model_config_id IS NULL", + mcID, b.ID, + ).Error; err != nil { + return fmt.Errorf("failed to reparent provider-config budget %q: %w", b.ID, err) + } + } + if pc.RateLimitID != nil { + if err := tx.Exec("UPDATE governance_model_configs SET rate_limit_id = ? WHERE id = ?", *pc.RateLimitID, mcID).Error; err != nil { + return fmt.Errorf("failed to move provider-config rate limit to model config: %w", err) + } + if err := tx.Exec("UPDATE governance_virtual_key_provider_configs SET rate_limit_id = NULL WHERE id = ?", pc.ID).Error; err != nil { + return fmt.Errorf("failed to clear provider-config rate limit FK: %w", err) + } + } + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + if !tx.Migrator().HasTable(&tables.TableModelConfig{}) || + !tx.Migrator().HasColumn(&tables.TableModelConfig{}, "scope") { + return nil + } + + // Only the VK-scoped all-models wildcards this migration creates. + var mcs []tables.TableModelConfig + if err := tx.Where("scope = ? AND model_name = ?", + tables.ModelConfigScopeVirtualKey, tables.ModelConfigAllModels).Find(&mcs).Error; err != nil { + return fmt.Errorf("failed to load VK wildcard model configs: %w", err) + } + + for i := range mcs { + mc := &mcs[i] + if mc.ScopeID == nil { + continue + } + var budgets []tables.TableBudget + if err := tx.Where("model_config_id = ?", mc.ID).Find(&budgets).Error; err != nil { + return fmt.Errorf("failed to load budgets for model config %q: %w", mc.ID, err) + } + + if mc.Provider == nil { + // VK top-level: restore VK ownership + rate limit. + for _, b := range budgets { + if err := tx.Exec("UPDATE governance_budgets SET virtual_key_id = ?, model_config_id = NULL WHERE id = ?", *mc.ScopeID, b.ID).Error; err != nil { + return fmt.Errorf("failed to restore VK budget %q: %w", b.ID, err) + } + } + if mc.RateLimitID != nil { + if err := tx.Exec("UPDATE governance_virtual_keys SET rate_limit_id = ? WHERE id = ?", *mc.RateLimitID, *mc.ScopeID).Error; err != nil { + return fmt.Errorf("failed to restore VK rate limit: %w", err) + } + } + } else { + // Provider-specific: find the matching provider config to restore onto. + var pcs []tables.TableVirtualKeyProviderConfig + if err := tx.Where("virtual_key_id = ? AND provider = ?", *mc.ScopeID, *mc.Provider). + Limit(1).Find(&pcs).Error; err != nil { + return fmt.Errorf("failed to find provider config for VK %q provider %q: %w", *mc.ScopeID, *mc.Provider, err) + } + if len(pcs) > 0 { + pcID := pcs[0].ID + for _, b := range budgets { + if err := tx.Exec("UPDATE governance_budgets SET provider_config_id = ?, model_config_id = NULL WHERE id = ?", pcID, b.ID).Error; err != nil { + return fmt.Errorf("failed to restore provider-config budget %q: %w", b.ID, err) + } + } + if mc.RateLimitID != nil { + if err := tx.Exec("UPDATE governance_virtual_key_provider_configs SET rate_limit_id = ? WHERE id = ?", *mc.RateLimitID, pcID).Error; err != nil { + return fmt.Errorf("failed to restore provider-config rate limit: %w", err) + } + } + } + } + + if err := tx.Delete(&tables.TableModelConfig{}, "id = ?", mc.ID).Error; err != nil { + return fmt.Errorf("failed to delete VK wildcard model config %q: %w", mc.ID, err) + } + } + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error while running migrate virtual key governance to model configs migration: %s", err.Error()) + } + return nil +} + +// migrationAddModelConfigCalendarAlignedColumn adds governance_model_configs.calendar_aligned +// and backfills VK-scoped wildcards from their owning virtual key. Budgets folded out of a +// calendar-aligned VK then keep snapping resets to calendar boundaries. +func migrationAddModelConfigCalendarAlignedColumn(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_model_config_calendar_aligned_column", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mig := tx.Migrator() + + if !mig.HasColumn(&tables.TableModelConfig{}, "calendar_aligned") { + if err := mig.AddColumn(&tables.TableModelConfig{}, "calendar_aligned"); err != nil { + return fmt.Errorf("failed to add calendar_aligned column: %w", err) + } + } + + // Backfill VK-scoped configs from their owning VK. + type vkRow struct { + ID string + CalendarAligned bool + } + var rows []vkRow + if err := tx.Table("governance_virtual_keys").Select("id, calendar_aligned").Scan(&rows).Error; err != nil { + return fmt.Errorf("failed to load virtual keys for calendar_aligned backfill: %w", err) + } + for _, r := range rows { + if !r.CalendarAligned { + continue // default is already false + } + if err := tx.Exec( + "UPDATE governance_model_configs SET calendar_aligned = ? WHERE scope = ? AND scope_id = ?", + true, tables.ModelConfigScopeVirtualKey, r.ID, + ).Error; err != nil { + return fmt.Errorf("failed to backfill calendar_aligned for VK %q: %w", r.ID, err) + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mig := tx.Migrator() + if mig.HasColumn(&tables.TableModelConfig{}, "calendar_aligned") { + if err := mig.DropColumn(&tables.TableModelConfig{}, "calendar_aligned"); err != nil { + return fmt.Errorf("failed to drop calendar_aligned column: %w", err) + } + } + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error while running add model config calendar_aligned column migration: %s", err.Error()) + } + return nil +} + // migrationAddAllowedHeadersJSONColumn adds the allowed_headers_json column to the client config table func migrationAddAllowedHeadersJSONColumn(ctx context.Context, db *gorm.DB) error { m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ @@ -9002,3 +9469,142 @@ func migrationAddAdditionalAttributesToPricing(ctx context.Context, db *gorm.DB) } return nil } + +// migrationAddCustomerCalendarAlignedColumn adds calendar_aligned to governance_customers +// so customer-level calendar alignment can be persisted. No backfill is needed: the +// legacy per-budget/per-rate-limit calendar_aligned columns were dropped by +// drop_legacy_calendar_aligned_columns before this migration runs, and calendar_aligned +// never worked for customers, so there is no prior behavior to preserve. +func migrationAddCustomerCalendarAlignedColumn(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_customer_calendar_aligned_column", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mig := tx.Migrator() + if !mig.HasColumn(&tables.TableCustomer{}, "calendar_aligned") { + if err := mig.AddColumn(&tables.TableCustomer{}, "CalendarAligned"); err != nil { + return fmt.Errorf("failed to add calendar_aligned column to governance_customers: %w", err) + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mig := tx.Migrator() + if mig.HasColumn(&tables.TableCustomer{}, "calendar_aligned") { + return mig.DropColumn(&tables.TableCustomer{}, "calendar_aligned") + } + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error running add_customer_calendar_aligned_column migration: %s", err.Error()) + } + return nil +} + +// migrationAddCustomerBudgetsToBudgetsTable pivots customer budgets from a single-FK on +// governance_customers.budget_id to multi-budget ownership via governance_budgets.customer_id, +// mirroring how team budgets were restructured in migrationAddTeamBudgetsToBudgetsTable. +func migrationAddCustomerBudgetsToBudgetsTable(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_customer_budgets_to_budgets_table", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mg := tx.Migrator() + + // Add customer_id FK column on governance_budgets. + if !mg.HasColumn(&tables.TableBudget{}, "customer_id") { + if err := mg.AddColumn(&tables.TableBudget{}, "CustomerID"); err != nil { + return fmt.Errorf("failed to add customer_id column to governance_budgets: %w", err) + } + } + + // Create index on the new FK column (AddColumn doesn't create indexes from struct tags). + if !mg.HasIndex(&tables.TableBudget{}, "idx_governance_budgets_customer_id") { + if err := mg.CreateIndex(&tables.TableBudget{}, "CustomerID"); err != nil { + return fmt.Errorf("failed to create index on governance_budgets.customer_id: %w", err) + } + } + + // Backfill: set customer_id from legacy governance_customers.budget_id (if column still exists). + // The column is intentionally kept; a future migration can drop it once all instances + // have migrated and the column is confirmed unused. + legacyExists, err := hasColumn(tx, "governance_customers", "budget_id") + if err != nil { + return fmt.Errorf("failed to introspect governance_customers for budget_id: %w", err) + } + if legacyExists { + // Preflight: fail if any customer-referenced budget is already owned by another entity. + var conflictCount int64 + if err := tx.Raw(` + SELECT COUNT(*) FROM governance_budgets b + WHERE (b.virtual_key_id IS NOT NULL OR b.provider_config_id IS NOT NULL OR b.team_id IS NOT NULL OR b.model_config_id IS NOT NULL) + AND EXISTS (SELECT 1 FROM governance_customers c WHERE c.budget_id = b.id) + `).Scan(&conflictCount).Error; err != nil { + return fmt.Errorf("failed to check for multi-owner customer budget conflicts: %w", err) + } + if conflictCount > 0 { + return fmt.Errorf( + "cannot migrate customer budgets: %d budget row(s) referenced by a customer are already owned by another entity; resolve manually before re-running", + conflictCount, + ) + } + + if err := tx.Exec(` + UPDATE governance_budgets SET customer_id = ( + SELECT id FROM governance_customers + WHERE governance_customers.budget_id = governance_budgets.id + ) WHERE customer_id IS NULL AND EXISTS ( + SELECT 1 FROM governance_customers + WHERE governance_customers.budget_id = governance_budgets.id + ) + `).Error; err != nil { + return fmt.Errorf("failed to backfill customer budget customer_id: %w", err) + } + } + + // Create FK constraint with CASCADE delete (defined on TableCustomer.Budgets). + if !mg.HasConstraint(&tables.TableCustomer{}, "Budgets") { + if err := mg.CreateConstraint(&tables.TableCustomer{}, "Budgets"); err != nil { + return fmt.Errorf("failed to create FK constraint for Customer -> Budgets: %w", err) + } + } + + // Refresh config_hash for customers whose budgets just got linked. GenerateCustomerHash + // now includes sorted budget IDs, so hashes written before multi-budget support are stale. + var customersToRehash []tables.TableCustomer + if err := tx.Preload("Budgets").Find(&customersToRehash).Error; err != nil { + return fmt.Errorf("failed to fetch customers for hash refresh: %w", err) + } + for _, c := range customersToRehash { + if len(c.Budgets) == 0 { + continue + } + hash, err := GenerateCustomerHash(c) + if err != nil { + return fmt.Errorf("failed to generate hash for customer %s: %w", c.ID, err) + } + if err := tx.Model(&tables.TableCustomer{}).Where("id = ?", c.ID).Update("config_hash", hash).Error; err != nil { + return fmt.Errorf("failed to update hash for customer %s: %w", c.ID, err) + } + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mg := tx.Migrator() + if mg.HasColumn(&tables.TableBudget{}, "customer_id") { + if err := mg.DropColumn(&tables.TableBudget{}, "customer_id"); err != nil { + return err + } + } + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error running add_customer_budgets_to_budgets_table migration: %s", err.Error()) + } + return nil +} diff --git a/framework/configstore/migrations_test.go b/framework/configstore/migrations_test.go index 4675db3ea23..7dbd212939e 100644 --- a/framework/configstore/migrations_test.go +++ b/framework/configstore/migrations_test.go @@ -2381,3 +2381,104 @@ func assertNoCorruptedFKReferences(t *testing.T, db *gorm.DB) { } func strPtr(s string) *string { return &s } + +// TestMigrationAddModelConfigScopeColumns verifies the existing-install transition: +// adding scope/scope_id columns, backfilling existing rows to "global", and swapping the +// (model_name, provider) unique index for the composite (scope, scope_id, model_name, provider) one. +func TestMigrationAddModelConfigScopeColumns(t *testing.T) { + db := setupTestDB(t) + ctx := context.Background() + + // Create the OLD governance_model_configs schema: no scope/scope_id columns, + // with a unique index on (model_name, provider). + require.NoError(t, db.Exec(` + CREATE TABLE governance_model_configs ( + id varchar(255) PRIMARY KEY, + model_name varchar(255) NOT NULL, + provider varchar(50), + budget_id varchar(255), + rate_limit_id varchar(255), + config_hash varchar(255), + created_at datetime NOT NULL, + updated_at datetime NOT NULL + ) + `).Error) + require.NoError(t, db.Exec(`CREATE UNIQUE INDEX idx_model_provider ON governance_model_configs (model_name, provider)`).Error) + + now := time.Now() + require.NoError(t, db.Exec(` + INSERT INTO governance_model_configs (id, model_name, created_at, updated_at) + VALUES (?, ?, ?, ?) + `, "mc-existing", "gpt-4", now, now).Error) + + mc := &tables.TableModelConfig{} + + // Pre-migration state. + assert.False(t, db.Migrator().HasColumn(mc, "scope"), "scope column should not exist yet") + assert.False(t, db.Migrator().HasColumn(mc, "scope_id"), "scope_id column should not exist yet") + assert.True(t, db.Migrator().HasIndex(mc, "idx_model_provider"), "old index should exist before migration") + + require.NoError(t, migrationAddModelConfigScopeColumns(ctx, db)) + + // Post-migration state. + assert.True(t, db.Migrator().HasColumn(mc, "scope"), "scope column should exist after migration") + assert.True(t, db.Migrator().HasColumn(mc, "scope_id"), "scope_id column should exist after migration") + assert.True(t, db.Migrator().HasIndex(mc, "idx_model_scope_provider"), "new composite index should exist after migration") + assert.False(t, db.Migrator().HasIndex(mc, "idx_model_provider"), "old index should be dropped after migration") + + // Existing row should be backfilled to the global scope. + var scope string + require.NoError(t, db.Table("governance_model_configs").Select("scope").Where("id = ?", "mc-existing").Scan(&scope).Error) + assert.Equal(t, tables.ModelConfigScopeGlobal, scope, "existing row should be backfilled to the global scope") + + // Global-scope rows must have NULL scope_id for the composite unique index to work correctly. + var scopeID *string + require.NoError(t, db.Table("governance_model_configs").Select("scope_id").Where("id = ?", "mc-existing").Scan(&scopeID).Error) + assert.Nil(t, scopeID, "global scope rows must have NULL scope_id") + + // Idempotency: running again must be a no-op (no error, state unchanged). + require.NoError(t, migrationAddModelConfigScopeColumns(ctx, db)) + assert.True(t, db.Migrator().HasColumn(mc, "scope")) + assert.True(t, db.Migrator().HasIndex(mc, "idx_model_scope_provider")) + assert.False(t, db.Migrator().HasIndex(mc, "idx_model_provider")) +} + +// TestMigrationMigrateProviderGovernanceToModelConfigs verifies provider-level governance is +// folded into a (global, provider, '*') model_config reusing the same budget/rate-limit rows, +// and the provider FKs are cleared. Idempotent on re-run. +func TestMigrationMigrateProviderGovernanceToModelConfigs(t *testing.T) { + db := setupTestDB(t) + ctx := context.Background() + require.NoError(t, db.AutoMigrate( + &tables.TableProvider{}, &tables.TableModelConfig{}, &tables.TableBudget{}, &tables.TableRateLimit{}, + )) + + now := time.Now() + require.NoError(t, db.Create(&tables.TableBudget{ID: "b1", MaxLimit: 100, ResetDuration: "1M", LastReset: now, CreatedAt: now, UpdatedAt: now}).Error) + require.NoError(t, db.Create(&tables.TableRateLimit{ID: "rl1", TokenMaxLimit: schemas.Ptr(int64(1000)), TokenResetDuration: schemas.Ptr("1h"), TokenLastReset: now, RequestLastReset: now, CreatedAt: now, UpdatedAt: now}).Error) + require.NoError(t, db.Create(&tables.TableProvider{Name: "openai", BudgetID: schemas.Ptr("b1"), RateLimitID: schemas.Ptr("rl1"), CreatedAt: now, UpdatedAt: now}).Error) + + require.NoError(t, migrationMigrateProviderGovernanceToModelConfigs(ctx, db)) + + // A (global, openai, '*') model config now exists reusing the same budget/rate-limit IDs. + var mc tables.TableModelConfig + require.NoError(t, db.Where("scope = ? AND model_name = ? AND provider = ?", tables.ModelConfigScopeGlobal, tables.ModelConfigAllModels, "openai").First(&mc).Error) + require.NotNil(t, mc.BudgetID) + assert.Equal(t, "b1", *mc.BudgetID) + require.NotNil(t, mc.RateLimitID) + assert.Equal(t, "rl1", *mc.RateLimitID) + + // Provider governance FKs are cleared (old path now inert). + var prov tables.TableProvider + require.NoError(t, db.Where("name = ?", "openai").First(&prov).Error) + assert.Nil(t, prov.BudgetID, "provider budget_id should be cleared") + assert.Nil(t, prov.RateLimitID, "provider rate_limit_id should be cleared") + + // Idempotency: re-run creates no duplicate wildcard row. + require.NoError(t, migrationMigrateProviderGovernanceToModelConfigs(ctx, db)) + var count int64 + require.NoError(t, db.Model(&tables.TableModelConfig{}). + Where("scope = ? AND model_name = ? AND provider = ?", tables.ModelConfigScopeGlobal, tables.ModelConfigAllModels, "openai"). + Count(&count).Error) + assert.Equal(t, int64(1), count, "re-run must not duplicate the wildcard config") +} diff --git a/framework/configstore/rdb.go b/framework/configstore/rdb.go index 51483396e00..64bf5a9b743 100644 --- a/framework/configstore/rdb.go +++ b/framework/configstore/rdb.go @@ -109,6 +109,14 @@ func lockBudgetOwner(ctx context.Context, txDB *gorm.DB, budget tables.TableBudg } return err } + case budget.CustomerID != nil && *budget.CustomerID != "": + var customer tables.TableCustomer + if err := dbForUpdate(txDB.WithContext(ctx)).First(&customer, "id = ?", *budget.CustomerID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotFound + } + return err + } } return nil } @@ -1155,6 +1163,45 @@ func (s *RDBConfigStore) DeleteProvider(ctx context.Context, provider schemas.Mo } } + // Clean up model configs scoped to this provider (and their owned budgets/rate-limits). + // Delete by snapshotted IDs rather than a second WHERE provider=? pass to avoid a race + // where a concurrent CreateModelConfig lands between the snapshot and the delete, leaving + // its owned budget/rate-limit rows dangling. + var providerModelConfigs []tables.TableModelConfig + if err := txDB.WithContext(ctx).Preload("Budgets").Where("provider = ?", string(provider)).Find(&providerModelConfigs).Error; err != nil { + return err + } + if len(providerModelConfigs) > 0 { + var mcIDs []string + var budgetIDs []string + var rateLimitIDs []string + for i := range providerModelConfigs { + mcIDs = append(mcIDs, providerModelConfigs[i].ID) + for j := range providerModelConfigs[i].Budgets { + budgetIDs = append(budgetIDs, providerModelConfigs[i].Budgets[j].ID) + } + if providerModelConfigs[i].BudgetID != nil { + budgetIDs = append(budgetIDs, *providerModelConfigs[i].BudgetID) + } + if providerModelConfigs[i].RateLimitID != nil { + rateLimitIDs = append(rateLimitIDs, *providerModelConfigs[i].RateLimitID) + } + } + if err := txDB.WithContext(ctx).Where("id IN ?", mcIDs).Delete(&tables.TableModelConfig{}).Error; err != nil { + return err + } + if len(budgetIDs) > 0 { + if err := txDB.WithContext(ctx).Delete(&tables.TableBudget{}, "id IN ?", budgetIDs).Error; err != nil { + return err + } + } + if len(rateLimitIDs) > 0 { + if err := txDB.WithContext(ctx).Delete(&tables.TableRateLimit{}, "id IN ?", rateLimitIDs).Error; err != nil { + return err + } + } + } + return nil } @@ -2537,7 +2584,7 @@ func preloadCustomerRelations(db *gorm.DB, prefix string) *gorm.DB { return db. Preload(relation("Teams")). Preload(relation("Teams.Budgets")). - Preload(relation("Budget")). + Preload(relation("Budgets")). Preload(relation("RateLimit")). Preload(relation("VirtualKeys")) } @@ -2956,6 +3003,42 @@ func (s *RDBConfigStore) DeleteVirtualKey(ctx context.Context, id string, tx ... if err := txDB.WithContext(ctx).Where("virtual_key_id = ?", id).Delete(&tables.TableBudget{}).Error; err != nil { return err } + // Delete model configs scoped to this virtual key, along with their owned + // budgets/rate-limits. scope_id has no FK constraint, so this cleanup must be + // explicit; otherwise per-VK model limits would orphan and leak budget/rate-limit rows. + // Model configs are deleted first (matching DeleteModelConfig order) before their + // owned budget/rate-limit rows. + var scopedModelConfigs []tables.TableModelConfig + if err := txDB.WithContext(ctx). + Where("scope = ? AND scope_id = ?", tables.ModelConfigScopeVirtualKey, id). + Find(&scopedModelConfigs).Error; err != nil { + return err + } + budgetIDs := make([]string, 0, len(scopedModelConfigs)) + rateLimitIDs := make([]string, 0, len(scopedModelConfigs)) + for _, mc := range scopedModelConfigs { + if mc.BudgetID != nil { + budgetIDs = append(budgetIDs, *mc.BudgetID) + } + if mc.RateLimitID != nil { + rateLimitIDs = append(rateLimitIDs, *mc.RateLimitID) + } + } + if err := txDB.WithContext(ctx). + Where("scope = ? AND scope_id = ?", tables.ModelConfigScopeVirtualKey, id). + Delete(&tables.TableModelConfig{}).Error; err != nil { + return err + } + if len(budgetIDs) > 0 { + if err := txDB.WithContext(ctx).Delete(&tables.TableBudget{}, "id IN ?", budgetIDs).Error; err != nil { + return err + } + } + if len(rateLimitIDs) > 0 { + if err := txDB.WithContext(ctx).Delete(&tables.TableRateLimit{}, "id IN ?", rateLimitIDs).Error; err != nil { + return err + } + } rateLimitID := virtualKey.RateLimitID // Delete the virtual key if err := txDB.WithContext(ctx).Delete(&tables.TableVirtualKey{}, "id = ?", id).Error; err != nil { @@ -3468,40 +3551,39 @@ func (s *RDBConfigStore) UpdateTeam(ctx context.Context, team *tables.TableTeam, // DeleteTeam deletes a team from the database. // Owned budgets cascade via the governance_budgets.team_id FK. // Rate limit is a sibling row (team holds a FK to it) — deleted explicitly. -func (s *RDBConfigStore) DeleteTeam(ctx context.Context, id string) error { - if err := s.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var team tables.TableTeam - if err := dbForUpdate(tx.WithContext(ctx)).Preload("RateLimit").First(&team, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ErrNotFound - } - return err - } - // Set team_id to null for all virtual keys associated with the team - if err := tx.WithContext(ctx).Model(&tables.TableVirtualKey{}).Where("team_id = ?", id).Update("team_id", nil).Error; err != nil { - return err - } - rateLimitID := team.RateLimitID - // Delete the team — owned budgets cascade via FK on governance_budgets.team_id - if err := tx.WithContext(ctx).Delete(&tables.TableTeam{}, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ErrNotFound - } - return err - } - // Delete the team's rate limit if it exists - if rateLimitID != nil { - if err := tx.WithContext(ctx).Delete(&tables.TableRateLimit{}, "id = ?", *rateLimitID).Error; err != nil { - return err - } +func (s *RDBConfigStore) DeleteTeam(ctx context.Context, id string, tx ...*gorm.DB) error { + if len(tx) == 0 || tx[0] == nil { + return s.DB().WithContext(ctx).Transaction(func(transaction *gorm.DB) error { + return s.DeleteTeam(ctx, id, transaction) + }) + } + + txDB := tx[0] + var team tables.TableTeam + if err := dbForUpdate(txDB.WithContext(ctx)).Preload("RateLimit").First(&team, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotFound } - return nil - }); err != nil { + return err + } + // Set team_id to null for all virtual keys associated with the team + if err := txDB.WithContext(ctx).Model(&tables.TableVirtualKey{}).Where("team_id = ?", id).Update("team_id", nil).Error; err != nil { + return err + } + rateLimitID := team.RateLimitID + // Delete the team - owned budgets cascade via FK on governance_budgets.team_id + if err := txDB.WithContext(ctx).Delete(&tables.TableTeam{}, "id = ?", id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrNotFound } return err } + // Delete the team's rate limit if it exists + if rateLimitID != nil { + if err := txDB.WithContext(ctx).Delete(&tables.TableRateLimit{}, "id = ?", *rateLimitID).Error; err != nil { + return err + } + } return nil } @@ -3601,52 +3683,46 @@ func (s *RDBConfigStore) UpdateCustomer(ctx context.Context, customer *tables.Ta } // DeleteCustomer deletes a customer from the database. -func (s *RDBConfigStore) DeleteCustomer(ctx context.Context, id string) error { - if err := s.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var customer tables.TableCustomer - if err := dbForUpdate(tx.WithContext(ctx)).Preload("Budget").Preload("RateLimit").First(&customer, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ErrNotFound - } - return err - } - // Set customer_id to null for all virtual keys associated with the customer - if err := tx.WithContext(ctx).Model(&tables.TableVirtualKey{}).Where("customer_id = ?", id).Update("customer_id", nil).Error; err != nil { - return err - } - // Set customer_id to null for all teams associated with the customer - if err := tx.WithContext(ctx).Model(&tables.TableTeam{}).Where("customer_id = ?", id).Update("customer_id", nil).Error; err != nil { - return err - } - // Store the budget and rate limit IDs before deleting the customer - budgetID := customer.BudgetID - rateLimitID := customer.RateLimitID - // Delete the customer first - if err := tx.WithContext(ctx).Delete(&tables.TableCustomer{}, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ErrNotFound - } - return err - } - // Delete the customer's budget if it exists - if budgetID != nil { - if err := tx.WithContext(ctx).Delete(&tables.TableBudget{}, "id = ?", *budgetID).Error; err != nil { - return err - } - } - // Delete the customer's rate limit if it exists - if rateLimitID != nil { - if err := tx.WithContext(ctx).Delete(&tables.TableRateLimit{}, "id = ?", *rateLimitID).Error; err != nil { - return err - } +func (s *RDBConfigStore) DeleteCustomer(ctx context.Context, id string, tx ...*gorm.DB) error { + if len(tx) == 0 || tx[0] == nil { + return s.DB().WithContext(ctx).Transaction(func(transaction *gorm.DB) error { + return s.DeleteCustomer(ctx, id, transaction) + }) + } + + txDB := tx[0] + var customer tables.TableCustomer + if err := dbForUpdate(txDB.WithContext(ctx)).Preload("RateLimit").First(&customer, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotFound } - return nil - }); err != nil { + return err + } + // Null out customer_id on associated VKs and teams before deleting the customer row. + if err := txDB.WithContext(ctx).Model(&tables.TableVirtualKey{}).Where("customer_id = ?", id).Update("customer_id", nil).Error; err != nil { + return err + } + // Set customer_id to null for all teams associated with the customer + if err := txDB.WithContext(ctx).Model(&tables.TableTeam{}).Where("customer_id = ?", id).Update("customer_id", nil).Error; err != nil { + return err + } + rateLimitID := customer.RateLimitID + // Explicitly delete owned budgets before the customer row. FK cascades cannot + // be relied on across all dialects for constraints added to pre-existing tables. + if err := txDB.WithContext(ctx).Where("customer_id = ?", id).Delete(&tables.TableBudget{}).Error; err != nil { + return err + } + if err := txDB.WithContext(ctx).Delete(&tables.TableCustomer{}, "id = ?", id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrNotFound } return err } + if rateLimitID != nil { + if err := txDB.WithContext(ctx).Delete(&tables.TableRateLimit{}, "id = ?", *rateLimitID).Error; err != nil { + return err + } + } return nil } @@ -3843,6 +3919,9 @@ func (s *RDBConfigStore) UpdateBudget(ctx context.Context, budget *tables.TableB if ownerBudget.TeamID == nil { ownerBudget.TeamID = existing.TeamID } + if ownerBudget.CustomerID == nil { + ownerBudget.CustomerID = existing.CustomerID + } if err := lockBudgetOwner(ctx, txDB, ownerBudget); err != nil { return err } @@ -3892,8 +3971,12 @@ func (s *RDBConfigStore) DeleteBudget(ctx context.Context, id string, tx ...*gor // UpdateBudgetUsage updates only the current_usage field of a budget. // Uses SkipHooks to avoid triggering BeforeSave validation since we're only updating usage. -func (s *RDBConfigStore) UpdateBudgetUsage(ctx context.Context, id string, currentUsage float64) error { - result := s.DB().WithContext(ctx). +func (s *RDBConfigStore) UpdateBudgetUsage(ctx context.Context, id string, currentUsage float64, tx ...*gorm.DB) error { + db := s.DB() + if len(tx) > 0 && tx[0] != nil { + db = tx[0] + } + result := db.WithContext(ctx). Session(&gorm.Session{SkipHooks: true}). Model(&tables.TableBudget{}). Where("id = ?", id). @@ -3909,8 +3992,12 @@ func (s *RDBConfigStore) UpdateBudgetUsage(ctx context.Context, id string, curre // UpdateRateLimitUsage updates only the usage fields of a rate limit. // Uses SkipHooks to avoid triggering BeforeSave validation since we're only updating usage. -func (s *RDBConfigStore) UpdateRateLimitUsage(ctx context.Context, id string, tokenCurrentUsage int64, requestCurrentUsage int64) error { - result := s.DB().WithContext(ctx). +func (s *RDBConfigStore) UpdateRateLimitUsage(ctx context.Context, id string, tokenCurrentUsage int64, requestCurrentUsage int64, tx ...*gorm.DB) error { + db := s.DB() + if len(tx) > 0 && tx[0] != nil { + db = tx[0] + } + result := db.WithContext(ctx). Session(&gorm.Session{SkipHooks: true}). Model(&tables.TableRateLimit{}). Where("id = ?", id). @@ -4193,7 +4280,33 @@ func (s *RDBConfigStore) DeleteRoutingRule(ctx context.Context, id string, tx .. // GetModelConfigs retrieves all model configs from the database. func (s *RDBConfigStore) GetModelConfigs(ctx context.Context) ([]tables.TableModelConfig, error) { var modelConfigs []tables.TableModelConfig - if err := s.DB().WithContext(ctx).Preload("Budget").Preload("RateLimit").Find(&modelConfigs).Error; err != nil { + if err := s.DB().WithContext(ctx).Preload("Budgets").Preload("Budget").Preload("RateLimit").Find(&modelConfigs).Error; err != nil { + return nil, err + } + return modelConfigs, nil +} + +// GetModelConfigsByScopeAndScopeIDs retrieves model configs for a specific scope limited to the given scope IDs. +func (s *RDBConfigStore) GetModelConfigsByScopeAndScopeIDs(ctx context.Context, scope string, scopeIDs []string) ([]tables.TableModelConfig, error) { + if len(scopeIDs) == 0 { + return nil, nil + } + var modelConfigs []tables.TableModelConfig + if err := s.DB().WithContext(ctx).Preload("Budgets").Preload("Budget").Preload("RateLimit"). + Where("scope = ? AND scope_id IN ?", scope, scopeIDs). + Find(&modelConfigs).Error; err != nil { + return nil, err + } + return modelConfigs, nil +} + +// GetProviderGovernanceModelConfigs retrieves the wildcard "all models on a provider" configs +func (s *RDBConfigStore) GetProviderGovernanceModelConfigs(ctx context.Context) ([]tables.TableModelConfig, error) { + var modelConfigs []tables.TableModelConfig + if err := s.DB().WithContext(ctx). + Preload("Budgets").Preload("Budget").Preload("RateLimit"). + Where("scope = ? AND model_name = ? AND provider IS NOT NULL", tables.ModelConfigScopeGlobal, tables.ModelConfigAllModels). + Find(&modelConfigs).Error; err != nil { return nil, err } return modelConfigs, nil @@ -4207,6 +4320,12 @@ func (s *RDBConfigStore) GetModelConfigsPaginated(ctx context.Context, params Mo search := "%" + strings.ToLower(params.Search) + "%" baseQuery = baseQuery.Where("LOWER(model_name) LIKE ?", search) } + if params.Scope != "" { + baseQuery = baseQuery.Where("scope = ?", params.Scope) + } + if params.Provider != "" { + baseQuery = baseQuery.Where("provider = ?", params.Provider) + } var totalCount int64 if err := baseQuery.Count(&totalCount).Error; err != nil { @@ -4228,9 +4347,10 @@ func (s *RDBConfigStore) GetModelConfigsPaginated(ctx context.Context, params Mo var modelConfigs []tables.TableModelConfig if err := baseQuery. + Preload("Budgets"). Preload("Budget"). Preload("RateLimit"). - Order("created_at ASC, id ASC"). + Order("created_at DESC, id DESC"). Offset(offset). Limit(limit). Find(&modelConfigs).Error; err != nil { @@ -4239,16 +4359,22 @@ func (s *RDBConfigStore) GetModelConfigsPaginated(ctx context.Context, params Mo return modelConfigs, totalCount, nil } -// GetModelConfig retrieves a specific model config from the database by model name and optional provider. -func (s *RDBConfigStore) GetModelConfig(ctx context.Context, modelName string, provider *string) (*tables.TableModelConfig, error) { +// GetModelConfig retrieves a specific model config from the database by its identity: +// scope, optional scope ID, model name, and optional provider. +func (s *RDBConfigStore) GetModelConfig(ctx context.Context, scope string, scopeID *string, modelName string, provider *string) (*tables.TableModelConfig, error) { var modelConfig tables.TableModelConfig - query := s.DB().WithContext(ctx).Where("model_name = ?", modelName) + query := s.DB().WithContext(ctx).Where("model_name = ?", modelName).Where("scope = ?", scope) + if scopeID != nil { + query = query.Where("scope_id = ?", *scopeID) + } else { + query = query.Where("scope_id IS NULL") + } if provider != nil { query = query.Where("provider = ?", *provider) } else { query = query.Where("provider IS NULL") } - if err := query.Preload("Budget").Preload("RateLimit").First(&modelConfig).Error; err != nil { + if err := query.Preload("Budgets").Preload("Budget").Preload("RateLimit").First(&modelConfig).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound } @@ -4260,7 +4386,7 @@ func (s *RDBConfigStore) GetModelConfig(ctx context.Context, modelName string, p // GetModelConfigByID retrieves a specific model config from the database by ID. func (s *RDBConfigStore) GetModelConfigByID(ctx context.Context, id string) (*tables.TableModelConfig, error) { var modelConfig tables.TableModelConfig - if err := s.DB().WithContext(ctx).Preload("Budget").Preload("RateLimit").First(&modelConfig, "id = ?", id).Error; err != nil { + if err := s.DB().WithContext(ctx).Preload("Budgets").Preload("Budget").Preload("RateLimit").First(&modelConfig, "id = ?", id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound } @@ -4301,7 +4427,9 @@ func (s *RDBConfigStore) UpdateModelConfig(ctx context.Context, modelConfig *tab return err } } - if err := txDB.WithContext(ctx).Save(modelConfig).Error; err != nil { + // Omit associations: budgets (has-many via ModelConfigID) and rate-limit are managed + // explicitly by callers. A cascading Save would otherwise clobber their usage counters. + if err := txDB.WithContext(ctx).Omit(clause.Associations).Save(modelConfig).Error; err != nil { return s.parseGormError(err) } return nil @@ -4327,40 +4455,53 @@ func (s *RDBConfigStore) UpdateModelConfigs(ctx context.Context, modelConfigs [] } // DeleteModelConfig deletes a model config from the database. -func (s *RDBConfigStore) DeleteModelConfig(ctx context.Context, id string) error { - return s.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // First fetch the model config to get budget and rate limit IDs - var modelConfig tables.TableModelConfig - if err := dbForUpdate(tx).First(&modelConfig, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ErrNotFound - } - return err +func (s *RDBConfigStore) DeleteModelConfig(ctx context.Context, id string, tx ...*gorm.DB) error { + if len(tx) == 0 || tx[0] == nil { + return s.DB().WithContext(ctx).Transaction(func(transaction *gorm.DB) error { + return s.DeleteModelConfig(ctx, id, transaction) + }) + } + + txDB := tx[0] + // Fetch the model config with its owned budgets to collect all IDs to clean up. + var modelConfig tables.TableModelConfig + if err := dbForUpdate(txDB.WithContext(ctx)).Preload("Budgets").First(&modelConfig, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotFound } - // Store the budget and rate limit IDs before deleting - budgetID := modelConfig.BudgetID - rateLimitID := modelConfig.RateLimitID - // Delete the model config first - if err := tx.Delete(&tables.TableModelConfig{}, "id = ?", id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ErrNotFound - } - return s.parseGormError(err) + return err + } + // Collect budget IDs from both the Budgets slice (active path, owned via ModelConfigID) + // and the legacy single BudgetID column. + budgetIDs := make([]string, 0, len(modelConfig.Budgets)+1) + for i := range modelConfig.Budgets { + budgetIDs = append(budgetIDs, modelConfig.Budgets[i].ID) + } + if modelConfig.BudgetID != nil { + budgetIDs = append(budgetIDs, *modelConfig.BudgetID) + } + rateLimitID := modelConfig.RateLimitID + // Delete the model config first + if err := txDB.WithContext(ctx).Delete(&tables.TableModelConfig{}, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotFound } - // Delete the budget if it exists - if budgetID != nil { - if err := tx.Delete(&tables.TableBudget{}, "id = ?", *budgetID).Error; err != nil { - return err - } + return s.parseGormError(err) + } + // Delete the owned budgets (don't rely on FK cascade — it isn't applied to + // pre-existing tables on all dialects). + if len(budgetIDs) > 0 { + if err := txDB.WithContext(ctx).Delete(&tables.TableBudget{}, "id IN ?", budgetIDs).Error; err != nil { + return err } - // Delete the rate limit if it exists - if rateLimitID != nil { - if err := tx.Delete(&tables.TableRateLimit{}, "id = ?", *rateLimitID).Error; err != nil { - return err - } + } + // Delete the rate limit if it exists + if rateLimitID != nil { + if err := txDB.WithContext(ctx).Delete(&tables.TableRateLimit{}, "id = ?", *rateLimitID).Error; err != nil { + return err } - return nil - }) + } + return nil } // GetGovernanceConfig retrieves the governance configuration from the database. diff --git a/framework/configstore/rdb_test.go b/framework/configstore/rdb_test.go index a73ad602270..227c437d4e3 100644 --- a/framework/configstore/rdb_test.go +++ b/framework/configstore/rdb_test.go @@ -32,6 +32,7 @@ func setupRDBTestStore(t *testing.T) *RDBConfigStore { &tables.TableVirtualKey{}, &tables.TableVirtualKeyProviderConfig{}, &tables.TableVirtualKeyProviderConfigKey{}, + &tables.TableModelConfig{}, &tables.TableCustomer{}, &tables.TableTeam{}, &tables.TableClientConfig{}, @@ -641,6 +642,97 @@ func TestDeleteVirtualKey(t *testing.T) { assert.Error(t, err, "Should not find deleted virtual key") } +func TestDeleteVirtualKey_CleansUpScopedModelConfigs(t *testing.T) { + store := setupRDBTestStore(t) + ctx := context.Background() + + vk := &tables.TableVirtualKey{ + ID: "vk-scoped", + Name: "Scoped VK", + Value: "vk-scoped-value", + IsActive: schemas.Ptr(true), + } + require.NoError(t, store.CreateVirtualKey(ctx, vk)) + + budget := &tables.TableBudget{ID: "b-scoped", MaxLimit: 100, ResetDuration: "1h"} + require.NoError(t, store.CreateBudget(ctx, budget)) + rateLimit := &tables.TableRateLimit{ + ID: "rl-scoped", + TokenMaxLimit: schemas.Ptr(int64(1000)), + TokenResetDuration: schemas.Ptr("1h"), + } + require.NoError(t, store.CreateRateLimit(ctx, rateLimit)) + + mc := &tables.TableModelConfig{ + ID: "mc-scoped", + ModelName: "gpt-4", + Scope: tables.ModelConfigScopeVirtualKey, + ScopeID: schemas.Ptr(vk.ID), + BudgetID: &budget.ID, + RateLimitID: &rateLimit.ID, + } + require.NoError(t, store.CreateModelConfig(ctx, mc)) + + // Sanity: the scoped config exists before deletion. + _, err := store.GetModelConfigByID(ctx, "mc-scoped") + require.NoError(t, err) + + // Deleting the VK must cascade-clean its scoped model config and owned budget/rate-limit. + require.NoError(t, store.DeleteVirtualKey(ctx, vk.ID)) + + _, err = store.GetModelConfigByID(ctx, "mc-scoped") + assert.Error(t, err, "scoped model config should be deleted with the VK") + + var budgetCount int64 + require.NoError(t, store.DB().Model(&tables.TableBudget{}).Where("id = ?", "b-scoped").Count(&budgetCount).Error) + assert.Equal(t, int64(0), budgetCount, "owned budget should be deleted") + + var rlCount int64 + require.NoError(t, store.DB().Model(&tables.TableRateLimit{}).Where("id = ?", "rl-scoped").Count(&rlCount).Error) + assert.Equal(t, int64(0), rlCount, "owned rate limit should be deleted") +} + +func TestDeleteProvider_CleansUpProviderModelConfigs(t *testing.T) { + store := setupRDBTestStore(t) + ctx := context.Background() + + now := time.Now() + require.NoError(t, store.DB().Create(&tables.TableProvider{Name: "openai", CreatedAt: now, UpdatedAt: now}).Error) + require.NoError(t, store.DB().Create(&tables.TableBudget{ID: "pb", MaxLimit: 100, ResetDuration: "1M", LastReset: now, CreatedAt: now, UpdatedAt: now}).Error) + require.NoError(t, store.DB().Create(&tables.TableRateLimit{ID: "prl", TokenMaxLimit: schemas.Ptr(int64(1000)), TokenResetDuration: schemas.Ptr("1h"), TokenLastReset: now, RequestLastReset: now, CreatedAt: now, UpdatedAt: now}).Error) + + providerName := "openai" + mc := &tables.TableModelConfig{ + ID: "mc-wildcard", + ModelName: tables.ModelConfigAllModels, + Provider: &providerName, + Scope: tables.ModelConfigScopeGlobal, + BudgetID: schemas.Ptr("pb"), + RateLimitID: schemas.Ptr("prl"), + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, store.CreateModelConfig(ctx, mc)) + + require.NoError(t, store.DeleteProvider(ctx, schemas.ModelProvider("openai"))) + + // The provider's wildcard model config and its owned budget/rate-limit are cleaned up. + _, err := store.GetModelConfigByID(ctx, "mc-wildcard") + assert.Error(t, err, "provider-scoped model config should be deleted with the provider") + for _, q := range []struct { + model any + id string + label string + }{ + {&tables.TableBudget{}, "pb", "budget"}, + {&tables.TableRateLimit{}, "prl", "rate limit"}, + } { + var count int64 + require.NoError(t, store.DB().Model(q.model).Where("id = ?", q.id).Count(&count).Error) + assert.Equal(t, int64(0), count, "owned "+q.label+" should be deleted") + } +} + // ============================================================================= // Virtual Key Provider Config Tests // ============================================================================= diff --git a/framework/configstore/store.go b/framework/configstore/store.go index 789ddf8796c..8b447f56ce0 100644 --- a/framework/configstore/store.go +++ b/framework/configstore/store.go @@ -28,9 +28,11 @@ type VirtualKeyQueryParams struct { // ModelConfigsQueryParams holds pagination, filtering, and search parameters for model configs queries. type ModelConfigsQueryParams struct { - Limit int - Offset int - Search string + Limit int + Offset int + Search string + Scope string // optional; filters to an exact scope value (e.g. "global", "virtual_key") + Provider string // optional; filters to an exact provider value (e.g. "openai") } // RoutingRulesQueryParams holds pagination, filtering, and search parameters for routing rules queries. @@ -216,7 +218,7 @@ type ConfigStore interface { GetTeamBySourceID(ctx context.Context, sourceID string) (*tables.TableTeam, error) CreateTeam(ctx context.Context, team *tables.TableTeam, tx ...*gorm.DB) error UpdateTeam(ctx context.Context, team *tables.TableTeam, tx ...*gorm.DB) error - DeleteTeam(ctx context.Context, id string) error + DeleteTeam(ctx context.Context, id string, tx ...*gorm.DB) error // Customer CRUD GetCustomers(ctx context.Context) ([]tables.TableCustomer, error) @@ -224,7 +226,7 @@ type ConfigStore interface { GetCustomer(ctx context.Context, id string) (*tables.TableCustomer, error) CreateCustomer(ctx context.Context, customer *tables.TableCustomer, tx ...*gorm.DB) error UpdateCustomer(ctx context.Context, customer *tables.TableCustomer, tx ...*gorm.DB) error - DeleteCustomer(ctx context.Context, id string) error + DeleteCustomer(ctx context.Context, id string, tx ...*gorm.DB) error // Rate limit CRUD GetRateLimits(ctx context.Context) ([]tables.TableRateLimit, error) @@ -241,8 +243,8 @@ type ConfigStore interface { UpdateBudget(ctx context.Context, budget *tables.TableBudget, tx ...*gorm.DB) error UpdateBudgets(ctx context.Context, budgets []*tables.TableBudget, tx ...*gorm.DB) error DeleteBudget(ctx context.Context, id string, tx ...*gorm.DB) error - UpdateBudgetUsage(ctx context.Context, id string, currentUsage float64) error - UpdateRateLimitUsage(ctx context.Context, id string, tokenCurrentUsage int64, requestCurrentUsage int64) error + UpdateBudgetUsage(ctx context.Context, id string, currentUsage float64, tx ...*gorm.DB) error + UpdateRateLimitUsage(ctx context.Context, id string, tokenCurrentUsage int64, requestCurrentUsage int64, tx ...*gorm.DB) error // Routing Rules CRUD GetRoutingRules(ctx context.Context) ([]tables.TableRoutingRule, error) @@ -256,13 +258,15 @@ type ConfigStore interface { // Model config CRUD GetModelConfigs(ctx context.Context) ([]tables.TableModelConfig, error) + GetModelConfigsByScopeAndScopeIDs(ctx context.Context, scope string, scopeIDs []string) ([]tables.TableModelConfig, error) + GetProviderGovernanceModelConfigs(ctx context.Context) ([]tables.TableModelConfig, error) GetModelConfigsPaginated(ctx context.Context, params ModelConfigsQueryParams) ([]tables.TableModelConfig, int64, error) - GetModelConfig(ctx context.Context, modelName string, provider *string) (*tables.TableModelConfig, error) + GetModelConfig(ctx context.Context, scope string, scopeID *string, modelName string, provider *string) (*tables.TableModelConfig, error) GetModelConfigByID(ctx context.Context, id string) (*tables.TableModelConfig, error) CreateModelConfig(ctx context.Context, modelConfig *tables.TableModelConfig, tx ...*gorm.DB) error UpdateModelConfig(ctx context.Context, modelConfig *tables.TableModelConfig, tx ...*gorm.DB) error UpdateModelConfigs(ctx context.Context, modelConfigs []*tables.TableModelConfig, tx ...*gorm.DB) error - DeleteModelConfig(ctx context.Context, id string) error + DeleteModelConfig(ctx context.Context, id string, tx ...*gorm.DB) error // Governance config CRUD GetGovernanceConfig(ctx context.Context) (*GovernanceConfig, error) diff --git a/framework/configstore/tables/budget.go b/framework/configstore/tables/budget.go index 015ec6e6963..4cf8d86d3e7 100644 --- a/framework/configstore/tables/budget.go +++ b/framework/configstore/tables/budget.go @@ -15,10 +15,12 @@ type TableBudget struct { LastReset time.Time `gorm:"index" json:"last_reset"` // Last time budget was reset CurrentUsage float64 `gorm:"default:0" json:"current_usage"` // Current usage in dollars - // Owner FKs: a budget belongs to at most one Team, one VK, or one ProviderConfig + // Owner FKs: a budget belongs to at most one Team, VK, ProviderConfig, ModelConfig, or Customer TeamID *string `gorm:"type:varchar(255);index" json:"team_id,omitempty"` VirtualKeyID *string `gorm:"type:varchar(255);index" json:"virtual_key_id,omitempty"` ProviderConfigID *uint `gorm:"index" json:"provider_config_id,omitempty"` + ModelConfigID *string `gorm:"type:varchar(255);index" json:"model_config_id,omitempty"` + CustomerID *string `gorm:"type:varchar(255);index" json:"customer_id,omitempty"` // Deprecated: set calendar_aligned on the parent access profile / VK / team // instead. Kept for backward compatibility with older config.json files; @@ -57,8 +59,14 @@ func (b *TableBudget) BeforeSave(tx *gorm.DB) error { if b.ProviderConfigID != nil { owners++ } + if b.ModelConfigID != nil { + owners++ + } + if b.CustomerID != nil { + owners++ + } if owners > 1 { - return fmt.Errorf("budget cannot have more than one owner (team/virtual key/provider config)") + return fmt.Errorf("budget cannot have more than one owner (team/virtual key/provider config/model config/customer)") } // Validate that ResetDuration is in correct format (e.g., "30s", "5m", "1h", "1d", "1w", "1M", "1Y") if d, err := ParseDuration(b.ResetDuration); err != nil { diff --git a/framework/configstore/tables/customer.go b/framework/configstore/tables/customer.go index c3b9a6f08cb..f70574f59a5 100644 --- a/framework/configstore/tables/customer.go +++ b/framework/configstore/tables/customer.go @@ -1,20 +1,28 @@ package tables -import "time" +import ( + "time" -// TableCustomer represents a customer entity with budget and rate limit + "gorm.io/gorm" +) + +// TableCustomer represents a customer entity with budgets, rate limit and team/VK association type TableCustomer struct { ID string `gorm:"primaryKey;type:varchar(255)" json:"id"` Name string `gorm:"type:varchar(255);not null" json:"name"` - BudgetID *string `gorm:"type:varchar(255);index" json:"budget_id,omitempty"` RateLimitID *string `gorm:"type:varchar(255);index" json:"rate_limit_id,omitempty"` + // BudgetID is a config-file-only field referencing a pre-declared budget (from governance.budgets) to link to this customer. Not persisted; used by the config sync path to set customer_id on the referenced budget row. + BudgetID *string `gorm:"-" json:"budget_id,omitempty"` + // Relationships - Budget *TableBudget `gorm:"foreignKey:BudgetID" json:"budget,omitempty"` + Budgets []TableBudget `gorm:"foreignKey:CustomerID;constraint:OnDelete:CASCADE" json:"budgets,omitempty"` RateLimit *TableRateLimit `gorm:"foreignKey:RateLimitID" json:"rate_limit,omitempty"` Teams []TableTeam `gorm:"foreignKey:CustomerID" json:"teams"` VirtualKeys []TableVirtualKey `gorm:"foreignKey:CustomerID" json:"virtual_keys"` + CalendarAligned bool `gorm:"default:false" json:"calendar_aligned"` + // Config hash is used to detect the changes synced from config.json file // Every time we sync the config.json file, we will update the config hash ConfigHash string `gorm:"type:varchar(255);null" json:"config_hash"` @@ -25,3 +33,15 @@ type TableCustomer struct { // TableName sets the table name for each model func (TableCustomer) TableName() string { return "governance_customers" } + +// AfterFind stamps IsCalendarAligned on owned budgets and rate limit so the +// reset path (which reads the derived field off those objects) sees the correct value. +func (c *TableCustomer) AfterFind(tx *gorm.DB) error { + for i := range c.Budgets { + c.Budgets[i].IsCalendarAligned = c.CalendarAligned + } + if c.RateLimit != nil { + c.RateLimit.IsCalendarAligned = c.CalendarAligned + } + return nil +} diff --git a/framework/configstore/tables/modelconfig.go b/framework/configstore/tables/modelconfig.go index 5e6b5ba6dc8..d1ff6732ecd 100644 --- a/framework/configstore/tables/modelconfig.go +++ b/framework/configstore/tables/modelconfig.go @@ -3,20 +3,90 @@ package tables import ( "fmt" "strings" + "sync" "time" "gorm.io/gorm" ) +// Model config scope values. Scope determines where a model config applies. +const ( + ModelConfigScopeGlobal = "global" + ModelConfigScopeVirtualKey = "virtual_key" + ModelConfigScopeUser = "user" +) + +// ModelConfigAllModels is the model_name sentinel meaning "all models". Combined with a +// specific provider it expresses provider-level governance (all models on that provider); +// with a nil provider it means all models on all providers. +const ModelConfigAllModels = "*" + +// validModelConfigScopes is the runtime registry of accepted scope values. +// OSS seeds it with global + virtual_key; downstream consumers (e.g. the +// enterprise build registering "user") extend it at startup via +// RegisterModelConfigScope. Guarded by validModelConfigScopesMu. +var ( + validModelConfigScopesMu sync.RWMutex + validModelConfigScopes = map[string]bool{ + ModelConfigScopeGlobal: true, + ModelConfigScopeVirtualKey: true, + } +) + +// RegisterModelConfigScope adds scope to the allow-list consulted by +// IsValidModelConfigScope and TableModelConfig.BeforeSave. Intended to be +// called once at process startup; safe to call concurrently. Whitespace- +// only input is ignored. +func RegisterModelConfigScope(scope string) { + s := strings.TrimSpace(scope) + if s == "" { + return + } + validModelConfigScopesMu.Lock() + validModelConfigScopes[s] = true + validModelConfigScopesMu.Unlock() +} + +// IsValidModelConfigScope reports whether scope is a recognized model config scope. +func IsValidModelConfigScope(scope string) bool { + validModelConfigScopesMu.RLock() + defer validModelConfigScopesMu.RUnlock() + return validModelConfigScopes[scope] +} + // TableModelConfig represents a model configuration with rate limiting and budgeting type TableModelConfig struct { - ID string `gorm:"primaryKey;type:varchar(255)" json:"id"` - ModelName string `gorm:"type:varchar(255);not null;uniqueIndex:idx_model_provider" json:"model_name"` - Provider *string `gorm:"type:varchar(50);uniqueIndex:idx_model_provider" json:"provider,omitempty"` // Optional provider, nullable - BudgetID *string `gorm:"type:varchar(255);index:idx_model_config_budget" json:"budget_id,omitempty"` - RateLimitID *string `gorm:"type:varchar(255);index:idx_model_config_rate_limit" json:"rate_limit_id,omitempty"` + ID string `gorm:"primaryKey;type:varchar(255)" json:"id"` + ModelName string `gorm:"type:varchar(255);not null;uniqueIndex:idx_model_scope_provider,priority:3" json:"model_name"` + Provider *string `gorm:"type:varchar(50);uniqueIndex:idx_model_scope_provider,priority:4" json:"provider,omitempty"` // Optional provider, nullable + // Scope determines where this config applies: "global" (default) or "virtual_key". + Scope string `gorm:"type:varchar(50);not null;default:'global';uniqueIndex:idx_model_scope_provider,priority:1" json:"scope"` + // ScopeID is the target of a non-global scope (e.g. the virtual key ID). NULL for global. + ScopeID *string `gorm:"type:varchar(255);uniqueIndex:idx_model_scope_provider,priority:2" json:"scope_id,omitempty"` + // CalendarAligned snaps this config's budget resets to calendar boundaries (e.g. a + // monthly budget resets on the 1st) rather than rolling windows. Propagated to owned + // budgets via AfterFind. For virtual_key-scoped configs it inherits the VK's setting. + CalendarAligned bool `gorm:"not null;default:false" json:"calendar_aligned"` + BudgetID *string `gorm:"type:varchar(255);index:idx_model_config_budget" json:"budget_id,omitempty"` + RateLimitID *string `gorm:"type:varchar(255);index:idx_model_config_rate_limit" json:"rate_limit_id,omitempty"` + + // ScopeName is a non-persisted, API-only field carrying the human-readable name of + // the scope target (e.g. the virtual key's name) so the UI can render a label + // instead of an opaque scope_id. Populated by the HTTP layer on read. + ScopeName string `gorm:"-" json:"scope_name,omitempty"` + // BudgetIDs is a config-file-only field listing pre-declared budget IDs (from + // governance.budgets) to link to this model config. Not persisted; used by the + // config sync path to set model_config_id on each referenced budget row. + BudgetIDs []string `gorm:"-" json:"budget_ids,omitempty"` // Relationships + // Budgets are owned by this model config via TableBudget.ModelConfigID (a model + // config may carry multiple budgets with different reset windows). This is the + // active representation. The legacy single Budget/BudgetID below is kept inert + // for backward compatibility and is no longer read by enforcement. + Budgets []TableBudget `gorm:"foreignKey:ModelConfigID;constraint:OnDelete:CASCADE" json:"budgets,omitempty"` + // Legacy (inert): superseded by Budgets. Retained so existing rows/columns keep + // parsing; not read by the governance store after the multi-budget cutover. Budget *TableBudget `gorm:"foreignKey:BudgetID;onDelete:CASCADE" json:"budget,omitempty"` RateLimit *TableRateLimit `gorm:"foreignKey:RateLimitID;onDelete:CASCADE" json:"rate_limit,omitempty"` @@ -33,8 +103,33 @@ func (TableModelConfig) TableName() string { return "governance_model_configs" } +// AfterFind propagates calendar_aligned down to owned budgets so the reset path reads +// the stamped value off each budget. Mirrors TableTeam/TableVirtualKey. The governance +// store's Update*InMemory paths re-stamp on every model-config update. +func (mc *TableModelConfig) AfterFind(tx *gorm.DB) error { + for i := range mc.Budgets { + mc.Budgets[i].IsCalendarAligned = mc.CalendarAligned + } + return nil +} + // BeforeSave hook for ModelConfig to validate required fields func (mc *TableModelConfig) BeforeSave(tx *gorm.DB) error { + // Default and validate scope. Global is the implicit default (preserves + // pre-scope behavior for configs created without an explicit scope). + if strings.TrimSpace(mc.Scope) == "" { + mc.Scope = ModelConfigScopeGlobal + } + if !IsValidModelConfigScope(mc.Scope) { + return fmt.Errorf("invalid scope %q for model config", mc.Scope) + } + // Enforce scope_id rules: global must not have one; non-global requires it. + if mc.Scope == ModelConfigScopeGlobal { + mc.ScopeID = nil + } else if mc.ScopeID == nil || strings.TrimSpace(*mc.ScopeID) == "" { + return fmt.Errorf("scope_id is required when scope is %q", mc.Scope) + } + // Validate that ModelName is not empty if strings.TrimSpace(mc.ModelName) == "" { return fmt.Errorf("model_name cannot be empty") diff --git a/framework/go.mod b/framework/go.mod index 2212b0e0bc2..f7a4cb68ebc 100644 --- a/framework/go.mod +++ b/framework/go.mod @@ -16,7 +16,7 @@ require ( github.com/weaviate/weaviate-go-client/v5 v5.7.1 golang.org/x/crypto v0.52.0 golang.org/x/sync v0.20.0 - google.golang.org/api v0.274.0 + google.golang.org/api v0.282.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 ) @@ -24,7 +24,7 @@ require ( require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/iam v1.5.3 // indirect cloud.google.com/go/monitoring v1.24.3 // indirect @@ -42,10 +42,10 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect github.com/bytedance/gopkg v0.1.3 // indirect - github.com/bytedance/sonic/loader v0.5.0 // indirect - github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect - github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect - github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -62,11 +62,11 @@ require ( github.com/go-openapi/swag/stringutils v0.25.4 // indirect github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect - github.com/googleapis/gax-go/v2 v2.19.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.9.2 // indirect @@ -80,9 +80,9 @@ require ( github.com/tidwall/pretty v1.2.0 // indirect github.com/tidwall/sjson v1.2.5 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.40.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk v1.43.0 // indirect @@ -91,13 +91,13 @@ require ( go.starlark.net v0.0.0-20260102030733-3fee463870c9 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 // indirect + google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect ) require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect - github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect github.com/aws/aws-sdk-go-v2 v1.41.7 github.com/aws/aws-sdk-go-v2/config v1.32.11 github.com/aws/aws-sdk-go-v2/credentials v1.19.14 @@ -113,7 +113,7 @@ require ( github.com/aws/smithy-go v1.25.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect - github.com/bytedance/sonic v1.15.0 + github.com/bytedance/sonic v1.15.1 github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -130,7 +130,7 @@ require ( github.com/invopop/jsonschema v0.13.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/mailru/easyjson v0.9.1 // indirect github.com/mark3labs/mcp-go v0.43.2 // indirect @@ -144,7 +144,7 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.68.0 + github.com/valyala/fasthttp v1.71.0 github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.mongodb.org/mongo-driver v1.17.6 // indirect @@ -153,9 +153,9 @@ require ( golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/grpc v1.80.0 - google.golang.org/protobuf v1.36.11 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect + google.golang.org/grpc v1.81.1 + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/driver/postgres v1.6.0 ) diff --git a/framework/go.sum b/framework/go.sum index ff3d15feb1f..3e70e556265 100644 --- a/framework/go.sum +++ b/framework/go.sum @@ -2,8 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= 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/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= @@ -41,8 +41,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= @@ -94,16 +94,16 @@ github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJ github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= -github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= -github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= -github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= -github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= +github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= 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/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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= @@ -113,12 +113,12 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= -github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= -github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= -github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE= github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -180,11 +180,11 @@ github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6 github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-openapi/validate v0.25.1 h1:sSACUI6Jcnbo5IWqbYHgjibrhhmt3vR6lCzKZnmAgBw= github.com/go-openapi/validate v0.25.1/go.mod h1:RMVyVFYte0gbSTaZ0N4KmTn6u/kClvAFp+mAVfS/DQc= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -195,10 +195,10 @@ 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.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= -github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= -github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= +github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= @@ -218,8 +218,8 @@ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/ github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -301,8 +301,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok= -github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= github.com/weaviate/weaviate v1.36.5 h1:lCiuEfQ08+5wK0DkTCUBb6ayNep9QpBH6JJhmZaRfzk= github.com/weaviate/weaviate v1.36.5/go.mod h1:ljzrgEmGKn3CRzDdcxvhmBUUZIcghwIYd1Lmn54f3Z8= github.com/weaviate/weaviate-go-client/v5 v5.7.1 h1:vEMxh486QqRqWaq58UEe/TiTbGbo9T5x7ZPFd5QENvQ= @@ -317,12 +317,12 @@ go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUps go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= 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/contrib/detectors/gcp v1.40.0 h1:Awaf8gmW99tZTOWqkLCOl6aw1/rxAWVlHsHIZ3fT2sA= -go.opentelemetry.io/contrib/detectors/gcp v1.40.0/go.mod h1:99OY9ZCqyLkzJLTh5XhECpLRSxcZl+ZDKBEO+jMBFR4= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= @@ -361,18 +361,18 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA= -google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= -google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE= -google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw= +google.golang.org/api v0.282.0 h1:WmJiSVqUnKqJCpJOx7YADbXaC+9DDsnGSfllFSj7R2I= +google.golang.org/api v0.282.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/framework/modelcatalog/config.go b/framework/modelcatalog/config.go index be0bd30fc71..80563eb1e00 100644 --- a/framework/modelcatalog/config.go +++ b/framework/modelcatalog/config.go @@ -11,8 +11,9 @@ const ( // syncWorkerTickerPeriod is the fixed interval at which the background sync worker // wakes up to check whether a sync is due. This is independent of pricingSyncInterval — // the ticker defines the check granularity, not the sync frequency. - // Setting pricingSyncInterval below this value has no effect on actual sync frequency. - syncWorkerTickerPeriod = 1 * time.Hour + // Kept well below MinimumPricingSyncIntervalSec so the threshold check is not + // defeated by ticker drift when pricingSyncInterval is set near the minimum. + syncWorkerTickerPeriod = 5 * time.Minute ConfigLastPricingSyncKey = "LastModelPricingSync" ConfigLastParamsSyncKey = "LastModelParametersSync" diff --git a/framework/modelcatalog/pricing.go b/framework/modelcatalog/pricing.go index f7dbfb2e169..eb80ed833d7 100644 --- a/framework/modelcatalog/pricing.go +++ b/framework/modelcatalog/pricing.go @@ -299,7 +299,7 @@ func (mc *ModelCatalog) calculateBaseCost(result *schemas.BifrostResponse, scope resolvedModelUsed := extraFields.ResolvedModelUsed requestType := extraFields.RequestType - // Extract usage data from the response + // Extract usage data from the response (passthrough and native paths unified) input := extractCostInput(result) // If provider already computed cost, use it @@ -312,8 +312,13 @@ func (mc *ModelCatalog) calculateBaseCost(result *schemas.BifrostResponse, scope return 0 } - // Normalize stream request types to their base type for pricing lookup - requestType = normalizeStreamRequestType(requestType) + if result.PassthroughResponse != nil { + // Infer request type from usage fields + path; passthrough bypasses stream normalization. + requestType = inferPassthroughRequestType(extraFields.Provider, extraFields.PassthroughPath, result.PassthroughResponse.PassthroughUsage) + } else { + // Normalize stream request types to their base type for pricing lookup + requestType = normalizeStreamRequestType(requestType) + } // When a pricing model override is set, use it in place of the actual requested/resolved // model names during pricing lookup (e.g. container creates always look up "container"). @@ -362,6 +367,9 @@ func extractCostInput(result *schemas.BifrostResponse) costInput { var input costInput switch { + case result.PassthroughResponse != nil && result.PassthroughResponse.PassthroughUsage != nil: + return passthroughUsageToCostInput(result.PassthroughResponse.PassthroughUsage) + case result.TextCompletionResponse != nil && result.TextCompletionResponse.Usage != nil: input.usage = result.TextCompletionResponse.Usage @@ -1335,3 +1343,134 @@ func (mc *ModelCatalog) UpsertModelPricingAttributes(ctx context.Context, model } return rows, nil } + +// --------------------------------------------------------------------------- +// Passthrough pricing helpers +// --------------------------------------------------------------------------- + +// detectPassthroughRequestType maps a provider + stripped path to a RequestType. +func detectPassthroughRequestType(provider schemas.ModelProvider, path string) schemas.RequestType { + if idx := strings.IndexByte(path, '?'); idx >= 0 { + path = path[:idx] + } + path = strings.TrimRight(path, "/") + switch provider { + case schemas.OpenAI, schemas.Azure: + switch { + case strings.HasSuffix(path, "/chat/completions"): + return schemas.ChatCompletionRequest + case strings.HasSuffix(path, "/completions"): + return schemas.TextCompletionRequest + case strings.HasSuffix(path, "/embeddings"): + return schemas.EmbeddingRequest + case strings.HasSuffix(path, "/responses"): + return schemas.ResponsesRequest + case strings.HasSuffix(path, "/images/generations"): + return schemas.ImageGenerationRequest + case strings.HasSuffix(path, "/images/edits"): + return schemas.ImageEditRequest + case strings.HasSuffix(path, "/images/variations"): + return schemas.ImageVariationRequest + case strings.HasSuffix(path, "/audio/speech"): + return schemas.SpeechRequest + case strings.HasSuffix(path, "/audio/transcriptions"), + strings.HasSuffix(path, "/audio/translations"): + return schemas.TranscriptionRequest + case strings.HasSuffix(path, "/containers"): + return schemas.ContainerCreateRequest + case strings.Contains(path, "/video"): + return schemas.VideoGenerationRequest + default: + return schemas.ChatCompletionRequest + } + case schemas.Gemini, schemas.Vertex: + // Interactions API paths carry no colon action suffix. + if strings.Contains(path, "/interactions") { + return schemas.ResponsesRequest + } + colonIdx := strings.LastIndexByte(path, ':') + if colonIdx < 0 { + return schemas.ChatCompletionRequest + } + switch path[colonIdx+1:] { + case "generateContent", "streamGenerateContent": + return schemas.ResponsesRequest + case "embedContent", "batchEmbedContents": + return schemas.EmbeddingRequest + case "generateImages": + return schemas.ImageGenerationRequest + case "predict": + return schemas.EmbeddingRequest + case "predictLongRunning": + return schemas.VideoGenerationRequest + default: + return schemas.ChatCompletionRequest + } + case schemas.Anthropic: + switch { + case strings.HasSuffix(path, "/messages"): + return schemas.ResponsesRequest + case strings.HasSuffix(path, "/complete"): + return schemas.TextCompletionRequest + default: + return schemas.ResponsesRequest + } + default: + return schemas.ChatCompletionRequest + } +} + +// inferPassthroughRequestType determines the request type from usage fields (primary) +// and falls back to path detection for text/embedding/responses where LLMUsage is ambiguous. +func inferPassthroughRequestType(provider schemas.ModelProvider, path string, su *schemas.BifrostPassthroughUsage) schemas.RequestType { + if su != nil { + if su.ContainerIdentifier != "" { + return schemas.ContainerCreateRequest + } + if su.ImageUsage != nil { + return schemas.ImageGenerationRequest + } + if su.AudioInputChars > 0 { + return schemas.SpeechRequest + } + if su.AudioTokenDetails != nil || su.AudioSeconds != nil { + return schemas.TranscriptionRequest + } + if su.VideoSeconds != nil { + return schemas.VideoGenerationRequest + } + } + return detectPassthroughRequestType(provider, path) +} + +// passthroughUsageToCostInput converts BifrostPassthroughUsage into costInput. +func passthroughUsageToCostInput(su *schemas.BifrostPassthroughUsage) costInput { + var input costInput + if su.LLMUsage != nil { + input.usage = su.LLMUsage + } + if su.ServiceTier != nil { + input.tier = tierFromString(su.ServiceTier) + } + if su.ImageUsage != nil { + input.imageUsage = su.ImageUsage + input.imageSize = su.ImageSize + input.imageQuality = su.ImageQuality + } + if su.AudioInputChars > 0 { + input.audioTextInputChars = su.AudioInputChars + } + if su.AudioSeconds != nil { + input.audioSeconds = su.AudioSeconds + } + if su.AudioTokenDetails != nil { + input.audioTokenDetails = su.AudioTokenDetails + } + if su.VideoSeconds != nil { + input.videoSeconds = su.VideoSeconds + } + if su.ContainerIdentifier != "" { + input.containerIdentifierString = su.ContainerIdentifier + } + return input +} diff --git a/framework/modelcatalog/sync.go b/framework/modelcatalog/sync.go index dbcd39e5241..bb7c74e1e82 100644 --- a/framework/modelcatalog/sync.go +++ b/framework/modelcatalog/sync.go @@ -250,19 +250,16 @@ func (mc *ModelCatalog) loadModelParametersFromDatabase(ctx context.Context) (in func (mc *ModelCatalog) startSyncWorker(ctx context.Context) { // IMPORTANT: scheduling model // - // The sync worker wakes on a fixed ticker (syncWorkerTickerPeriod = 1h). - // On each wake it calls checkAndSyncPricing, which checks: + // The sync worker wakes on a fixed ticker (syncWorkerTickerPeriod). On each + // wake it checks: // // time.Since(lastSyncTimestamp) >= pricingSyncInterval // - // This means: - // • pricingSyncInterval defines the *minimum elapsed time* between syncs. - // • The actual sync frequency = max(syncWorkerTickerPeriod, pricingSyncInterval). - // • Setting pricingSyncInterval < 1h does NOT increase sync frequency — - // the hourly ticker is the hard lower bound on check granularity. - // - // Design rationale: avoids high-frequency polling while allowing operators to - // tune how stale pricing data can get (e.g., 1h vs 24h vs 7d). + // pricingSyncInterval defines the minimum elapsed time between syncs. The + // ticker period is the check granularity and must stay well below the + // minimum supported pricingSyncInterval, otherwise ticker drift (the few + // seconds a sync takes to complete) pushes the next check just under the + // threshold and the effective cadence doubles. mc.syncTicker = time.NewTicker(syncWorkerTickerPeriod) mc.wg.Add(1) go mc.syncWorker(ctx) diff --git a/framework/streaming/passthrough.go b/framework/streaming/passthrough.go index e898e680454..1cdad9870d1 100644 --- a/framework/streaming/passthrough.go +++ b/framework/streaming/passthrough.go @@ -41,6 +41,11 @@ func (a *Accumulator) processPassthroughStreamingResponse(ctx *schemas.BifrostCo maps.Copy(accumulator.PassthroughHeaders, result.PassthroughResponse.Headers) } + // Save path from the first chunk that carries it (set once in ExtraFields by the provider) + if accumulator.PassthroughPath == "" && result != nil && result.PassthroughResponse != nil { + accumulator.PassthroughPath = result.PassthroughResponse.ExtraFields.PassthroughPath + } + // Accumulate the body bytes from this chunk if result != nil && result.PassthroughResponse != nil && len(result.PassthroughResponse.Body) > 0 { // Make a copy of the body bytes to avoid referencing pooled memory @@ -67,14 +72,25 @@ func (a *Accumulator) processPassthroughStreamingResponse(ctx *schemas.BifrostCo accumulator.FinalTimestamp = time.Now() } + // PassthroughUsage is set by the provider on the final EOF chunk before any + // plugin runs — read it from result rather than re-extracting here. + var passthroughUsage *schemas.BifrostPassthroughUsage + if result != nil && result.PassthroughResponse != nil { + passthroughUsage = result.PassthroughResponse.PassthroughUsage + } + // Build the accumulated passthrough response passthroughResp := &schemas.BifrostPassthroughResponse{ - StatusCode: accumulator.PassthroughStatusCode, - Headers: accumulator.PassthroughHeaders, - Body: accumulator.PassthroughBody, + StatusCode: accumulator.PassthroughStatusCode, + Headers: accumulator.PassthroughHeaders, + Body: accumulator.PassthroughBody, + Path: accumulator.PassthroughPath, + PassthroughUsage: passthroughUsage, } - // Build accumulated data with the passthrough response + // Build accumulated data with the passthrough response. + // Populate TokenUsage from PassthroughUsage.LLMUsage so applyStreamingOutputToEntry sets + // entry.TokenUsageParsed via the standard streaming token path. data := &AccumulatedData{ RequestID: requestID, Model: requestedModel, @@ -84,6 +100,9 @@ func (a *Accumulator) processPassthroughStreamingResponse(ctx *schemas.BifrostCo EndTimestamp: accumulator.FinalTimestamp, PassthroughOutput: passthroughResp, } + if passthroughUsage != nil && passthroughUsage.LLMUsage != nil { + data.TokenUsage = passthroughUsage.LLMUsage + } // Set error status if there was an error if bifrostErr != nil { diff --git a/framework/streaming/types.go b/framework/streaming/types.go index 16779891d75..c6739633314 100644 --- a/framework/streaming/types.go +++ b/framework/streaming/types.go @@ -141,9 +141,10 @@ type StreamAccumulator struct { TerminalErrorChunkIndex int // Passthrough streaming accumulation - PassthroughBody []byte // Accumulated body bytes from passthrough streaming chunks - PassthroughStatusCode int // Status code from passthrough response - PassthroughHeaders map[string]string // Headers from passthrough response + PassthroughBody []byte // Accumulated body bytes from passthrough streaming chunks + PassthroughStatusCode int // Status code from passthrough response + PassthroughHeaders map[string]string // Headers from passthrough response + PassthroughPath string // Stripped provider path, e.g. "/v1/chat/completions" IsComplete bool FinalTimestamp time.Time diff --git a/framework/tracing/store.go b/framework/tracing/store.go index 340ebf3c4c4..659c689deb9 100644 --- a/framework/tracing/store.go +++ b/framework/tracing/store.go @@ -14,12 +14,12 @@ import ( type DeferredSpanInfo struct { SpanID string StartTime time.Time - Tracer schemas.Tracer // Reference to tracer for completing the span - RequestID string // Request ID for accumulator lookup - FirstChunkTime time.Time // Timestamp of first chunk (for TTFT calculation) - ChunkCount int // Count of received streaming chunks (for AttrTotalChunks) + Tracer schemas.Tracer // Reference to tracer for completing the span + RequestID string // Request ID for accumulator lookup + FirstChunkTime time.Time // Timestamp of first chunk (for TTFT calculation) + ChunkCount int // Count of received streaming chunks (for AttrTotalChunks) AccumulatedResponse *schemas.BifrostResponse // Full accumulated response from streaming chunks - mu sync.Mutex // Mutex for thread-safe chunk accumulation + mu sync.Mutex // Mutex for thread-safe chunk accumulation } // TraceStore manages traces with thread-safe access and object pooling @@ -106,6 +106,9 @@ func (s *TraceStore) CreateTrace(inheritedTraceID string, requestID ...string) s clear(trace.Attributes) } + // Reset request headers + trace.RequestHeaders = nil + s.traces.Store(trace.TraceID, trace) return trace.TraceID } @@ -127,6 +130,15 @@ func (s *TraceStore) SetRequestID(traceID string, requestID string) { trace.SetRequestID(requestID) } +// SetRequestHeaders sets the captured request headers for the trace +func (s *TraceStore) SetRequestHeaders(traceID string, headers map[string]string) { + trace := s.GetTrace(traceID) + if trace == nil { + return + } + trace.SetRequestHeaders(headers) +} + // CompleteTrace marks the trace as complete, removes it from store, and returns it for flushing func (s *TraceStore) CompleteTrace(traceID string) *schemas.Trace { // Clear any deferred span for this trace diff --git a/framework/tracing/tracer.go b/framework/tracing/tracer.go index 3c8cb548940..81862a30617 100644 --- a/framework/tracing/tracer.go +++ b/framework/tracing/tracer.go @@ -18,12 +18,13 @@ import ( // framework's TraceStore implementation. // It also embeds a streaming.Accumulator for centralized streaming chunk accumulation. type Tracer struct { - store *TraceStore - accumulator *streaming.Accumulator - pricingManager *modelcatalog.ModelCatalog - logger schemas.Logger - obsPlugins atomic.Pointer[[]schemas.ObservabilityPlugin] - flushWG sync.WaitGroup + store *TraceStore + accumulator *streaming.Accumulator + pricingManager *modelcatalog.ModelCatalog + logger schemas.Logger + obsPlugins atomic.Pointer[[]schemas.ObservabilityPlugin] + cachedHdrPatterns atomic.Pointer[[]string] + flushWG sync.WaitGroup } // NewTracer creates a new Tracer wrapping the given TraceStore. @@ -40,11 +41,66 @@ func NewTracer(store *TraceStore, pricingManager *modelcatalog.ModelCatalog, log } // SetObservabilityPlugins updates the plugins that receive completed traces. +// It also precomputes the deduplicated, normalized union of request-header patterns +// requested by those plugins so the per-request capture path is a single atomic load. func (t *Tracer) SetObservabilityPlugins(obsPlugins []schemas.ObservabilityPlugin) { if t == nil { return } t.obsPlugins.Store(&obsPlugins) + + seen := make(map[string]struct{}) + var patterns []string + for _, plugin := range obsPlugins { + if w, ok := plugin.(interface{ RequestHeaderPatterns() []string }); ok { + for _, p := range w.RequestHeaderPatterns() { + normalized := strings.ToLower(strings.TrimSpace(p)) + if normalized == "" { + continue + } + if _, exists := seen[normalized]; !exists { + seen[normalized] = struct{}{} + patterns = append(patterns, normalized) + } + } + } + } + t.cachedHdrPatterns.Store(&patterns) +} + +// ShouldCaptureRequestHeaders reports whether any observability plugin has opted into +// request-header capture (by implementing RequestHeaderPatterns). Derived from the cached +// pattern union computed in SetObservabilityPlugins, so there is no per-request recompute. +func (t *Tracer) ShouldCaptureRequestHeaders() bool { + cached := t.cachedHdrPatterns.Load() + return cached != nil && len(*cached) > 0 +} + +// CollectRequestHeaderPatterns returns the deduplicated union of header patterns +// requested by all observability plugins. The middleware uses this to capture only +// matched headers onto the trace, keeping the trace lean. The union is precomputed in +// SetObservabilityPlugins; this is a single atomic load. +func (t *Tracer) CollectRequestHeaderPatterns() []string { + cached := t.cachedHdrPatterns.Load() + if cached == nil { + return nil + } + return *cached +} + +// SetTraceRequestHeaders filters the given request headers down to the union of +// patterns requested by observability plugins and stores the matched subset on the +// trace. Header keys are expected to be lowercased by the caller. +func (t *Tracer) SetTraceRequestHeaders(traceID string, headers map[string]string) { + if len(headers) == 0 { + return + } + patterns := t.CollectRequestHeaderPatterns() + matched := schemas.FilterHeaders(headers, patterns) + if len(matched) == 0 { + return + } + t.store.SetRequestHeaders(traceID, matched) } // CreateTrace creates a new trace with optional parent ID and returns the trace ID. diff --git a/helm-charts/bifrost/README.md b/helm-charts/bifrost/README.md index 2546945d7e2..276bb9d9fba 100644 --- a/helm-charts/bifrost/README.md +++ b/helm-charts/bifrost/README.md @@ -9,7 +9,9 @@ Official Helm charts for deploying [Bifrost](https://github.com/maximhq/bifrost) ## Changelog ### 2.1.21 + - Add `per_user_oauth`/`per_user_headers` to `authType` enum in mcpClientConfig +- Added `scope` and `scope_id` fields to `bifrost.governance.modelConfigs[]` items in `values.yaml` and `values.schema.json`. `scope` accepts `"global"` (default, applies to all traffic) or `"virtual_key"` (applies to a specific virtual key); `scope_id` is required when `scope` is `"virtual_key"` and must reference a virtual key `id`. The `_helpers.tpl` already passes `modelConfigs` through as-is so no template change was needed. ### 2.1.20 @@ -647,17 +649,17 @@ bifrost: ### MCP Configuration -| Parameter | Description | Default | -| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -| `bifrost.mcp.enabled` | Enable MCP (Model Context Protocol) | `false` | -| `bifrost.mcp.clientConfigs` | Array of MCP client configurations | `[]` | -| `bifrost.mcp.toolManagerConfig.toolExecutionTimeout` | Tool execution timeout. Integer = seconds, string = Go duration (e.g. `"30s"`, `"2m"`). Prefer the string form. | `"30s"` | -| `bifrost.mcp.toolManagerConfig.maxAgentDepth` | Maximum agent depth | `10` | -| `bifrost.mcp.toolManagerConfig.codeModeBindingLevel` | Code mode binding level (`server` or `tool`) | `server` | -| `bifrost.mcp.toolManagerConfig.disableAutoToolInject` | Disable automatic MCP tool injection | `false` | -| `bifrost.mcp.toolSyncInterval` | Global MCP tool sync interval. Prefer a Go duration string (for example, `10m`); legacy numeric nanoseconds are still supported for backward compatibility, but string format is recommended. | `10m` | -| `bifrost.mcp.clientConfigs[].tlsConfig.insecureSkipVerify` | **[Upcoming]** Disable TLS certificate verification for HTTP/SSE MCP connections. Takes priority over `caCertPem`. For development/testing only — not recommended for production. | `false` | -| `bifrost.mcp.clientConfigs[].tlsConfig.caCertPem` | **[Upcoming]** PEM-encoded CA certificate to trust for HTTP/SSE MCP server connections. Accepts a literal PEM string or an `env.VAR_NAME` reference. Use when the MCP server uses a self-signed or private CA. | `""` | +| Parameter | Description | Default | +| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `bifrost.mcp.enabled` | Enable MCP (Model Context Protocol) | `false` | +| `bifrost.mcp.clientConfigs` | Array of MCP client configurations | `[]` | +| `bifrost.mcp.toolManagerConfig.toolExecutionTimeout` | Tool execution timeout. Integer = seconds, string = Go duration (e.g. `"30s"`, `"2m"`). Prefer the string form. | `"30s"` | +| `bifrost.mcp.toolManagerConfig.maxAgentDepth` | Maximum agent depth | `10` | +| `bifrost.mcp.toolManagerConfig.codeModeBindingLevel` | Code mode binding level (`server` or `tool`) | `server` | +| `bifrost.mcp.toolManagerConfig.disableAutoToolInject` | Disable automatic MCP tool injection | `false` | +| `bifrost.mcp.toolSyncInterval` | Global MCP tool sync interval. Prefer a Go duration string (for example, `10m`); legacy numeric nanoseconds are still supported for backward compatibility, but string format is recommended. | `10m` | +| `bifrost.mcp.clientConfigs[].tlsConfig.insecureSkipVerify` | **[Upcoming]** Disable TLS certificate verification for HTTP/SSE MCP connections. Takes priority over `caCertPem`. For development/testing only — not recommended for production. | `false` | +| `bifrost.mcp.clientConfigs[].tlsConfig.caCertPem` | **[Upcoming]** PEM-encoded CA certificate to trust for HTTP/SSE MCP server connections. Accepts a literal PEM string or an `env.VAR_NAME` reference. Use when the MCP server uses a self-signed or private CA. | `""` | #### MCP Migration Guide (`client.mcp*` -> `mcp.*`) diff --git a/helm-charts/bifrost/templates/_helpers.tpl b/helm-charts/bifrost/templates/_helpers.tpl index b25854306a5..010fdda828e 100644 --- a/helm-charts/bifrost/templates/_helpers.tpl +++ b/helm-charts/bifrost/templates/_helpers.tpl @@ -443,6 +443,18 @@ false {{- end }} {{- $_ := set $governance "business_units" $businessUnits }} {{- end }} +{{- if .Values.bifrost.governance.roles }} +{{- $roles := list }} +{{- range .Values.bifrost.governance.roles }} +{{- $role := dict "name" .name }} +{{- if .description }}{{- $_ := set $role "description" .description }}{{- end }} +{{- if .dac }}{{- $_ := set $role "dac" .dac }}{{- end }} +{{- if .access_profile }}{{- $_ := set $role "access_profile" .access_profile }}{{- end }} +{{- if .permissions }}{{- $_ := set $role "permissions" .permissions }}{{- end }} +{{- $roles = append $roles $role }} +{{- end }} +{{- $_ := set $governance "roles" $roles }} +{{- end }} {{- if .Values.bifrost.governance.virtualKeys }} {{- $vks := list }} {{- range .Values.bifrost.governance.virtualKeys }} @@ -494,7 +506,7 @@ false {{- $_ := set $governance "auth_config" $authConfig }} {{- end }} {{- end }} -{{- if or $governance.budgets $governance.rate_limits $governance.customers $governance.teams $governance.business_units $governance.virtual_keys $governance.routing_rules $governance.model_configs $governance.providers $governance.pricing_overrides $governance.auth_config }} +{{- if or $governance.budgets $governance.rate_limits $governance.customers $governance.teams $governance.business_units $governance.roles $governance.virtual_keys $governance.routing_rules $governance.model_configs $governance.providers $governance.pricing_overrides $governance.auth_config }} {{- $_ := set $config "governance" $governance }} {{- end }} {{- end }} @@ -1114,6 +1126,12 @@ false {{- if .Values.bifrost.plugins.otel.enabled }} {{- $otelConfig := dict }} {{- $inputConfig := .Values.bifrost.plugins.otel.config | default dict }} +{{- if hasKey $inputConfig "profiles" }} +{{- $_ := set $otelConfig "profiles" $inputConfig.profiles }} +{{- if $inputConfig.plugin_span_filter }} +{{- $_ := set $otelConfig "plugin_span_filter" $inputConfig.plugin_span_filter }} +{{- end }} +{{- else }} {{- if $inputConfig.service_name }} {{- $_ := set $otelConfig "service_name" $inputConfig.service_name }} {{- end }} @@ -1144,6 +1162,10 @@ false {{- if hasKey $inputConfig "insecure" }} {{- $_ := set $otelConfig "insecure" $inputConfig.insecure }} {{- end }} +{{- if $inputConfig.plugin_span_filter }} +{{- $_ := set $otelConfig "plugin_span_filter" $inputConfig.plugin_span_filter }} +{{- end }} +{{- end }} {{- $plugin := dict "enabled" true "name" "otel" "config" $otelConfig }} {{- if hasKey .Values.bifrost.plugins.otel "version" }}{{- $_ := set $plugin "version" (.Values.bifrost.plugins.otel.version | int) }}{{- end }} {{- $plugins = append $plugins $plugin }} @@ -1340,15 +1362,45 @@ Call this template at the beginning of deployment/stateful templates {{/* Validate OTEL plugin when enabled */}} {{- if .Values.bifrost.plugins.otel.enabled }} -{{- if not .Values.bifrost.plugins.otel.config.collector_url }} +{{- $otelInputConfig := .Values.bifrost.plugins.otel.config | default dict }} +{{- if hasKey $otelInputConfig "profiles" }} +{{- if not $otelInputConfig.profiles }} +{{- fail "ERROR: bifrost.plugins.otel.config.profiles must contain at least one profile when OTEL plugin is enabled." }} +{{- end }} +{{- range $idx, $profile := $otelInputConfig.profiles }} +{{- $profileEnabled := true }} +{{- if hasKey $profile "enabled" }} +{{- $profileEnabled = $profile.enabled }} +{{- end }} +{{- if $profileEnabled }} +{{- if not $profile.collector_url }} +{{- fail (printf "ERROR: bifrost.plugins.otel.config.profiles[%d].collector_url is required for enabled OTEL profiles." $idx) }} +{{- end }} +{{- if not $profile.trace_type }} +{{- fail (printf "ERROR: bifrost.plugins.otel.config.profiles[%d].trace_type is required. Supported values: genai_extension, vercel, open_inference" $idx) }} +{{- end }} +{{- if not $profile.protocol }} +{{- fail (printf "ERROR: bifrost.plugins.otel.config.profiles[%d].protocol is required. Supported values: http, grpc" $idx) }} +{{- end }} +{{- if and $profile.metrics_enabled (not $profile.metrics_endpoint) }} +{{- fail (printf "ERROR: bifrost.plugins.otel.config.profiles[%d].metrics_endpoint is required when metrics_enabled is true." $idx) }} +{{- end }} +{{- end }} +{{- end }} +{{- else }} +{{- if not $otelInputConfig.collector_url }} {{- fail "ERROR: bifrost.plugins.otel.config.collector_url is required when OTEL plugin is enabled. Provide the URL of your OpenTelemetry collector." }} {{- end }} -{{- if not .Values.bifrost.plugins.otel.config.trace_type }} +{{- if not $otelInputConfig.trace_type }} {{- fail "ERROR: bifrost.plugins.otel.config.trace_type is required when OTEL plugin is enabled. Supported values: genai_extension, vercel, open_inference" }} {{- end }} -{{- if not .Values.bifrost.plugins.otel.config.protocol }} +{{- if not $otelInputConfig.protocol }} {{- fail "ERROR: bifrost.plugins.otel.config.protocol is required when OTEL plugin is enabled. Supported values: http, grpc" }} {{- end }} +{{- if and $otelInputConfig.metrics_enabled (not $otelInputConfig.metrics_endpoint) }} +{{- fail "ERROR: bifrost.plugins.otel.config.metrics_endpoint is required when metrics_enabled is true." }} +{{- end }} +{{- end }} {{- end }} {{/* Validate Maxim plugin when enabled */}} @@ -1577,6 +1629,15 @@ Call this template at the beginning of deployment/stateful templates {{- end }} {{- end }} +{{/* Validate governance roles */}} +{{- if .Values.bifrost.governance.roles }} +{{- range $idx, $role := .Values.bifrost.governance.roles }} +{{- if not $role.name }} +{{- fail (printf "ERROR: bifrost.governance.roles[%d].name is required." $idx) }} +{{- end }} +{{- end }} +{{- end }} + {{/* Validate guardrails rules */}} {{- if .Values.bifrost.guardrails.rules }} {{- range $idx, $rule := .Values.bifrost.guardrails.rules }} diff --git a/helm-charts/bifrost/values.schema.json b/helm-charts/bifrost/values.schema.json index e5069cb3272..1252bd4714f 100644 --- a/helm-charts/bifrost/values.schema.json +++ b/helm-charts/bifrost/values.schema.json @@ -835,70 +835,11 @@ "type": "boolean" }, "config": { - "type": "object", - "properties": { - "service_name": { - "type": "string", - "description": "Service name to be used for tracing", - "default": "bifrost" - }, - "collector_url": { - "type": "string", - "description": "URL of the OpenTelemetry collector" - }, - "trace_type": { - "type": "string", - "enum": ["genai_extension", "vercel", "open_inference"], - "description": "Type of trace to use for the OTEL collector" - }, - "protocol": { - "type": "string", - "enum": ["http", "grpc"], - "description": "Protocol to use for the OTEL collector" - }, - "metrics_enabled": { - "type": "boolean", - "description": "Enable push-based metrics export via OTLP. Recommended for multi-node cluster deployments.", - "default": false - }, - "metrics_endpoint": { - "type": "string", - "description": "OTLP metrics endpoint URL (e.g., http://otel-collector:4318/v1/metrics for HTTP or otel-collector:4317 for gRPC)" - }, - "metrics_push_interval": { - "type": "integer", - "description": "Metrics push interval in seconds", - "default": 15, - "minimum": 1, - "maximum": 300 - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "description": "Custom headers for the collector (supports env.VAR_NAME prefix)" - }, - "tls_ca_cert": { - "type": "string", - "description": "Path to TLS CA certificate file" - }, - "insecure": { - "type": "boolean", - "description": "Skip TLS verification (ignored if tls_ca_cert is set)" - } - }, - "if": { - "properties": { - "metrics_enabled": { - "const": true - } - }, - "required": ["metrics_enabled"] - }, - "then": { - "required": ["metrics_endpoint"] - } + "anyOf": [ + { "$ref": "#/$defs/otelProfileConfig" }, + { "$ref": "#/$defs/otelProfilesConfig" } + ], + "description": "Configuration for the OpenTelemetry plugin. Supports the legacy single-profile shape or the profiles wrapper for multiple collectors." } }, "if": { @@ -911,7 +852,10 @@ "then": { "properties": { "config": { - "required": ["collector_url", "trace_type", "protocol"] + "anyOf": [ + { "$ref": "#/$defs/otelProfileConfig" }, + { "$ref": "#/$defs/otelProfilesConfig" } + ] } } } @@ -1191,6 +1135,42 @@ "required": ["id", "name"] } }, + "roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "dac": { + "type": "string", + "enum": ["own-data", "team-data", "all-data"], + "default": "all-data" + }, + "access_profile": { + "type": "string" + }, + "permissions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "resource": { "type": "string" }, + "operation": { "type": "string" } + }, + "required": ["resource", "operation"], + "additionalProperties": false + } + } + }, + "required": ["name"], + "additionalProperties": false + } + }, "virtualKeys": { "type": "array", "items": { @@ -1366,10 +1346,22 @@ "type": "string" }, "model_name": { - "type": "string" + "type": "string", + "description": "Model name, or \"*\" to match all models" }, "provider": { - "type": "string" + "type": "string", + "description": "Provider name; omit to apply to all providers" + }, + "scope": { + "type": "string", + "enum": ["global", "virtual_key"], + "default": "global", + "description": "Scope where this limit applies" + }, + "scope_id": { + "type": "string", + "description": "Target entity ID — required when scope is \"virtual_key\"" }, "budget_id": { "type": "string" @@ -1474,14 +1466,7 @@ "description": "Internal hash for change detection (auto-managed)" } }, - "required": [ - "id", - "name", - "scope_kind", - "match_type", - "pattern", - "request_types" - ] + "required": ["id", "name", "scope_kind", "match_type", "pattern", "request_types"] } } }, @@ -1537,11 +1522,7 @@ "description": "Number of failed probes before marking as failed" } }, - "required": [ - "timeoutSeconds", - "successThreshold", - "failureThreshold" - ], + "required": ["timeoutSeconds", "successThreshold", "failureThreshold"], "additionalProperties": false } }, @@ -1575,15 +1556,7 @@ }, "type": { "type": "string", - "enum": [ - "", - "kubernetes", - "dns", - "udp", - "consul", - "etcd", - "mdns" - ], + "enum": ["", "kubernetes", "dns", "udp", "consul", "etcd", "mdns"], "description": "Discovery type (empty when not configured)" }, "serviceName": { @@ -1798,13 +1771,28 @@ }, "attributeTeamMappings": { "type": "array", - "description": "Attribute -> team mappings (all matches apply). Use value '*' for pass-through (every claim value becomes a team name).", + "description": "Attribute -> team mappings (all matches apply). Use value '*' for pass-through (every claim value becomes a team name). Add attributeType/attributeValue to enable SCIM provisioning for a mapping.", "items": { "type": "object", "properties": { - "attribute": { "type": "string" }, - "value": { "type": "string" }, - "team": { "type": "string" } + "attribute": { "type": "string", "description": "JWT claim name" }, + "value": { + "type": "string", + "description": "Claim value to match, or '*' for pass-through" + }, + "team": { + "type": "string", + "description": "Bifrost team slug to assign" + }, + "attributeType": { + "type": "string", + "enum": ["user", "group"], + "description": "SCIM provisioning type: 'user' matches SCIM User attributes, 'group' matches SCIM Group displayName" + }, + "attributeValue": { + "type": "string", + "description": "SCIM attribute value to match (for 'user': SCIM user attribute value; for 'group': SCIM group displayName, auto-set to 'displayName')" + } }, "required": ["attribute", "value", "team"], "additionalProperties": false @@ -1812,25 +1800,32 @@ }, "attributeBusinessUnitMappings": { "type": "array", - "description": "Attribute -> business-unit mappings (all matches apply).", + "description": "Attribute -> business-unit mappings (all matches apply). Add attributeType/attributeValue to enable SCIM provisioning for a mapping.", "items": { "type": "object", "properties": { - "attribute": { "type": "string" }, - "value": { "type": "string" }, - "business_unit": { "type": "string" } + "attribute": { "type": "string", "description": "JWT claim name" }, + "value": { "type": "string", "description": "Claim value to match" }, + "business_unit": { + "type": "string", + "description": "Bifrost business unit slug to assign" + }, + "attributeType": { + "type": "string", + "enum": ["user", "group"], + "description": "SCIM provisioning type: 'user' matches SCIM User attributes, 'group' matches SCIM Group displayName" + }, + "attributeValue": { + "type": "string", + "description": "SCIM attribute value to match (for 'user': SCIM user attribute value; for 'group': SCIM group displayName, auto-set to 'displayName')" + } }, "required": ["attribute", "value", "business_unit"], "additionalProperties": false } } }, - "required": [ - "issuerUrl", - "clientId", - "clientSecret", - "apiToken" - ], + "required": ["issuerUrl", "clientId", "clientSecret", "apiToken"], "additionalProperties": false } } @@ -2060,12 +2055,7 @@ } } }, - "required": [ - "serverUrl", - "realm", - "clientId", - "clientSecret" - ], + "required": ["serverUrl", "realm", "clientId", "clientSecret"], "additionalProperties": false } } @@ -2423,13 +2413,7 @@ "description": "IDs of provider configurations to use with this rule" } }, - "required": [ - "id", - "name", - "enabled", - "cel_expression", - "apply_to" - ], + "required": ["id", "name", "enabled", "cel_expression", "apply_to"], "additionalProperties": false } }, @@ -2934,14 +2918,7 @@ }, "sslMode": { "type": "string", - "enum": [ - "disable", - "allow", - "prefer", - "require", - "verify-ca", - "verify-full" - ] + "enum": ["disable", "allow", "prefer", "require", "verify-ca", "verify-full"] }, "existingSecret": { "type": "string" @@ -3377,6 +3354,208 @@ } }, "$defs": { + "otelEndpoint": { + "type": "string", + "description": "OpenTelemetry endpoint URL or host:port. Supports env.VAR_NAME prefix for environment variable substitution.", + "anyOf": [ + { + "format": "uri" + }, + { + "pattern": "^[^:\\s]+:\\d+$" + }, + { + "pattern": "^env\\.[A-Za-z_][A-Za-z0-9_]*$" + }, + { + "const": "" + } + ] + }, + "otelPluginSpanFilter": { + "type": "object", + "description": "Controls which plugin hook spans are exported to the OTEL collector. Omit to export all plugin spans.", + "properties": { + "mode": { + "type": "string", + "enum": ["include", "exclude"] + }, + "plugins": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["mode", "plugins"], + "additionalProperties": false + }, + "otelProfileConfig": { + "type": "object", + "description": "OpenTelemetry export profile. This legacy single-profile shape is still accepted directly as bifrost.plugins.otel.config.", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether this profile exports traces and metrics", + "default": true + }, + "service_name": { + "type": "string", + "description": "Service name to be used for tracing", + "default": "bifrost" + }, + "collector_url": { + "$ref": "#/$defs/otelEndpoint", + "description": "URL of the OpenTelemetry collector" + }, + "trace_type": { + "type": "string", + "enum": ["genai_extension", "vercel", "open_inference"], + "description": "Type of trace to use for the OTEL collector" + }, + "protocol": { + "type": "string", + "enum": ["http", "grpc"], + "description": "Protocol to use for the OTEL collector" + }, + "metrics_enabled": { + "type": "boolean", + "description": "Enable push-based metrics export via OTLP. Recommended for multi-node cluster deployments.", + "default": false + }, + "metrics_endpoint": { + "$ref": "#/$defs/otelEndpoint", + "description": "OTLP metrics endpoint URL (e.g., http://otel-collector:4318/v1/metrics for HTTP or otel-collector:4317 for gRPC)" + }, + "metrics_push_interval": { + "type": "integer", + "description": "Metrics push interval in seconds", + "default": 15, + "minimum": 1, + "maximum": 300 + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Custom headers for the collector (supports env.VAR_NAME prefix)" + }, + "tls_ca_cert": { + "type": "string", + "description": "Path to TLS CA certificate file" + }, + "insecure": { + "type": "boolean", + "description": "Skip TLS verification (ignored if tls_ca_cert is set)", + "default": true + }, + "plugin_span_filter": { + "$ref": "#/$defs/otelPluginSpanFilter" + } + }, + "allOf": [ + { + "if": { + "not": { + "properties": { + "enabled": { + "const": false + } + }, + "required": ["enabled"] + } + }, + "then": { + "required": ["collector_url", "trace_type", "protocol"] + } + }, + { + "if": { + "properties": { + "metrics_enabled": { + "const": true + } + }, + "required": ["metrics_enabled"] + }, + "then": { + "required": ["metrics_endpoint"] + } + } + ], + "additionalProperties": false + }, + "otelProfilesConfig": { + "type": "object", + "description": "OpenTelemetry plugin configuration with one or more export profiles.", + "properties": { + "profiles": { + "type": "array", + "description": "OpenTelemetry export profiles", + "items": { + "$ref": "#/$defs/otelProfileConfig" + }, + "minItems": 1 + }, + "plugin_span_filter": { + "$ref": "#/$defs/otelPluginSpanFilter" + }, + "enabled": { + "type": "boolean", + "description": "Deprecated in the profiles wrapper; kept only so Helm's default map merge does not reject values that switch from the legacy shape to profiles." + }, + "service_name": { + "type": "string", + "description": "Deprecated in the profiles wrapper; configure service_name per profile instead." + }, + "collector_url": { + "$ref": "#/$defs/otelEndpoint", + "description": "Deprecated in the profiles wrapper; configure collector_url per profile instead." + }, + "trace_type": { + "type": "string", + "enum": ["genai_extension", "vercel", "open_inference"], + "description": "Deprecated in the profiles wrapper; configure trace_type per profile instead." + }, + "protocol": { + "type": "string", + "enum": ["http", "grpc"], + "description": "Deprecated in the profiles wrapper; configure protocol per profile instead." + }, + "metrics_enabled": { + "type": "boolean", + "description": "Deprecated in the profiles wrapper; configure metrics_enabled per profile instead." + }, + "metrics_endpoint": { + "$ref": "#/$defs/otelEndpoint", + "description": "Deprecated in the profiles wrapper; configure metrics_endpoint per profile instead." + }, + "metrics_push_interval": { + "type": "integer", + "minimum": 1, + "maximum": 300, + "description": "Deprecated in the profiles wrapper; configure metrics_push_interval per profile instead." + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Deprecated in the profiles wrapper; configure headers per profile instead." + }, + "tls_ca_cert": { + "type": "string", + "description": "Deprecated in the profiles wrapper; configure tls_ca_cert per profile instead." + }, + "insecure": { + "type": "boolean", + "description": "Deprecated in the profiles wrapper; configure insecure per profile instead." + } + }, + "required": ["profiles"], + "additionalProperties": false + }, "authConfig": { "type": "object", "description": "Authentication configuration. Deprecated: Use governance.auth_config instead.", @@ -3526,6 +3705,25 @@ "type": "string", "description": "Azure endpoint (can use env. prefix)" }, + "client_id": { + "type": "string", + "description": "Azure client ID for authentication (can use env. prefix)" + }, + "client_secret": { + "type": "string", + "description": "Azure client secret for authentication (can use env. prefix)" + }, + "tenant_id": { + "type": "string", + "description": "Azure tenant ID for authentication (can use env. prefix)" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Azure scopes for authentication" + }, "deployments": { "type": "object", "additionalProperties": { @@ -3539,6 +3737,11 @@ } }, "required": ["endpoint"], + "dependentRequired": { + "client_id": ["client_secret", "tenant_id"], + "client_secret": ["client_id", "tenant_id"], + "tenant_id": ["client_id", "client_secret"] + }, "additionalProperties": false }, "vertex_key_config": { @@ -3912,13 +4115,7 @@ }, "authType": { "type": "string", - "enum": [ - "none", - "headers", - "oauth", - "per_user_oauth", - "per_user_headers" - ], + "enum": ["none", "headers", "oauth", "per_user_oauth", "per_user_headers"], "description": "Authentication type for MCP connection" }, "oauthConfigId": { @@ -4259,6 +4456,25 @@ "type": "string", "description": "Azure endpoint (can use env. prefix)" }, + "client_id": { + "type": "string", + "description": "Azure client ID for authentication (can use env. prefix)" + }, + "client_secret": { + "type": "string", + "description": "Azure client secret for authentication (can use env. prefix)" + }, + "tenant_id": { + "type": "string", + "description": "Azure tenant ID for authentication (can use env. prefix)" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Azure scopes for authentication" + }, "deployments": { "type": "object", "additionalProperties": { @@ -4271,6 +4487,12 @@ "description": "Azure API version" } }, + "required": ["endpoint"], + "dependentRequired": { + "client_id": ["client_secret", "tenant_id"], + "client_secret": ["client_id", "tenant_id"], + "tenant_id": ["client_id", "client_secret"] + }, "additionalProperties": false }, "vertex_key_config": { @@ -4482,4 +4704,4 @@ "additionalProperties": false } } -} +} \ No newline at end of file diff --git a/helm-charts/bifrost/values.yaml b/helm-charts/bifrost/values.yaml index 67859d1b307..909afeb80a0 100644 --- a/helm-charts/bifrost/values.yaml +++ b/helm-charts/bifrost/values.yaml @@ -383,6 +383,15 @@ bifrost: # tlsConfig: # insecureSkipVerify: false # Disable TLS verification (dev/test only — takes priority over caCertPem) # caCertPem: "env.MY_MCP_CA_CERT" # PEM string or env.VAR_NAME reference + # + # - name: "example-oauth-mcp" + # connectionType: "http" + # connectionString: "https://my-mcp.corp/mcp" + # # authType "oauth": shared OAuth token; provide oauthConfigId referencing an existing oauth_config. + # # authType "per_user_oauth": each user authenticates individually via OAuth flow; + # # oauth_config is registered via the API (POST /api/mcp/clients), not configured here. + # authType: "oauth" + # oauthConfigId: "my-oauth-config-id" # ID of the OAuth config created in Bifrost # toolSyncInterval: "10m" # Global tool sync interval (Go duration string, e.g. "10m", "1h", "0s") # Tool manager configuration toolManagerConfig: @@ -506,8 +515,17 @@ bifrost: customers: [] # - id: "customer-1" # name: "Customer Name" - # budget_id: "budget-1" # rate_limit_id: "rate-limit-1" + # # Option A: inline multi-budget (each must have a unique reset_duration) + # budgets: + # - id: "budget-monthly" + # max_limit: 500 + # reset_duration: "1M" + # - id: "budget-yearly" + # max_limit: 5000 + # reset_duration: "1Y" + # # Option B: single budget reference (pre-declared in governance.budgets) + # budget_id: "budget-1" teams: [] # - id: "team-1" # name: "Team Name" @@ -517,6 +535,18 @@ bifrost: # profile: {} # Team profile data # config: {} # Team configuration data # claims: {} # Team claims data + roles: [] + # - name: "data-analyst" + # description: "Read-only access for data analysts" + # dac: "team-data" # own-data | team-data | all-data (default: all-data) + # access_profile: "analyst-profile" # Optional: name of an access_profile to attach + # permissions: + # - resource: "Logs" + # operation: "View" + # - resource: "Metrics" + # operation: "View" + # - resource: "VirtualKeys" + # operation: "View" virtualKeys: [] # - id: "vk-1" # name: "Virtual Key 1" @@ -542,8 +572,10 @@ bifrost: # tools_to_execute: ["tool1", "tool2"] modelConfigs: [] # - id: "model-config-1" - # model_name: "gpt-4o" - # provider: "openai" + # model_name: "gpt-4o" # model name, or "*" for all models + # provider: "openai" # optional; omit to apply to all providers + # scope: "global" # "global" (default) or "virtual_key" + # scope_id: "" # required when scope is "virtual_key" — the virtual key id # budget_id: "budget-1" # rate_limit_id: "rate-limit-1" providers: [] @@ -651,6 +683,18 @@ bifrost: # - attribute: "groups" # value: "*" # pass-through: every group becomes a team # team: "" # ignored when value is "*" + # # SCIM provisioning: match by SCIM user attribute + # - attribute: "department" + # value: "engineering" + # team: "eng-team" + # attributeType: "user" # "user" = SCIM User push, "group" = SCIM Group push + # attributeValue: "engineering" + # # SCIM provisioning: match by SCIM group (displayName) + # - attribute: "groups" + # value: "Engineering" + # team: "eng-team" + # attributeType: "group" + # attributeValue: "displayName" # always "displayName" for group type # attributeBusinessUnitMappings: # - attribute: "department" # value: "platform" diff --git a/plugins/compat/changelog.md b/plugins/compat/changelog.md index c5c2bac21f5..998a5bd07ab 100644 --- a/plugins/compat/changelog.md +++ b/plugins/compat/changelog.md @@ -1 +1 @@ -- chore: upgraded core to v1.5.16 and framework to v1.3.16 +- chore: upgraded core to v1.5.16 and framework to v1.3.16 \ No newline at end of file diff --git a/plugins/compat/dropparams.go b/plugins/compat/dropparams.go index 9bd1b140517..c87f7aa034d 100644 --- a/plugins/compat/dropparams.go +++ b/plugins/compat/dropparams.go @@ -38,7 +38,9 @@ func dropUnsupportedParams(ctx *schemas.BifrostContext, req *schemas.BifrostRequ params.LogProbs = nil dropped = append(dropped, "logprobs") } - if params.MaxCompletionTokens != nil && !isSupported["max_completion_tokens"] { + // max_tokens is converted to max_completion_tokens before compat plugin's PreLLMHook is called. + // so if either max_tokens or max_completion_tokens is supported, we let max_completion_tokens pass through. + if params.MaxCompletionTokens != nil && !isSupported["max_completion_tokens"] && !isSupported["max_tokens"] { params.MaxCompletionTokens = nil dropped = append(dropped, "max_completion_tokens") } diff --git a/plugins/compat/go.mod b/plugins/compat/go.mod index 26520631ec0..391f4bbc4f4 100644 --- a/plugins/compat/go.mod +++ b/plugins/compat/go.mod @@ -3,7 +3,7 @@ module github.com/maximhq/bifrost/plugins/compat go 1.26.3 require ( - github.com/bytedance/sonic v1.15.0 + github.com/bytedance/sonic v1.15.1 github.com/maximhq/bifrost/core v1.5.15 github.com/maximhq/bifrost/framework v1.3.15 ) @@ -11,7 +11,7 @@ require ( require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect @@ -24,7 +24,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect - github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect @@ -48,14 +48,14 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/bytedance/gopkg v0.1.3 // indirect - github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect - github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -81,12 +81,12 @@ require ( github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-openapi/validate v0.25.1 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // 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.14 // indirect - github.com/googleapis/gax-go/v2 v2.19.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -94,7 +94,7 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mailru/easyjson v0.9.1 // indirect @@ -121,16 +121,16 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.68.0 // indirect + github.com/valyala/fasthttp v1.71.0 // indirect github.com/weaviate/weaviate v1.36.5 // indirect github.com/weaviate/weaviate-go-client/v5 v5.7.1 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.mongodb.org/mongo-driver v1.17.6 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.40.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk v1.43.0 // indirect @@ -146,12 +146,12 @@ require ( golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/api v0.274.0 // indirect - google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 // indirect + google.golang.org/api v0.282.0 // indirect + google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/grpc v1.80.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/driver/postgres v1.6.0 // indirect gorm.io/driver/sqlite v1.6.0 // indirect diff --git a/plugins/compat/go.sum b/plugins/compat/go.sum index 5c47b428a62..7db77aa7dcd 100644 --- a/plugins/compat/go.sum +++ b/plugins/compat/go.sum @@ -2,8 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= 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/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= @@ -41,8 +41,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= @@ -94,16 +94,16 @@ github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJ github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= -github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= -github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= -github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= -github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= +github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= 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/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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= @@ -113,12 +113,12 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= -github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= -github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= -github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE= github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -180,11 +180,11 @@ github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6 github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-openapi/validate v0.25.1 h1:sSACUI6Jcnbo5IWqbYHgjibrhhmt3vR6lCzKZnmAgBw= github.com/go-openapi/validate v0.25.1/go.mod h1:RMVyVFYte0gbSTaZ0N4KmTn6u/kClvAFp+mAVfS/DQc= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -195,10 +195,10 @@ 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.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= -github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= -github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= +github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= @@ -218,8 +218,8 @@ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/ github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -303,8 +303,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok= -github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= github.com/weaviate/weaviate v1.36.5 h1:lCiuEfQ08+5wK0DkTCUBb6ayNep9QpBH6JJhmZaRfzk= github.com/weaviate/weaviate v1.36.5/go.mod h1:ljzrgEmGKn3CRzDdcxvhmBUUZIcghwIYd1Lmn54f3Z8= github.com/weaviate/weaviate-go-client/v5 v5.7.1 h1:vEMxh486QqRqWaq58UEe/TiTbGbo9T5x7ZPFd5QENvQ= @@ -319,12 +319,12 @@ go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUps go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= 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/contrib/detectors/gcp v1.40.0 h1:Awaf8gmW99tZTOWqkLCOl6aw1/rxAWVlHsHIZ3fT2sA= -go.opentelemetry.io/contrib/detectors/gcp v1.40.0/go.mod h1:99OY9ZCqyLkzJLTh5XhECpLRSxcZl+ZDKBEO+jMBFR4= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= @@ -363,18 +363,18 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA= -google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= -google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE= -google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw= +google.golang.org/api v0.282.0 h1:WmJiSVqUnKqJCpJOx7YADbXaC+9DDsnGSfllFSj7R2I= +google.golang.org/api v0.282.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/plugins/governance/changelog.md b/plugins/governance/changelog.md index c5c2bac21f5..998a5bd07ab 100644 --- a/plugins/governance/changelog.md +++ b/plugins/governance/changelog.md @@ -1 +1 @@ -- chore: upgraded core to v1.5.16 and framework to v1.3.16 +- chore: upgraded core to v1.5.16 and framework to v1.3.16 \ No newline at end of file diff --git a/plugins/governance/go.mod b/plugins/governance/go.mod index 6c221399452..a8f23a55ad9 100644 --- a/plugins/governance/go.mod +++ b/plugins/governance/go.mod @@ -5,19 +5,19 @@ go 1.26.3 require gorm.io/gorm v1.31.1 require ( - github.com/bytedance/sonic v1.15.0 - github.com/google/cel-go v0.26.1 + github.com/bytedance/sonic v1.15.1 + github.com/google/cel-go v0.28.1 github.com/google/uuid v1.6.0 github.com/maximhq/bifrost/core v1.5.15 github.com/maximhq/bifrost/framework v1.3.15 github.com/stretchr/testify v1.11.1 - github.com/valyala/fasthttp v1.68.0 + github.com/valyala/fasthttp v1.71.0 ) require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect @@ -30,8 +30,8 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect - github.com/andybalholm/brotli v1.2.0 // indirect - github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect @@ -55,14 +55,14 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/bytedance/gopkg v0.1.3 // indirect - github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect - github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -88,11 +88,11 @@ require ( github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-openapi/validate v0.25.1 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect - github.com/googleapis/gax-go/v2 v2.19.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -100,7 +100,7 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mailru/easyjson v0.9.1 // indirect @@ -120,7 +120,6 @@ require ( github.com/rs/zerolog v1.34.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect - github.com/stoewer/go-strcase v1.3.1 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect @@ -133,9 +132,9 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.mongodb.org/mongo-driver v1.17.6 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.40.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk v1.43.0 // indirect @@ -145,19 +144,19 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.23.0 // indirect golang.org/x/crypto v0.52.0 // indirect - golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect + golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/api v0.274.0 // indirect - google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 // indirect + google.golang.org/api v0.282.0 // indirect + google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/grpc v1.80.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/driver/postgres v1.6.0 // indirect gorm.io/driver/sqlite v1.6.0 // indirect diff --git a/plugins/governance/go.sum b/plugins/governance/go.sum index c3b98c7ce42..bf5c92acf5e 100644 --- a/plugins/governance/go.sum +++ b/plugins/governance/go.sum @@ -2,8 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= 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/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= @@ -41,10 +41,10 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= -github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= @@ -96,16 +96,16 @@ github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJ github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= -github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= -github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= -github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= -github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= +github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= 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/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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= @@ -115,12 +115,12 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= -github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= -github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= -github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE= github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -182,15 +182,15 @@ github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6 github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-openapi/validate v0.25.1 h1:sSACUI6Jcnbo5IWqbYHgjibrhhmt3vR6lCzKZnmAgBw= github.com/go-openapi/validate v0.25.1/go.mod h1:RMVyVFYte0gbSTaZ0N4KmTn6u/kClvAFp+mAVfS/DQc= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= -github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/cel-go v0.28.1 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM= +github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= 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/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= @@ -199,10 +199,10 @@ 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.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= -github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= -github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= +github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= @@ -222,8 +222,8 @@ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/ github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -280,8 +280,6 @@ github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qq github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= -github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= -github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= 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= @@ -292,7 +290,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -310,8 +307,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok= -github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= github.com/weaviate/weaviate v1.36.5 h1:lCiuEfQ08+5wK0DkTCUBb6ayNep9QpBH6JJhmZaRfzk= github.com/weaviate/weaviate v1.36.5/go.mod h1:ljzrgEmGKn3CRzDdcxvhmBUUZIcghwIYd1Lmn54f3Z8= github.com/weaviate/weaviate-go-client/v5 v5.7.1 h1:vEMxh486QqRqWaq58UEe/TiTbGbo9T5x7ZPFd5QENvQ= @@ -326,12 +323,12 @@ go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUps go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= 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/contrib/detectors/gcp v1.40.0 h1:Awaf8gmW99tZTOWqkLCOl6aw1/rxAWVlHsHIZ3fT2sA= -go.opentelemetry.io/contrib/detectors/gcp v1.40.0/go.mod h1:99OY9ZCqyLkzJLTh5XhECpLRSxcZl+ZDKBEO+jMBFR4= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= @@ -352,8 +349,8 @@ golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= -golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= @@ -372,18 +369,18 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA= -google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= -google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE= -google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw= +google.golang.org/api v0.282.0 h1:WmJiSVqUnKqJCpJOx7YADbXaC+9DDsnGSfllFSj7R2I= +google.golang.org/api v0.282.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/plugins/governance/httptransportprehook_test.go b/plugins/governance/httptransportprehook_test.go index 2fae3a224c4..3345efb298d 100644 --- a/plugins/governance/httptransportprehook_test.go +++ b/plugins/governance/httptransportprehook_test.go @@ -65,6 +65,92 @@ func TestHTTPTransportPreHook_VirtualKeyReplicateRefinesNestedModel(t *testing.T require.Equal(t, "replicate/openai/gpt-5-nano", payload.Model) } +func TestHTTPTransportPreHook_ModelOnlyVirtualKeySetsAvailableProviders(t *testing.T) { + logger := NewMockLogger() + + openAIConfig := buildProviderConfig("openai", []string{"gpt-4o"}) + openAIConfig.Weight = nil + anthropicConfig := buildProviderConfig("anthropic", []string{"claude-3-5-sonnet"}) + anthropicConfig.Weight = nil + + virtualKey := buildVirtualKeyWithProviders( + "vk-constraint", + "sk-bf-constraint-test", + "provider-constraint-vk", + []configstoreTables.TableVirtualKeyProviderConfig{ + openAIConfig, + anthropicConfig, + }, + ) + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + VirtualKeys: []configstoreTables.TableVirtualKey{*virtualKey}, + }, nil) + require.NoError(t, err) + + plugin, err := InitFromStore(context.Background(), &Config{IsVkMandatory: boolPtr(false)}, logger, store, nil, nil, nil, nil) + require.NoError(t, err) + defer func() { + require.NoError(t, plugin.Cleanup()) + }() + + req := schemas.AcquireHTTPRequest() + defer schemas.ReleaseHTTPRequest(req) + req.Method = "POST" + req.Path = "/v1/chat/completions" + req.Headers["Authorization"] = "Bearer sk-bf-constraint-test" + req.Headers["Content-Type"] = "application/json" + req.Body = []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"Hello!"}]}`) + + bfCtx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + resp, err := plugin.HTTPTransportPreHook(bfCtx, req) + require.NoError(t, err) + require.Nil(t, resp) + + allowedProviders, ok := bfCtx.Value(schemas.BifrostContextKeyAvailableProviders).([]schemas.ModelProvider) + require.True(t, ok, "provider constraint should be set") + require.Equal(t, []schemas.ModelProvider{schemas.OpenAI}, allowedProviders) +} + +func TestHTTPTransportPreHook_ModelOnlyVirtualKeySetsEmptyAvailableProvidersWhenNoProviderAllowsModel(t *testing.T) { + logger := NewMockLogger() + + virtualKey := buildVirtualKeyWithProviders( + "vk-empty-constraint", + "sk-bf-empty-constraint-test", + "empty-provider-constraint-vk", + []configstoreTables.TableVirtualKeyProviderConfig{ + buildProviderConfig("openai", []string{"gpt-4o"}), + }, + ) + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + VirtualKeys: []configstoreTables.TableVirtualKey{*virtualKey}, + }, nil) + require.NoError(t, err) + + plugin, err := InitFromStore(context.Background(), &Config{IsVkMandatory: boolPtr(false)}, logger, store, nil, nil, nil, nil) + require.NoError(t, err) + defer func() { + require.NoError(t, plugin.Cleanup()) + }() + + req := schemas.AcquireHTTPRequest() + defer schemas.ReleaseHTTPRequest(req) + req.Method = "POST" + req.Path = "/v1/chat/completions" + req.Headers["Authorization"] = "Bearer sk-bf-empty-constraint-test" + req.Headers["Content-Type"] = "application/json" + req.Body = []byte(`{"model":"claude-3-5-sonnet","messages":[{"role":"user","content":"Hello!"}]}`) + + bfCtx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + resp, err := plugin.HTTPTransportPreHook(bfCtx, req) + require.NoError(t, err) + require.Nil(t, resp) + + allowedProviders, ok := bfCtx.Value(schemas.BifrostContextKeyAvailableProviders).([]schemas.ModelProvider) + require.True(t, ok, "provider constraint should be set") + require.Empty(t, allowedProviders) +} + // TestHTTPTransportPreHook_GenAIRoutingRulePreservesTarget verifies that when a routing rule // matches on the /genai path, governance load balancing does not override the routing-rule target // with a provider from the VK pool (regression test for issue #2516). diff --git a/plugins/governance/main.go b/plugins/governance/main.go index 5658a19f819..b31c1580b08 100644 --- a/plugins/governance/main.go +++ b/plugins/governance/main.go @@ -755,6 +755,7 @@ func (p *GovernancePlugin) loadBalanceProvider(ctx *schemas.BifrostContext, req // Get provider configs for this virtual key providerConfigs := virtualKey.ProviderConfigs if len(providerConfigs) == 0 { + ctx.SetValue(schemas.BifrostContextKeyAvailableProviders, []schemas.ModelProvider{}) ctx.AppendRoutingEngineLog(schemas.RoutingEngineGovernance, schemas.LogLevelWarn, fmt.Sprintf("No provider configs on virtual key %s for model %s, skipping load balancing", virtualKey.Name, modelStr)) // No provider configs, continue without modification return body, nil @@ -818,9 +819,12 @@ func (p *GovernancePlugin) loadBalanceProvider(ctx *schemas.BifrostContext, req } var allowedProviders []string + allowedModelProviders := make([]schemas.ModelProvider, 0, len(allowedProviderConfigs)) for _, pc := range allowedProviderConfigs { allowedProviders = append(allowedProviders, pc.Provider) + allowedModelProviders = append(allowedModelProviders, schemas.ModelProvider(pc.Provider)) } + ctx.SetValue(schemas.BifrostContextKeyAvailableProviders, allowedModelProviders) p.logger.Debug("[Governance] Allowed providers after filtering: %v", allowedProviders) ctx.AppendRoutingEngineLog(schemas.RoutingEngineGovernance, schemas.LogLevelInfo, fmt.Sprintf("Allowed providers after filtering: %v", allowedProviders)) @@ -1819,8 +1823,13 @@ func (p *GovernancePlugin) postHookWorker(result *schemas.BifrostResponse, provi tokensUsed = *result.TranscriptionResponse.Usage.TotalTokens case result.TranscriptionStreamResponse != nil && result.TranscriptionStreamResponse.Usage != nil && result.TranscriptionStreamResponse.Usage.TotalTokens != nil: tokensUsed = *result.TranscriptionStreamResponse.Usage.TotalTokens + case result.PassthroughResponse != nil: + if su := result.PassthroughResponse.PassthroughUsage; su != nil && su.LLMUsage != nil { + tokensUsed = su.LLMUsage.TotalTokens + } } } + // Create usage update for tracker (business logic) usageUpdate := &UsageUpdate{ VirtualKey: virtualKey, @@ -1833,7 +1842,7 @@ func (p *GovernancePlugin) postHookWorker(result *schemas.BifrostResponse, provi UserID: userID, IsStreaming: isStreaming, IsFinalChunk: isFinalChunk, - HasUsageData: tokensUsed > 0, + HasUsageData: tokensUsed > 0 || cost > 0, } // Queue usage update asynchronously using tracker diff --git a/plugins/governance/modelprovidergovernance_test.go b/plugins/governance/modelprovidergovernance_test.go index f0fb5b1d2ee..c3a0a985d89 100644 --- a/plugins/governance/modelprovidergovernance_test.go +++ b/plugins/governance/modelprovidergovernance_test.go @@ -218,6 +218,74 @@ func TestStore_CheckModelBudget_ModelOnly_Exceeded(t *testing.T) { assert.Contains(t, err.Error(), "budget exceeded") } +// buildModelConfigMultiBudget builds a global model config owning multiple budgets +// (via TableBudget.ModelConfigID), for multi-budget enforcement tests. +func buildModelConfigMultiBudget(id, model string, provider *string, budgets []*configstoreTables.TableBudget) *configstoreTables.TableModelConfig { + mc := &configstoreTables.TableModelConfig{ + ID: id, + ModelName: model, + Provider: provider, + Scope: configstoreTables.ModelConfigScopeGlobal, + } + for _, b := range budgets { + b.ModelConfigID = &mc.ID + mc.Budgets = append(mc.Budgets, *b) + } + return mc +} + +func TestStore_CheckModelBudget_MultiBudget_OneExceededBlocks(t *testing.T) { + logger := NewMockLogger() + within := buildBudget("b-day", 100.0, "1d") // plenty of headroom + exceeded := buildBudgetWithUsage("b-hour", 10.0, 10.0, "1h") // at limit + mc := buildModelConfigMultiBudget("mc-multi", "gpt-4", nil, []*configstoreTables.TableBudget{within, exceeded}) + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + ModelConfigs: []configstoreTables.TableModelConfig{*mc}, + Budgets: []configstoreTables.TableBudget{*within, *exceeded}, + }, nil) + require.NoError(t, err) + + _, err = store.CheckModelBudget(context.Background(), &EvaluationRequest{Model: "gpt-4", Provider: schemas.OpenAI}, nil) + assert.Error(t, err, "the exceeded budget among several on one config must block") + assert.Contains(t, err.Error(), "budget exceeded") +} + +func TestStore_CheckModelBudget_MultiBudget_AllWithinPasses(t *testing.T) { + logger := NewMockLogger() + b1 := buildBudget("b-day", 100.0, "1d") + b2 := buildBudget("b-hour", 10.0, "1h") + mc := buildModelConfigMultiBudget("mc-multi", "gpt-4", nil, []*configstoreTables.TableBudget{b1, b2}) + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + ModelConfigs: []configstoreTables.TableModelConfig{*mc}, + Budgets: []configstoreTables.TableBudget{*b1, *b2}, + }, nil) + require.NoError(t, err) + + _, err = store.CheckModelBudget(context.Background(), &EvaluationRequest{Model: "gpt-4", Provider: schemas.OpenAI}, nil) + assert.NoError(t, err, "all budgets within limit should pass") +} + +func TestStore_UpdateModelBudgetUsage_MultiBudget_BumpsAll(t *testing.T) { + logger := NewMockLogger() + b1 := buildBudget("b-day", 100.0, "1d") + b2 := buildBudget("b-hour", 50.0, "1h") + mc := buildModelConfigMultiBudget("mc-multi", "gpt-4", nil, []*configstoreTables.TableBudget{b1, b2}) + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + ModelConfigs: []configstoreTables.TableModelConfig{*mc}, + Budgets: []configstoreTables.TableBudget{*b1, *b2}, + }, nil) + require.NoError(t, err) + + err = store.UpdateProviderAndModelBudgetUsageInMemory(context.Background(), "gpt-4", schemas.OpenAI, 7.5) + require.NoError(t, err) + + for _, id := range []string{"b-day", "b-hour"} { + b := store.LoadBudget(context.Background(), id) + require.NotNil(t, b, "budget %s should be loadable", id) + assert.InDelta(t, 7.5, b.CurrentUsage, 0.001, "every budget on the config must be bumped (budget %s)", id) + } +} + func TestStore_CheckModelBudget_ModelWithProvider_WithinLimit(t *testing.T) { logger := NewMockLogger() budget := buildBudget("budget1", 100.0, "1h") @@ -2100,3 +2168,311 @@ func TestStore_CheckModelBudget_NoCatalog_NoMatch(t *testing.T) { _, err = store.CheckModelBudget(context.Background(), &EvaluationRequest{Model: "gpt-4o", Provider: schemas.OpenAI}, nil) assert.Error(t, err, "Direct match should still work without catalog") } + +// ============================================================================ +// Store Tests - All-models ("*") wildcard tier (provider-level governance) +// ============================================================================ + +// TestStore_CheckModelBudget_AllModelsOnProvider_Exceeded verifies that an all-models +// wildcard config (provider=openai, model_name="*") — the migrated provider-level budget — +// applies to ANY model on that provider. +func TestStore_CheckModelBudget_AllModelsOnProvider_Exceeded(t *testing.T) { + logger := NewMockLogger() + budget := buildBudgetWithUsage("b1", 100.0, 100.0, "1h") // exceeded + providerStr := "openai" + mc := buildModelConfig("mc-provider", configstoreTables.ModelConfigAllModels, &providerStr, budget, nil) + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + ModelConfigs: []configstoreTables.TableModelConfig{*mc}, + Budgets: []configstoreTables.TableBudget{*budget}, + }, nil) + require.NoError(t, err) + + // A request for an arbitrary OpenAI model must be caught by the "*:openai" config. + _, err = store.CheckModelBudget(context.Background(), &EvaluationRequest{Model: "gpt-4o", Provider: schemas.OpenAI}, nil) + assert.Error(t, err, "all-models budget for the provider should apply to any model on it") + assert.Contains(t, err.Error(), "budget exceeded") +} + +// TestStore_CheckModelBudget_AllModelsOnProvider_OtherProviderPasses confirms the wildcard +// is provider-scoped: it must NOT affect a different provider. +func TestStore_CheckModelBudget_AllModelsOnProvider_OtherProviderPasses(t *testing.T) { + logger := NewMockLogger() + budget := buildBudgetWithUsage("b1", 100.0, 100.0, "1h") // exceeded + providerStr := "openai" + mc := buildModelConfig("mc-provider", configstoreTables.ModelConfigAllModels, &providerStr, budget, nil) + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + ModelConfigs: []configstoreTables.TableModelConfig{*mc}, + Budgets: []configstoreTables.TableBudget{*budget}, + }, nil) + require.NoError(t, err) + + decision, err := store.CheckModelBudget(context.Background(), &EvaluationRequest{Model: "claude-opus-4-7", Provider: schemas.Anthropic}, nil) + assert.NoError(t, err, "an OpenAI all-models budget must not affect an Anthropic request") + assert.Equal(t, DecisionAllow, decision) +} + +// TestStore_UpdateProviderModelUsage_BumpsAllModelsWildcard verifies usage recording reaches +// the all-models wildcard config (record-then-check loop for provider-level governance). +func TestStore_UpdateProviderModelUsage_BumpsAllModelsWildcard(t *testing.T) { + logger := NewMockLogger() + rateLimit := buildRateLimitWithUsage("rl1", 100, 0, 1000000, 0) // 100-token cap + providerStr := "openai" + mc := buildModelConfig("mc-provider", configstoreTables.ModelConfigAllModels, &providerStr, nil, rateLimit) + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + ModelConfigs: []configstoreTables.TableModelConfig{*mc}, + RateLimits: []configstoreTables.TableRateLimit{*rateLimit}, + }, nil) + require.NoError(t, err) + + // Within limit initially. + decision, err := store.CheckModelRateLimit(context.Background(), &EvaluationRequest{Model: "gpt-4o", Provider: schemas.OpenAI}, nil, nil) + require.NoError(t, err) + require.Equal(t, DecisionAllow, decision) + + // Record usage for a (different) model on the provider — must bump the "*:openai" config. + require.NoError(t, store.UpdateProviderAndModelRateLimitUsageInMemory(context.Background(), "gpt-4o", schemas.OpenAI, 150, true, true)) + + // Now the all-models rate limit trips for any model on the provider. + decision, err = store.CheckModelRateLimit(context.Background(), &EvaluationRequest{Model: "gpt-4o-mini", Provider: schemas.OpenAI}, nil, nil) + assert.Error(t, err) + assert.Equal(t, DecisionTokenLimited, decision) +} + +// ============================================================================ +// Store Tests - Per-VK-Scoped Model Budget / Rate Limit +// ============================================================================ + +func TestStore_CheckVirtualKeyScopedModelBudget_NilVK(t *testing.T) { + logger := NewMockLogger() + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{}, nil) + require.NoError(t, err) + + decision, err := store.CheckScopedModelBudget(context.Background(), "", "", &EvaluationRequest{Model: "gpt-4", Provider: schemas.OpenAI}, nil) + assert.NoError(t, err) + assert.Equal(t, DecisionAllow, decision) +} + +func TestStore_CheckVirtualKeyScopedModelBudget_NoConfig(t *testing.T) { + logger := NewMockLogger() + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{}, nil) + require.NoError(t, err) + + vk := buildVirtualKey("vk1", "vk1-value", "vk1", true) + decision, err := store.CheckScopedModelBudget(context.Background(), configstoreTables.ModelConfigScopeVirtualKey, vk.ID, &EvaluationRequest{Model: "gpt-4", Provider: schemas.OpenAI}, nil) + assert.NoError(t, err) + assert.Equal(t, DecisionAllow, decision) +} + +func TestStore_CheckVirtualKeyScopedModelBudget_WithinLimit(t *testing.T) { + logger := NewMockLogger() + vk := buildVirtualKey("vk1", "vk1-value", "vk1", true) + budget := buildBudget("b1", 100.0, "1h") + mc := buildVKScopedModelConfig("mc1", "gpt-4", nil, vk.ID, budget, nil) + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + ModelConfigs: []configstoreTables.TableModelConfig{*mc}, + Budgets: []configstoreTables.TableBudget{*budget}, + }, nil) + require.NoError(t, err) + + _, err = store.CheckScopedModelBudget(context.Background(), configstoreTables.ModelConfigScopeVirtualKey, vk.ID, &EvaluationRequest{Model: "gpt-4", Provider: schemas.OpenAI}, nil) + assert.NoError(t, err, "Should allow when per-VK model budget is within limit") +} + +func TestStore_CheckVirtualKeyScopedModelBudget_Exceeded(t *testing.T) { + logger := NewMockLogger() + vk := buildVirtualKey("vk1", "vk1-value", "vk1", true) + budget := buildBudgetWithUsage("b1", 100.0, 100.0, "1h") // At limit + mc := buildVKScopedModelConfig("mc1", "gpt-4", nil, vk.ID, budget, nil) + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + ModelConfigs: []configstoreTables.TableModelConfig{*mc}, + Budgets: []configstoreTables.TableBudget{*budget}, + }, nil) + require.NoError(t, err) + + _, err = store.CheckScopedModelBudget(context.Background(), configstoreTables.ModelConfigScopeVirtualKey, vk.ID, &EvaluationRequest{Model: "gpt-4", Provider: schemas.OpenAI}, nil) + assert.Error(t, err, "Should reject when per-VK model budget is exceeded") + assert.Contains(t, err.Error(), "budget exceeded") +} + +func TestStore_CheckVirtualKeyScopedModelBudget_OnlyAppliesToMatchingVK(t *testing.T) { + logger := NewMockLogger() + ownerVK := buildVirtualKey("vk1", "vk1-value", "vk1", true) + otherVK := buildVirtualKey("vk2", "vk2-value", "vk2", true) + budget := buildBudgetWithUsage("b1", 100.0, 100.0, "1h") // exceeded + mc := buildVKScopedModelConfig("mc1", "gpt-4", nil, ownerVK.ID, budget, nil) + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + ModelConfigs: []configstoreTables.TableModelConfig{*mc}, + Budgets: []configstoreTables.TableBudget{*budget}, + }, nil) + require.NoError(t, err) + + // A request made with a DIFFERENT virtual key must not be affected by vk1's scoped config. + decision, err := store.CheckScopedModelBudget(context.Background(), configstoreTables.ModelConfigScopeVirtualKey, otherVK.ID, &EvaluationRequest{Model: "gpt-4", Provider: schemas.OpenAI}, nil) + assert.NoError(t, err) + assert.Equal(t, DecisionAllow, decision) +} + +func TestStore_CheckVirtualKeyScopedModelBudget_IgnoresGlobalConfig(t *testing.T) { + logger := NewMockLogger() + vk := buildVirtualKey("vk1", "vk1-value", "vk1", true) + // A GLOBAL (scope defaults to global) model config that is exceeded. The per-VK scoped + // check must ignore it — global is enforced separately by EvaluateModelAndProviderRequest, + // so the scoped path must not double-count it. + budget := buildBudgetWithUsage("b1", 100.0, 100.0, "1h") + globalMC := buildModelConfig("mc-global", "gpt-4", nil, budget, nil) + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + ModelConfigs: []configstoreTables.TableModelConfig{*globalMC}, + Budgets: []configstoreTables.TableBudget{*budget}, + }, nil) + require.NoError(t, err) + + decision, err := store.CheckScopedModelBudget(context.Background(), configstoreTables.ModelConfigScopeVirtualKey, vk.ID, &EvaluationRequest{Model: "gpt-4", Provider: schemas.OpenAI}, nil) + assert.NoError(t, err, "Scoped check must not pick up the global config") + assert.Equal(t, DecisionAllow, decision) + + // Sanity: the global model check DOES still catch the exceeded global budget. + _, gErr := store.CheckModelBudget(context.Background(), &EvaluationRequest{Model: "gpt-4", Provider: schemas.OpenAI}, nil) + assert.Error(t, gErr, "Global model check should catch the exceeded global budget") +} + +func TestStore_CheckVirtualKeyScopedModelRateLimit_TokenLimitExceeded(t *testing.T) { + logger := NewMockLogger() + vk := buildVirtualKey("vk1", "vk1-value", "vk1", true) + rateLimit := buildRateLimitWithUsage("rl1", 10000, 10000, 1000, 0) // tokens at max + mc := buildVKScopedModelConfig("mc1", "gpt-4", nil, vk.ID, nil, rateLimit) + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + ModelConfigs: []configstoreTables.TableModelConfig{*mc}, + RateLimits: []configstoreTables.TableRateLimit{*rateLimit}, + }, nil) + require.NoError(t, err) + + decision, err := store.CheckScopedModelRateLimit(context.Background(), configstoreTables.ModelConfigScopeVirtualKey, vk.ID, &EvaluationRequest{Model: "gpt-4", Provider: schemas.OpenAI}, nil, nil) + assert.Error(t, err, "Should reject when per-VK model token limit is exceeded") + assert.Equal(t, DecisionTokenLimited, decision) +} + +func TestStore_CheckVirtualKeyScopedModelRateLimit_WithinLimit(t *testing.T) { + logger := NewMockLogger() + vk := buildVirtualKey("vk1", "vk1-value", "vk1", true) + rateLimit := buildRateLimitWithUsage("rl1", 10000, 100, 1000, 10) // well within limits + mc := buildVKScopedModelConfig("mc1", "gpt-4", nil, vk.ID, nil, rateLimit) + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + ModelConfigs: []configstoreTables.TableModelConfig{*mc}, + RateLimits: []configstoreTables.TableRateLimit{*rateLimit}, + }, nil) + require.NoError(t, err) + + decision, err := store.CheckScopedModelRateLimit(context.Background(), configstoreTables.ModelConfigScopeVirtualKey, vk.ID, &EvaluationRequest{Model: "gpt-4", Provider: schemas.OpenAI}, nil, nil) + assert.NoError(t, err) + assert.Equal(t, DecisionAllow, decision) +} + +// TestStore_VirtualKeyScopedModel_RecordThenCheck_TokenLimitTrips reproduces the reported bug: +// recording usage against a per-VK scoped model config must increment the scoped counter so a +// subsequent check trips. (The original bug only wired the check, not the usage recording.) +func TestStore_VirtualKeyScopedModel_RecordThenCheck_TokenLimitTrips(t *testing.T) { + logger := NewMockLogger() + vk := buildVirtualKey("vk1", "vk1-value", "vk1", true) + rateLimit := buildRateLimitWithUsage("rl1", 100, 0, 1000000, 0) // 100 token cap, request cap effectively unlimited + mc := buildVKScopedModelConfig("mc1", "claude-opus-4-7", nil, vk.ID, nil, rateLimit) + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + ModelConfigs: []configstoreTables.TableModelConfig{*mc}, + RateLimits: []configstoreTables.TableRateLimit{*rateLimit}, + }, nil) + require.NoError(t, err) + + req := &EvaluationRequest{Model: "claude-opus-4-7", Provider: schemas.Anthropic} + + // Initially within limit. + decision, err := store.CheckScopedModelRateLimit(context.Background(), configstoreTables.ModelConfigScopeVirtualKey, vk.ID, req, nil, nil) + require.NoError(t, err) + require.Equal(t, DecisionAllow, decision) + + // Record usage above the limit (what the tracker does post-response). Provider differs from + // the config's (which is all-providers), exercising the model-only scoped lookup. + require.NoError(t, store.UpdateScopedModelRateLimitUsageInMemory(context.Background(), configstoreTables.ModelConfigScopeVirtualKey, vk.ID, "claude-opus-4-7", schemas.Anthropic, 150, true, true)) + + // Now the scoped check must trip. + decision, err = store.CheckScopedModelRateLimit(context.Background(), configstoreTables.ModelConfigScopeVirtualKey, vk.ID, req, nil, nil) + assert.Error(t, err) + assert.Equal(t, DecisionTokenLimited, decision) +} + +func TestStore_VirtualKeyScopedModel_RecordThenCheck_BudgetTrips(t *testing.T) { + logger := NewMockLogger() + vk := buildVirtualKey("vk1", "vk1-value", "vk1", true) + budget := buildBudget("b1", 10.0, "1h") // $10 cap, 0 usage + mc := buildVKScopedModelConfig("mc1", "claude-opus-4-7", nil, vk.ID, budget, nil) + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + ModelConfigs: []configstoreTables.TableModelConfig{*mc}, + Budgets: []configstoreTables.TableBudget{*budget}, + }, nil) + require.NoError(t, err) + + req := &EvaluationRequest{Model: "claude-opus-4-7", Provider: schemas.Anthropic} + + decision, err := store.CheckScopedModelBudget(context.Background(), configstoreTables.ModelConfigScopeVirtualKey, vk.ID, req, nil) + require.NoError(t, err) + require.Equal(t, DecisionAllow, decision) + + require.NoError(t, store.UpdateScopedModelBudgetUsageInMemory(context.Background(), configstoreTables.ModelConfigScopeVirtualKey, vk.ID, "claude-opus-4-7", schemas.Anthropic, 15.0)) + + _, err = store.CheckScopedModelBudget(context.Background(), configstoreTables.ModelConfigScopeVirtualKey, vk.ID, req, nil) + assert.Error(t, err, "scoped budget should trip once usage exceeds the cap") +} + +// TestStore_VKGovernanceBudget_NoDoubleCount is the double-count guard: after the cutover a +// VK's budget lives only on its VK-scoped all-models wildcard model config (vk.Budgets is +// empty). The tracker invokes both the scoped-model path and the VK hierarchy path on every +// request; only the scoped path may charge the budget — never both. +func TestStore_VKGovernanceBudget_NoDoubleCount(t *testing.T) { + logger := NewMockLogger() + vk := buildVirtualKey("vk1", "vk1-value", "vk1", true) + vk.ProviderConfigs = []configstoreTables.TableVirtualKeyProviderConfig{buildProviderConfig("openai", []string{"*"})} + budget := buildBudget("vkb", 100.0, "1h") + // Owned by the VK-scoped all-models wildcard (provider=nil), not by the VK directly. + mc := buildVKScopedModelConfig("mc-vk", "*", nil, vk.ID, budget, nil) + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + VirtualKeys: []configstoreTables.TableVirtualKey{*vk}, + ModelConfigs: []configstoreTables.TableModelConfig{*mc}, + Budgets: []configstoreTables.TableBudget{*budget}, + }, nil) + require.NoError(t, err) + + // Mirror tracker.UpdateUsage: scoped-model path + hierarchy path, same request/cost. + require.NoError(t, store.UpdateScopedModelBudgetUsageInMemory(context.Background(), configstoreTables.ModelConfigScopeVirtualKey, vk.ID, "gpt-4", schemas.OpenAI, 10.0)) + require.NoError(t, store.UpdateVirtualKeyBudgetUsageInMemory(context.Background(), vk, schemas.OpenAI, 10.0)) + + b := store.LoadBudget(context.Background(), "vkb") + require.NotNil(t, b) + assert.InDelta(t, 10.0, b.CurrentUsage, 0.001, "VK governance budget must be charged exactly once (no hierarchy+scoped double count)") +} + +// TestStore_CheckVirtualKeyScopedModelBudget_MultiBudget_OneExceededBlocks exercises the +// scope-chain path that production VK governance flows through after cutover, with multiple +// budgets on one VK-scoped wildcard config. +func TestStore_CheckVirtualKeyScopedModelBudget_MultiBudget_OneExceededBlocks(t *testing.T) { + logger := NewMockLogger() + vk := buildVirtualKey("vk1", "vk1-value", "vk1", true) + within := buildBudget("b-day", 100.0, "1d") + exceeded := buildBudgetWithUsage("b-hour", 10.0, 10.0, "1h") + mcID := "mc-vk-multi" + mc := &configstoreTables.TableModelConfig{ + ID: mcID, + ModelName: configstoreTables.ModelConfigAllModels, + Scope: configstoreTables.ModelConfigScopeVirtualKey, + ScopeID: &vk.ID, + } + for _, b := range []*configstoreTables.TableBudget{within, exceeded} { + b.ModelConfigID = &mcID + mc.Budgets = append(mc.Budgets, *b) + } + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + ModelConfigs: []configstoreTables.TableModelConfig{*mc}, + Budgets: []configstoreTables.TableBudget{*within, *exceeded}, + }, nil) + require.NoError(t, err) + + _, err = store.CheckScopedModelBudget(context.Background(), configstoreTables.ModelConfigScopeVirtualKey, vk.ID, &EvaluationRequest{Model: "gpt-4", Provider: schemas.OpenAI}, nil) + assert.Error(t, err, "an exceeded budget among several on a VK-scoped config must block") +} diff --git a/plugins/governance/resolver.go b/plugins/governance/resolver.go index 3bd169c6ee5..84dd9e207b2 100644 --- a/plugins/governance/resolver.go +++ b/plugins/governance/resolver.go @@ -215,6 +215,24 @@ func (r *BudgetResolver) EvaluateUserRequest(ctx *schemas.BifrostContext, userID } } + // Check per-user-scoped model config rate limits and budgets. Mirrors the + // VK-scoped block in EvaluateVirtualKeyRequest. Gated on model being present — + // MCP tool execution (no model) is excluded naturally by this guard. + if request.Model != "" { + if decision, err := r.store.CheckScopedModelRateLimit(ctx, configstoreTables.ModelConfigScopeUser, userID, request, nil, nil); err != nil || isRateLimitViolation(decision) { + return &EvaluationResult{ + Decision: decision, + Reason: fmt.Sprintf("User-level model rate limit exceeded: %s", reasonFromErr(err, decision)), + } + } + if decision, err := r.store.CheckScopedModelBudget(ctx, configstoreTables.ModelConfigScopeUser, userID, request, nil); err != nil || isBudgetViolation(decision) { + return &EvaluationResult{ + Decision: decision, + Reason: fmt.Sprintf("User-level model budget exceeded: %s", reasonFromErr(err, decision)), + } + } + } + return &EvaluationResult{ Decision: DecisionAllow, Reason: "User-level checks passed", @@ -261,8 +279,10 @@ func (r *BudgetResolver) EvaluateVirtualKeyRequest(ctx *schemas.BifrostContext, VirtualKey: vk, } } - // 3. Check model filtering - if IsModelRequiredForRequest(requestType) && !r.isModelAllowed(vk, provider, model) { + // 3. Check model filtering. Most request types always carry a model and are always checked. + // Passthrough forwards raw provider routes where a model may or may not be resolvable for some request types. + isPassthrough := requestType == schemas.PassthroughRequest || requestType == schemas.PassthroughStreamRequest + if (IsModelRequiredForRequest(requestType) || (isPassthrough && model != "")) && !r.isModelAllowed(vk, provider, model) { return &EvaluationResult{ Decision: DecisionModelBlocked, Reason: fmt.Sprintf("Model '%s' is not allowed for this virtual key", model), @@ -286,6 +306,27 @@ func (r *BudgetResolver) EvaluateVirtualKeyRequest(ctx *schemas.BifrostContext, if budgetResult := r.checkBudgetHierarchy(ctx, vk, evaluationRequest); budgetResult != nil { return budgetResult } + + // 6. Check per-VK-scoped model config rate limits and budgets. These aggregate with + // the global model checks already enforced in EvaluateModelAndProviderRequest — the + // request must satisfy both (most-restrictive wins). Gated on a model being present, + // mirroring the global model checks. + if model != "" { + if decision, err := r.store.CheckScopedModelRateLimit(ctx, configstoreTables.ModelConfigScopeVirtualKey, vk.ID, evaluationRequest, nil, nil); err != nil || isRateLimitViolation(decision) { + return &EvaluationResult{ + Decision: decision, + Reason: fmt.Sprintf("Model-level rate limit check failed (virtual key scope): %s", reasonFromErr(err, decision)), + VirtualKey: vk, + } + } + if decision, err := r.store.CheckScopedModelBudget(ctx, configstoreTables.ModelConfigScopeVirtualKey, vk.ID, evaluationRequest, nil); err != nil || isBudgetViolation(decision) { + return &EvaluationResult{ + Decision: decision, + Reason: fmt.Sprintf("Model-level budget exceeded (virtual key scope): %s", reasonFromErr(err, decision)), + VirtualKey: vk, + } + } + } } // Find the provider config that matches the request's provider and apply key filtering diff --git a/plugins/governance/resolver_test.go b/plugins/governance/resolver_test.go index 718e2632a6e..86fb7a40416 100644 --- a/plugins/governance/resolver_test.go +++ b/plugins/governance/resolver_test.go @@ -504,3 +504,47 @@ func TestBudgetResolver_ContextPopulation(t *testing.T) { assert.Equal(t, "team1", teamID) assert.Equal(t, "cust1", customerID) } + +// TestBudgetResolver_EvaluateRequest_PassthroughModelFiltering verifies that passthrough requests +// enforce the VK's model allowlist only when a model is resolved: a disallowed model is blocked, an +// allowed model passes, and an absent model imposes no model restriction. Non-passthrough +// model-not-required types (e.g. batch) remain unfiltered, confirming the change is scoped. +func TestBudgetResolver_EvaluateRequest_PassthroughModelFiltering(t *testing.T) { + tests := []struct { + name string + model string + requestType schemas.RequestType + want Decision + }{ + {"passthrough disallowed model is blocked", "gpt-4o-mini", schemas.PassthroughRequest, DecisionModelBlocked}, + {"passthrough allowed model passes", "gpt-4", schemas.PassthroughRequest, DecisionAllow}, + {"passthrough without model has no restriction", "", schemas.PassthroughRequest, DecisionAllow}, + {"passthrough stream disallowed model is blocked", "gpt-4o-mini", schemas.PassthroughStreamRequest, DecisionModelBlocked}, + {"passthrough stream allowed model passes", "gpt-4", schemas.PassthroughStreamRequest, DecisionAllow}, + {"passthrough stream without model has no restriction", "", schemas.PassthroughStreamRequest, DecisionAllow}, + // Scoping guard: batch is model-not-required and not passthrough, so its model is never + // filtered even when set to a disallowed value (behavior unchanged by the passthrough fix). + {"batch with disallowed model is not filtered", "gpt-4o-mini", schemas.BatchCreateRequest, DecisionAllow}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logger := NewMockLogger() + providerConfigs := []configstoreTables.TableVirtualKeyProviderConfig{ + buildProviderConfig("openai", []string{"gpt-4", "gpt-4-turbo"}), + } + vk := buildVirtualKeyWithProviders("vk1", "sk-bf-test", "Test VK", providerConfigs) + + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + VirtualKeys: []configstoreTables.TableVirtualKey{*vk}, + }, nil) + require.NoError(t, err) + + resolver := NewBudgetResolver(store, nil, logger, nil) + ctx := &schemas.BifrostContext{} + + result := resolver.EvaluateVirtualKeyRequest(ctx, "sk-bf-test", schemas.OpenAI, tt.model, tt.requestType, false) + assertDecision(t, tt.want, result) + }) + } +} diff --git a/plugins/governance/store.go b/plugins/governance/store.go index 5da25ca63f4..46399d5b221 100644 --- a/plugins/governance/store.go +++ b/plugins/governance/store.go @@ -118,12 +118,22 @@ type GovernanceStore interface { // Model-level governance checks CheckModelBudget(ctx context.Context, request *EvaluationRequest, baselines map[string]float64) (Decision, error) CheckModelRateLimit(ctx context.Context, request *EvaluationRequest, tokensBaselines map[string]int64, requestsBaselines map[string]int64) (Decision, error) + // Scoped model-level governance checks (aggregate with the global model checks above). + // scope/scopeID identify the owning entity (e.g. "virtual_key" + VK.ID, or any other + // scope registered via tables.RegisterModelConfigScope). An empty scope or scopeID + // is a no-op (returns DecisionAllow). + CheckScopedModelBudget(ctx context.Context, scope, scopeID string, request *EvaluationRequest, baselines map[string]float64) (Decision, error) + CheckScopedModelRateLimit(ctx context.Context, scope, scopeID string, request *EvaluationRequest, tokensBaselines map[string]int64, requestsBaselines map[string]int64) (Decision, error) // VK-level governance checks CheckVirtualKeyBudget(ctx context.Context, vk *configstoreTables.TableVirtualKey, request *EvaluationRequest, baselines map[string]float64) (Decision, error) CheckVirtualKeyRateLimit(ctx context.Context, vk *configstoreTables.TableVirtualKey, request *EvaluationRequest, tokensBaselines map[string]int64, requestsBaselines map[string]int64) (Decision, error) // In-memory usage updates (for VK-level) UpdateVirtualKeyBudgetUsageInMemory(ctx context.Context, vk *configstoreTables.TableVirtualKey, provider schemas.ModelProvider, cost float64) error UpdateVirtualKeyRateLimitUsageInMemory(ctx context.Context, vk *configstoreTables.TableVirtualKey, provider schemas.ModelProvider, tokensUsed int64, shouldUpdateTokens bool, shouldUpdateRequests bool) error + // In-memory usage updates for scoped model configs (mirror the global model updates). + // scope/scopeID identify the owning entity; an empty scope or scopeID is a no-op. + UpdateScopedModelBudgetUsageInMemory(ctx context.Context, scope, scopeID, model string, provider schemas.ModelProvider, cost float64) error + UpdateScopedModelRateLimitUsageInMemory(ctx context.Context, scope, scopeID, model string, provider schemas.ModelProvider, tokensUsed int64, shouldUpdateTokens bool, shouldUpdateRequests bool) error // In-memory reset checks (return items that need DB sync) ResetExpiredRateLimitsInMemory(ctx context.Context) []*configstoreTables.TableRateLimit ResetExpiredBudgetsInMemory(ctx context.Context) []*configstoreTables.TableBudget @@ -166,6 +176,7 @@ type GovernanceStore interface { // Model config in-memory operations UpdateModelConfigInMemory(ctx context.Context, mc *configstoreTables.TableModelConfig) *configstoreTables.TableModelConfig DeleteModelConfigInMemory(ctx context.Context, mcID string) + ScopedModelConfigIDs(scope, scopeID string) []string // Provider in-memory operations UpdateProviderInMemory(ctx context.Context, provider *configstoreTables.TableProvider) *configstoreTables.TableProvider DeleteProviderInMemory(ctx context.Context, providerName string) @@ -440,6 +451,35 @@ func (gs *LocalGovernanceStore) BumpRateLimitUsage(ctx context.Context, rateLimi } } +// BumpRateLimitUsageBy atomically adds arbitrary token and request deltas to the +// rate limit identified by rateLimitID. Unlike BumpRateLimitUsage (which adds a +// token count and a single request), this adds caller-supplied counts on both +// dimensions — used to fold accumulated usage carried from another rate limit. +// Same CAS-retry contract: no increment is dropped under concurrent callers. +// No window-reset side effect, since carried deltas are not request traffic. +// No-op when the rate limit is absent or both deltas are zero. +func (gs *LocalGovernanceStore) BumpRateLimitUsageBy(ctx context.Context, rateLimitID string, tokenDelta, requestDelta int64) error { + if tokenDelta == 0 && requestDelta == 0 { + return nil + } + for { + raw, exists := gs.rateLimits.Load(rateLimitID) + if !exists || raw == nil { + return nil + } + old, ok := raw.(*configstoreTables.TableRateLimit) + if !ok || old == nil { + return nil + } + clone := *old + clone.TokenCurrentUsage += tokenDelta + clone.RequestCurrentUsage += requestDelta + if gs.rateLimits.CompareAndSwap(rateLimitID, raw, &clone) { + return nil + } + } +} + // ResetBudgetAt atomically zeros the budget's CurrentUsage and advances its // LastReset to newLastReset, provided the currently-stored budget has an // older LastReset. Returns the reset budget and true when the CAS succeeds; @@ -629,13 +669,18 @@ func (gs *LocalGovernanceStore) GetGovernanceData(ctx context.Context) *Governan clone := *customer clone.Teams = make([]configstoreTables.TableTeam, 0) clone.VirtualKeys = make([]configstoreTables.TableVirtualKey, 0) - if clone.BudgetID != nil { - if liveBudget, exists := gs.budgets.Load(*clone.BudgetID); exists && liveBudget != nil { - if b, ok := liveBudget.(*configstoreTables.TableBudget); ok { - clone.Budget = b + // Refresh each owned budget from the live budget map. + refreshedBudgets := make([]configstoreTables.TableBudget, 0, len(clone.Budgets)) + for _, b := range clone.Budgets { + if liveBudget, exists := gs.budgets.Load(b.ID); exists && liveBudget != nil { + if lb, ok := liveBudget.(*configstoreTables.TableBudget); ok { + refreshedBudgets = append(refreshedBudgets, *lb) + continue } } + refreshedBudgets = append(refreshedBudgets, b) } + clone.Budgets = refreshedBudgets if clone.RateLimitID != nil { if liveRL, exists := gs.rateLimits.Load(*clone.RateLimitID); exists && liveRL != nil { if rl, ok := liveRL.(*configstoreTables.TableRateLimit); ok { @@ -738,15 +783,18 @@ func (gs *LocalGovernanceStore) GetGovernanceData(ctx context.Context) *Governan if !ok || mc == nil { return true // continue } - // Cross-reference live budget/rate limit from standalone maps - // (usage updates clone into budgets/rateLimits maps, so embedded pointers go stale) + // Cross-reference live budgets/rate limit from standalone maps. clone := *mc - if clone.BudgetID != nil { - if liveBudget, exists := gs.budgets.Load(*clone.BudgetID); exists && liveBudget != nil { - if b, ok := liveBudget.(*configstoreTables.TableBudget); ok { - clone.Budget = b + if len(clone.Budgets) > 0 { + liveBudgets := make([]configstoreTables.TableBudget, 0, len(clone.Budgets)) + for _, b := range clone.Budgets { + if lb, exists := gs.budgets.Load(b.ID); exists && lb != nil { + if budget, ok := lb.(*configstoreTables.TableBudget); ok { + liveBudgets = append(liveBudgets, *budget) + } } } + clone.Budgets = liveBudgets } if clone.RateLimitID != nil { if liveRL, exists := gs.rateLimits.Load(*clone.RateLimitID); exists && liveRL != nil { @@ -1003,66 +1051,163 @@ func (gs *LocalGovernanceStore) CheckProviderRateLimit(ctx context.Context, requ return gs.CheckRateLimit(ctx, EntityWiseRateLimits{providerKey: []*configstoreTables.TableRateLimit{rateLimit}}, tokensBaselines, requestsBaselines) } -// findModelOnlyConfig looks up a model-only config (no provider) with cross-provider model name normalization. -// Returns the matching config and the display name for error messages. -func (gs *LocalGovernanceStore) findModelOnlyConfig(ctx context.Context, model string) (*configstoreTables.TableModelConfig, string) { - // If modelMatcher is available, try normalized base model name first (cross-provider matching) +const modelConfigWildcard = configstoreTables.ModelConfigAllModels + +// modelConfigStoreKey builds the in-memory cache key for a model config. +func modelConfigStoreKey(scope, scopeID, modelKey string, provider *string) string { + base := modelKey + if provider != nil { + base = fmt.Sprintf("%s:%s", modelKey, *provider) + } + if scope == "" || scope == configstoreTables.ModelConfigScopeGlobal { + return base + } + return fmt.Sprintf("%s:%s:%s", scope, scopeID, base) +} + +// modelConfigScope is one level of the model-config scope chain (name + target ID). +type modelConfigScope struct { + name string + id string +} + +// nonGlobalModelConfigScopeChain returns the non-global scopes that apply to a request +// made with the given virtual key, most specific first. The global scope is intentionally +// excluded because it is enforced separately (and unconditionally) by EvaluateModelAndProviderRequest. +func nonGlobalModelConfigScopeChain(vk *configstoreTables.TableVirtualKey) []modelConfigScope { + if vk == nil { + return nil + } + return []modelConfigScope{{name: configstoreTables.ModelConfigScopeVirtualKey, id: vk.ID}} +} + +// findScopedModelOnlyConfig looks up a model-only config (no provider) within a specific +// scope, preserving cross-provider model-name normalization. scope=="global" reproduces the +// historical global lookup exactly. Returns the matching config and the display name. +func (gs *LocalGovernanceStore) findScopedModelOnlyConfig(ctx context.Context, scope, scopeID, model string) (*configstoreTables.TableModelConfig, string) { + tryKey := func(modelKey string) (*configstoreTables.TableModelConfig, string) { + key := modelConfigStoreKey(scope, scopeID, modelKey, nil) + if value, exists := gs.modelConfigs.Load(key); exists && value != nil { + if mc, ok := value.(*configstoreTables.TableModelConfig); ok && mc != nil { + return mc, modelKey + } + } + return nil, "" + } + // If modelCatalog is available, try normalized base model name first (cross-provider matching) if gs.modelCatalog != nil { baseName := gs.modelCatalog.GetBaseModelName(model) if baseName != model { - if value, exists := gs.modelConfigs.Load(baseName); exists && value != nil { - if mc, ok := value.(*configstoreTables.TableModelConfig); ok && mc != nil { - return mc, baseName - } + if mc, name := tryKey(baseName); mc != nil { + return mc, name } } } // Always try direct lookup by original model name as fallback - if value, exists := gs.modelConfigs.Load(model); exists && value != nil { - if mc, ok := value.(*configstoreTables.TableModelConfig); ok && mc != nil { - return mc, model + return tryKey(model) +} + +// extractModelAndProvider extracts the model name and optional provider from a request. +func extractModelAndProvider(request *EvaluationRequest) (string, *string) { + if request == nil { + return "", nil + } + var provider *string + if request.Provider != "" { + p := string(request.Provider) + provider = &p + } + return request.Model, provider +} + +// collectModelConfigsFor returns every model config that applies to a request for +// (model, provider) within a single scope, across four tiers (most → least specific), +// deduped by config ID: +// 1. (model, provider) exact model on this provider +// 2. (model, nil) exact model on all providers (base-name normalized) +// 3. ("*", provider) all models on this provider (provider-level governance) +// 4. ("*", nil) all models on all providers +// +// This is the single source of truth for "which model configs apply"; every budget / +// rate-limit check and usage-tracking site iterates it so the wildcard tiers are matched +// consistently everywhere. +func (gs *LocalGovernanceStore) collectModelConfigsFor(ctx context.Context, scope, scopeID, model string, provider *string) []*configstoreTables.TableModelConfig { + var out []*configstoreTables.TableModelConfig + seen := make(map[string]bool) + add := func(mc *configstoreTables.TableModelConfig) { + if mc == nil || seen[mc.ID] { + return } + seen[mc.ID] = true + out = append(out, mc) + } + loadKey := func(modelKey string, prov *string) *configstoreTables.TableModelConfig { + if value, exists := gs.modelConfigs.Load(modelConfigStoreKey(scope, scopeID, modelKey, prov)); exists && value != nil { + if mc, ok := value.(*configstoreTables.TableModelConfig); ok { + return mc + } + } + return nil + } + if provider != nil { + add(loadKey(model, provider)) // tier 1: exact model + provider + } + if mc, _ := gs.findScopedModelOnlyConfig(ctx, scope, scopeID, model); mc != nil { + add(mc) // tier 2: exact model, all providers (normalized) + } + if provider != nil { + add(loadKey(modelConfigWildcard, provider)) // tier 3: all models on this provider } - return nil, "" + add(loadKey(modelConfigWildcard, nil)) // tier 4: all models, all providers + return out } -// CheckModelBudget performs budget checking for model-level configs (lock-free for high performance) +// modelConfigEntityKey builds a stable, unique entity description for a model config +func modelConfigEntityKey(mc *configstoreTables.TableModelConfig) string { + name := mc.ModelName + if name == modelConfigWildcard { + name = "AllModels" + } + key := "Model:" + name + if mc.Provider != nil { + key += ":Provider:" + *mc.Provider + } + if mc.Scope != "" && mc.Scope != configstoreTables.ModelConfigScopeGlobal { + scopeID := "" + if mc.ScopeID != nil { + scopeID = *mc.ScopeID + } + key += ":" + mc.Scope + ":" + scopeID + } + return key +} + +// loadModelConfigBudgets returns the hot in-memory budget rows owned by a model config +func (gs *LocalGovernanceStore) loadModelConfigBudgets(ctx context.Context, mc *configstoreTables.TableModelConfig) []*configstoreTables.TableBudget { + if mc == nil || len(mc.Budgets) == 0 { + return nil + } + out := make([]*configstoreTables.TableBudget, 0, len(mc.Budgets)) + for i := range mc.Budgets { + if budget := gs.LoadBudget(ctx, mc.Budgets[i].ID); budget != nil { + out = append(out, budget) + } + } + return out +} + +// CheckModelBudget performs budget checking for global-scope model-level configs, across all +// four tiers (exact model±provider and all-models "*"±provider). func (gs *LocalGovernanceStore) CheckModelBudget(ctx context.Context, request *EvaluationRequest, baselines map[string]float64) (Decision, error) { // This is to prevent nil pointer dereference if baselines == nil { baselines = map[string]float64{} } - // Extract model and provider from request - var model string - var provider *schemas.ModelProvider - if request != nil { - model = request.Model - if request.Provider != "" { - provider = &request.Provider - } - } - // Collect model configs to check: model+provider (if exists) AND model-only (if exists) + model, provider := extractModelAndProvider(request) entityWiseBudgets := EntityWiseBudgets{} - // Check model+provider config first (more specific) - if provider is provided - if provider != nil { - key := fmt.Sprintf("%s:%s", model, string(*provider)) - if value, exists := gs.modelConfigs.Load(key); exists && value != nil { - if mc, ok := value.(*configstoreTables.TableModelConfig); ok && mc != nil && mc.Budget != nil { - budget := gs.LoadBudget(ctx, *mc.BudgetID) - if budget != nil { - key := fmt.Sprintf("Model:%s:Provider:%s", mc.ModelName, *provider) - entityWiseBudgets[key] = []*configstoreTables.TableBudget{budget} - } - } - } - } - // Always check model-only config (if exists) - regardless of whether model+provider config exists - // Uses findModelOnlyConfig for cross-provider model name normalization - if mc, _ := gs.findModelOnlyConfig(ctx, model); mc != nil && mc.Budget != nil { - budget := gs.LoadBudget(ctx, *mc.BudgetID) - if budget != nil { - key := fmt.Sprintf("Model:%s", mc.ModelName) - entityWiseBudgets[key] = []*configstoreTables.TableBudget{budget} + for _, mc := range gs.collectModelConfigsFor(ctx, configstoreTables.ModelConfigScopeGlobal, "", model, provider) { + if budgets := gs.loadModelConfigBudgets(ctx, mc); len(budgets) > 0 { + entityWiseBudgets[modelConfigEntityKey(mc)] = budgets } } return gs.CheckBudget(ctx, entityWiseBudgets, baselines) @@ -1135,15 +1280,20 @@ func (gs *LocalGovernanceStore) CheckCustomerBudget(ctx context.Context, custome return DecisionAllow, nil } customer, ok := customerValue.(*configstoreTables.TableCustomer) - if !ok || customer.BudgetID == nil { + if !ok || len(customer.Budgets) == 0 { return DecisionAllow, nil } - customerBudget := gs.LoadBudget(ctx, *customer.BudgetID) - if customerBudget == nil { + key := fmt.Sprintf("Customer:%s", customerID) + var customerBudgets []*configstoreTables.TableBudget + for i := range customer.Budgets { + if b := gs.LoadBudget(ctx, customer.Budgets[i].ID); b != nil { + customerBudgets = append(customerBudgets, b) + } + } + if len(customerBudgets) == 0 { return DecisionAllow, nil } - key := fmt.Sprintf("Customer:%s", customerID) - entityWiseBudgets := EntityWiseBudgets{key: {customerBudget}} + entityWiseBudgets := EntityWiseBudgets{key: customerBudgets} return gs.CheckBudget(ctx, entityWiseBudgets, baselines) } @@ -1181,7 +1331,7 @@ func (gs *LocalGovernanceStore) CheckUserBudget(ctx context.Context, userID stri return DecisionAllow, nil } -// CheckModelRateLimit checks model-level rate limits and returns evaluation result if violated +// CheckModelRateLimit checks global-scope model-level rate limits across all four tiers func (gs *LocalGovernanceStore) CheckModelRateLimit(ctx context.Context, request *EvaluationRequest, tokensBaselines map[string]int64, requestsBaselines map[string]int64) (Decision, error) { // This is to prevent nil pointer dereference if tokensBaselines == nil { @@ -1190,35 +1340,59 @@ func (gs *LocalGovernanceStore) CheckModelRateLimit(ctx context.Context, request if requestsBaselines == nil { requestsBaselines = map[string]int64{} } - // Extract model and provider from request - var model string - var provider *schemas.ModelProvider - if request != nil { - model = request.Model - if request.Provider != "" { - provider = &request.Provider + model, provider := extractModelAndProvider(request) + entityWiseRateLimits := make(EntityWiseRateLimits) + for _, mc := range gs.collectModelConfigsFor(ctx, configstoreTables.ModelConfigScopeGlobal, "", model, provider) { + if mc.RateLimitID == nil { + continue + } + if rateLimit := gs.LoadRateLimit(ctx, *mc.RateLimitID); rateLimit != nil { + entityWiseRateLimits[modelConfigEntityKey(mc)] = []*configstoreTables.TableRateLimit{rateLimit} } } - // Collect model configs to check: model+provider (if exists) AND model-only (if exists) - entityWiseRateLimits := make(EntityWiseRateLimits) - // Check model+provider config first (more specific) - if provider is provided - if provider != nil { - key := fmt.Sprintf("%s:%s", model, string(*provider)) - if value, exists := gs.modelConfigs.Load(key); exists && value != nil { - if mc, ok := value.(*configstoreTables.TableModelConfig); ok && mc != nil && mc.RateLimitID != nil { - rateLimit := gs.LoadRateLimit(ctx, *mc.RateLimitID) - if rateLimit != nil { - entityWiseRateLimits[fmt.Sprintf("Model:%s:Provider:%s", model, string(*provider))] = []*configstoreTables.TableRateLimit{rateLimit} - } - } + return gs.CheckRateLimit(ctx, entityWiseRateLimits, tokensBaselines, requestsBaselines) +} + +// CheckScopedModelBudget enforces budgets from model configs scoped to the given +// (scope, scopeID) — e.g. ("virtual_key", vk.ID). Checked in addition to the global +// model budgets; a request must satisfy both. Empty scope or scopeID is a no-op. +func (gs *LocalGovernanceStore) CheckScopedModelBudget(ctx context.Context, scope, scopeID string, request *EvaluationRequest, baselines map[string]float64) (Decision, error) { + if scope == "" || scopeID == "" { + return DecisionAllow, nil + } + if baselines == nil { + baselines = map[string]float64{} + } + model, provider := extractModelAndProvider(request) + entityWiseBudgets := EntityWiseBudgets{} + for _, mc := range gs.collectModelConfigsFor(ctx, scope, scopeID, model, provider) { + if budgets := gs.loadModelConfigBudgets(ctx, mc); len(budgets) > 0 { + entityWiseBudgets[modelConfigEntityKey(mc)] = budgets } } - // Always check model-only config (if exists) - regardless of whether model+provider config exists - // Uses findModelOnlyConfig for cross-provider model name normalization - if mc, configKey := gs.findModelOnlyConfig(ctx, model); mc != nil && mc.RateLimitID != nil { - rateLimit := gs.LoadRateLimit(ctx, *mc.RateLimitID) - if rateLimit != nil { - entityWiseRateLimits[fmt.Sprintf("Model:%s", configKey)] = []*configstoreTables.TableRateLimit{rateLimit} + return gs.CheckBudget(ctx, entityWiseBudgets, baselines) +} + +// CheckScopedModelRateLimit enforces rate limits from model configs scoped to the given +// (scope, scopeID), in addition to the global model rate limits. +func (gs *LocalGovernanceStore) CheckScopedModelRateLimit(ctx context.Context, scope, scopeID string, request *EvaluationRequest, tokensBaselines map[string]int64, requestsBaselines map[string]int64) (Decision, error) { + if scope == "" || scopeID == "" { + return DecisionAllow, nil + } + if tokensBaselines == nil { + tokensBaselines = map[string]int64{} + } + if requestsBaselines == nil { + requestsBaselines = map[string]int64{} + } + model, provider := extractModelAndProvider(request) + entityWiseRateLimits := make(EntityWiseRateLimits) + for _, mc := range gs.collectModelConfigsFor(ctx, scope, scopeID, model, provider) { + if mc.RateLimitID == nil { + continue + } + if rateLimit := gs.LoadRateLimit(ctx, *mc.RateLimitID); rateLimit != nil { + entityWiseRateLimits[modelConfigEntityKey(mc)] = []*configstoreTables.TableRateLimit{rateLimit} } } return gs.CheckRateLimit(ctx, entityWiseRateLimits, tokensBaselines, requestsBaselines) @@ -1278,24 +1452,18 @@ func (gs *LocalGovernanceStore) UpdateProviderAndModelBudgetUsageInMemory(ctx co } } - // 2. Update model-level budgets - // Check model+provider config first (more specific) - if provider is provided + // 2. Update global-scope model-level budgets across all four tiers (incl. the + // all-models "*:provider" tier that now carries provider-level governance). + var providerStr *string if provider != "" { - key := fmt.Sprintf("%s:%s", model, string(provider)) - if value, exists := gs.modelConfigs.Load(key); exists && value != nil { - if mc, ok := value.(*configstoreTables.TableModelConfig); ok && mc != nil && mc.BudgetID != nil { - if err := gs.BumpBudgetUsage(ctx, *mc.BudgetID, cost); err != nil { - return err - } - } - } + p := string(provider) + providerStr = &p } - - // Always check model-only config (if exists) - regardless of whether model+provider config exists - // Uses findModelOnlyConfig for cross-provider model name normalization - if mc, _ := gs.findModelOnlyConfig(ctx, model); mc != nil && mc.BudgetID != nil { - if err := gs.BumpBudgetUsage(ctx, *mc.BudgetID, cost); err != nil { - return err + for _, mc := range gs.collectModelConfigsFor(ctx, configstoreTables.ModelConfigScopeGlobal, "", model, providerStr) { + for i := range mc.Budgets { + if err := gs.BumpBudgetUsage(ctx, mc.Budgets[i].ID, cost); err != nil { + return err + } } } @@ -1322,27 +1490,66 @@ func (gs *LocalGovernanceStore) UpdateProviderAndModelRateLimitUsageInMemory(ctx } } - // 2. Update model-level rate limits - // Check model+provider config first (more specific) - if provider is provided + // 2. Update global-scope model-level rate limits across all four tiers (incl. the + // all-models "*:provider" tier that now carries provider-level governance). + var providerStr *string if provider != "" { - key := fmt.Sprintf("%s:%s", model, string(provider)) - if value, exists := gs.modelConfigs.Load(key); exists && value != nil { - if mc, ok := value.(*configstoreTables.TableModelConfig); ok && mc != nil && mc.RateLimitID != nil { - if err := gs.BumpRateLimitUsage(ctx, *mc.RateLimitID, tokensUsed, shouldUpdateTokens, shouldUpdateRequests); err != nil { - return err - } + p := string(provider) + providerStr = &p + } + for _, mc := range gs.collectModelConfigsFor(ctx, configstoreTables.ModelConfigScopeGlobal, "", model, providerStr) { + if mc.RateLimitID == nil { + continue + } + if err := gs.BumpRateLimitUsage(ctx, *mc.RateLimitID, tokensUsed, shouldUpdateTokens, shouldUpdateRequests); err != nil { + return err + } + } + + return nil +} + +// UpdateScopedModelBudgetUsageInMemory bumps budget usage for model configs scoped to the +// given (scope, scopeID). Post-response counterpart to CheckScopedModelBudget — without it, +// scoped budgets never increase and never trip. Empty scope/scopeID/model is a no-op. +func (gs *LocalGovernanceStore) UpdateScopedModelBudgetUsageInMemory(ctx context.Context, scope, scopeID, model string, provider schemas.ModelProvider, cost float64) error { + if scope == "" || scopeID == "" { + return nil + } + var providerStr *string + if provider != "" { + p := string(provider) + providerStr = &p + } + for _, mc := range gs.collectModelConfigsFor(ctx, scope, scopeID, model, providerStr) { + for i := range mc.Budgets { + if err := gs.BumpBudgetUsage(ctx, mc.Budgets[i].ID, cost); err != nil { + return err } } } + return nil +} - // Always check model-only config (if exists) - regardless of whether model+provider config exists - // Uses findModelOnlyConfig for cross-provider model name normalization - if mc, _ := gs.findModelOnlyConfig(ctx, model); mc != nil && mc.RateLimitID != nil { +// UpdateScopedModelRateLimitUsageInMemory bumps rate limit counters for model configs scoped +// to the given (scope, scopeID). Post-response counterpart to CheckScopedModelRateLimit. +func (gs *LocalGovernanceStore) UpdateScopedModelRateLimitUsageInMemory(ctx context.Context, scope, scopeID, model string, provider schemas.ModelProvider, tokensUsed int64, shouldUpdateTokens bool, shouldUpdateRequests bool) error { + if scope == "" || scopeID == "" { + return nil + } + var providerStr *string + if provider != "" { + p := string(provider) + providerStr = &p + } + for _, mc := range gs.collectModelConfigsFor(ctx, scope, scopeID, model, providerStr) { + if mc.RateLimitID == nil { + continue + } if err := gs.BumpRateLimitUsage(ctx, *mc.RateLimitID, tokensUsed, shouldUpdateTokens, shouldUpdateRequests); err != nil { return err } } - return nil } @@ -1800,11 +2007,20 @@ func (gs *LocalGovernanceStore) loadFromConfigMemory(ctx context.Context, config // Load routing rules routingRules := config.RoutingRules - // Populate model configs with their relationships (Budget and RateLimit) + // Populate model configs with their relationships (Budgets and RateLimit) for i := range modelConfigs { mc := &modelConfigs[i] - // Populate budget + // Populate multi-budgets owned via TableBudget.ModelConfigID (the active path). + if len(mc.Budgets) == 0 { + for j := range budgets { + if budgets[j].ModelConfigID != nil && *budgets[j].ModelConfigID == mc.ID { + mc.Budgets = append(mc.Budgets, budgets[j]) + } + } + } + + // Legacy single-budget linking (inert; kept for backward-compatible config.json). if mc.BudgetID != nil { for j := range budgets { if budgets[j].ID == *mc.BudgetID { @@ -1944,26 +2160,63 @@ func (gs *LocalGovernanceStore) rebuildInMemoryStructures(ctx context.Context, c gs.virtualKeys.Store(vk.Value, vk) } - // Build model configs map - // Key format: "modelName" for global configs, "modelName:provider" for provider-specific configs + // Build model configs map. + // Key format (global scope): "modelName" for all-provider configs, "modelName:provider" + // for provider-specific configs. Non-global scopes (e.g. virtual_key) prefix the key with + // "::" via modelConfigStoreKey so they never collide with global configs. // Model names are normalized using GetBaseModelName to prevent duplicate config leakage - // (e.g., "openai/gpt-4o" and "gpt-4o" both store under key "gpt-4o") + // (e.g., "openai/gpt-4o" and "gpt-4o" both store under base "gpt-4o"). for i := range modelConfigs { mc := &modelConfigs[i] + // Stamp calendar alignment onto owned budgets and rate limit so the reset path + // reads the right window (the flat budgets/rate-limits list lacks owner context). + // Mirrors how VK/team budgets are stamped from their owner. + for j := range mc.Budgets { + mc.Budgets[j].IsCalendarAligned = mc.CalendarAligned + gs.budgets.Store(mc.Budgets[j].ID, &mc.Budgets[j]) + } + if mc.RateLimit != nil { + mc.RateLimit.IsCalendarAligned = mc.CalendarAligned + gs.rateLimits.Store(mc.RateLimit.ID, mc.RateLimit) + } + scopeID := "" + if mc.ScopeID != nil { + scopeID = *mc.ScopeID + } if mc.Provider != nil { - // Store under provider-specific key - key := fmt.Sprintf("%s:%s", mc.ModelName, *mc.Provider) + // Provider-specific: store under (scope-prefixed) "modelName:provider" key + key := modelConfigStoreKey(mc.Scope, scopeID, mc.ModelName, mc.Provider) gs.modelConfigs.Store(key, mc) } else { - // Global config (applies to all providers) - store under normalized model name - key := mc.ModelName - if gs.modelCatalog != nil { - key = gs.modelCatalog.GetBaseModelName(mc.ModelName) + // All-provider config - store under normalized (scope-prefixed) model name. + // The "*" (all-models) sentinel is never normalized. + modelKey := mc.ModelName + if gs.modelCatalog != nil && mc.ModelName != modelConfigWildcard { + modelKey = gs.modelCatalog.GetBaseModelName(mc.ModelName) } + key := modelConfigStoreKey(mc.Scope, scopeID, modelKey, nil) gs.modelConfigs.Store(key, mc) } } + // Stamp customer-owned budget and rate-limit entries in the flat caches so + // calendar-aligned resets survive restarts and reloads. Mirrors the model-config + // stamping above. + for i := range customers { + customer := &customers[i] + for j := range customer.Budgets { + customer.Budgets[j].IsCalendarAligned = customer.CalendarAligned + gs.budgets.Store(customer.Budgets[j].ID, &customer.Budgets[j]) + } + if customer.RateLimitID != nil { + if raw, ok := gs.rateLimits.Load(*customer.RateLimitID); ok { + if rl, ok := raw.(*configstoreTables.TableRateLimit); ok && rl != nil { + rl.IsCalendarAligned = customer.CalendarAligned + } + } + } + } + // Build providers map // Key format: provider name (e.g., "openai", "anthropic") for i := range providers { @@ -2193,10 +2446,10 @@ func (gs *LocalGovernanceStore) collectBudgetsFromHierarchy(_ context.Context, v teamCustomerID = *team.CustomerID if customerValue, exists := gs.customers.Load(*team.CustomerID); exists && customerValue != nil { if customer, ok := customerValue.(*configstoreTables.TableCustomer); ok && customer != nil { - if customer.BudgetID != nil { - if budgetValue, exists := gs.budgets.Load(*customer.BudgetID); exists && budgetValue != nil { + for _, cb := range customer.Budgets { + if budgetValue, exists := gs.budgets.Load(cb.ID); exists && budgetValue != nil { if budget, ok := budgetValue.(*configstoreTables.TableBudget); ok && budget != nil { - if categoryBudgets := entityWiseBudgets["Customer"]; categoryBudgets == nil { + if entityWiseBudgets["Customer"] == nil { entityWiseBudgets["Customer"] = []*configstoreTables.TableBudget{} } entityWiseBudgets["Customer"] = append(entityWiseBudgets["Customer"], budget) @@ -2214,10 +2467,10 @@ func (gs *LocalGovernanceStore) collectBudgetsFromHierarchy(_ context.Context, v if vk.CustomerID != nil && (teamCustomerID == "" || *vk.CustomerID != teamCustomerID) { if customerValue, exists := gs.customers.Load(*vk.CustomerID); exists && customerValue != nil { if customer, ok := customerValue.(*configstoreTables.TableCustomer); ok && customer != nil { - if customer.BudgetID != nil { - if budgetValue, exists := gs.budgets.Load(*customer.BudgetID); exists && budgetValue != nil { + for _, cb := range customer.Budgets { + if budgetValue, exists := gs.budgets.Load(cb.ID); exists && budgetValue != nil { if budget, ok := budgetValue.(*configstoreTables.TableBudget); ok && budget != nil { - if categoryBudgets := entityWiseBudgets["Customer"]; categoryBudgets == nil { + if entityWiseBudgets["Customer"] == nil { entityWiseBudgets["Customer"] = []*configstoreTables.TableBudget{} } entityWiseBudgets["Customer"] = append(entityWiseBudgets["Customer"], budget) @@ -2280,40 +2533,44 @@ func (gs *LocalGovernanceStore) CollectApplicableGovernanceIDs(ctx context.Conte } } - // --- Model-level --- - if model != "" { - // model+provider specific config - if provider != "" { - key := fmt.Sprintf("%s:%s", model, string(provider)) - if value, exists := gs.modelConfigs.Load(key); exists && value != nil { - if mc, ok := value.(*configstoreTables.TableModelConfig); ok && mc != nil { - if mc.BudgetID != nil && !seenBudgets[*mc.BudgetID] { - budgetIDs = append(budgetIDs, *mc.BudgetID) - seenBudgets[*mc.BudgetID] = true - } - if mc.RateLimitID != nil && !seenRateLimits[*mc.RateLimitID] { - rateLimitIDs = append(rateLimitIDs, *mc.RateLimitID) - seenRateLimits[*mc.RateLimitID] = true - } - } + var providerStr *string + if provider != "" { + p := string(provider) + providerStr = &p + } + // addModelConfigIDs accumulates the (multi-)budget and rate-limit IDs owned by a + // model config, matching what the enforcement/recording paths count. + addModelConfigIDs := func(mc *configstoreTables.TableModelConfig) { + for i := range mc.Budgets { + if id := mc.Budgets[i].ID; !seenBudgets[id] { + budgetIDs = append(budgetIDs, id) + seenBudgets[id] = true } } - // model-only config - if mc, _ := gs.findModelOnlyConfig(ctx, model); mc != nil { - if mc.BudgetID != nil && !seenBudgets[*mc.BudgetID] { - budgetIDs = append(budgetIDs, *mc.BudgetID) - seenBudgets[*mc.BudgetID] = true - } - if mc.RateLimitID != nil && !seenRateLimits[*mc.RateLimitID] { - rateLimitIDs = append(rateLimitIDs, *mc.RateLimitID) - seenRateLimits[*mc.RateLimitID] = true - } + if mc.RateLimitID != nil && !seenRateLimits[*mc.RateLimitID] { + rateLimitIDs = append(rateLimitIDs, *mc.RateLimitID) + seenRateLimits[*mc.RateLimitID] = true + } + } + + // --- Model-level (global scope), all four tiers incl. provider/all-models wildcards --- + if model != "" { + for _, mc := range gs.collectModelConfigsFor(ctx, configstoreTables.ModelConfigScopeGlobal, "", model, providerStr) { + addModelConfigIDs(mc) } } - // --- VK hierarchy (provider-config → VK → team → customer) --- + // --- VK hierarchy (VK-scoped model configs + team/customer) --- if virtualKey != "" { if vk, exists := gs.GetVirtualKey(ctx, virtualKey); exists && vk != nil { + // VK-scoped model configs (provider-level + all-models wildcards). + if model != "" { + for _, scope := range nonGlobalModelConfigScopeChain(vk) { + for _, mc := range gs.collectModelConfigsFor(ctx, scope.name, scope.id, model, providerStr) { + addModelConfigIDs(mc) + } + } + } for _, id := range gs.collectBudgetIDsFromMemory(ctx, vk, provider) { if !seenBudgets[id] { budgetIDs = append(budgetIDs, id) @@ -2593,6 +2850,26 @@ func (gs *LocalGovernanceStore) DeleteVirtualKeyInMemory(ctx context.Context, vk } return true // continue iteration }) + + // Evict any model configs scoped to this virtual key (and their budgets/rate-limits). + // Mirrors the DB-side cleanup in DeleteVirtualKey and keeps the in-memory store + // consistent even when the VK entry was already removed. + gs.modelConfigs.Range(func(key, value any) bool { + mc, ok := value.(*configstoreTables.TableModelConfig) + if !ok || mc == nil { + return true + } + if mc.Scope == configstoreTables.ModelConfigScopeVirtualKey && mc.ScopeID != nil && *mc.ScopeID == vkID { + for i := range mc.Budgets { + gs.DeleteBudget(ctx, mc.Budgets[i].ID) + } + if mc.RateLimitID != nil { + gs.DeleteRateLimit(ctx, *mc.RateLimitID) + } + gs.modelConfigs.Delete(key) + } + return true + }) } // CreateTeamInMemory adds a new team to the in-memory store (lock-free) @@ -2733,12 +3010,12 @@ func (gs *LocalGovernanceStore) CreateCustomerInMemory(ctx context.Context, cust if customer == nil { return // Nothing to create } - // Create associated budget if exists - if customer.Budget != nil { - gs.budgets.Store(customer.Budget.ID, customer.Budget) + for i := range customer.Budgets { + customer.Budgets[i].IsCalendarAligned = customer.CalendarAligned + gs.budgets.Store(customer.Budgets[i].ID, &customer.Budgets[i]) } - // Create associated rate limit if exists if customer.RateLimit != nil { + customer.RateLimit.IsCalendarAligned = customer.CalendarAligned gs.rateLimits.Store(customer.RateLimit.ID, customer.RateLimit) } gs.customers.Store(customer.ID, customer) @@ -2758,28 +3035,29 @@ func (gs *LocalGovernanceStore) UpdateCustomerInMemory(ctx context.Context, cust // Create clone to avoid modifying the original clone := *customer - // Handle budget updates with consistent logic - if clone.Budget != nil { - // Preserve existing usage from memory when updating customer budget config - if existingBudgetValue, exists := gs.budgets.Load(clone.Budget.ID); exists && existingBudgetValue != nil { + // Reconcile budgets: upsert new set, delete any that were removed. + newBudgetIDs := make(map[string]bool, len(clone.Budgets)) + for i := range clone.Budgets { + b := &clone.Budgets[i] + b.IsCalendarAligned = clone.CalendarAligned + if existingBudgetValue, exists := gs.budgets.Load(b.ID); exists && existingBudgetValue != nil { if existingBudget, ok := existingBudgetValue.(*configstoreTables.TableBudget); ok && existingBudget != nil { - // Preserve current usage and last reset time from existing in-memory budget - clone.Budget.CurrentUsage = existingBudget.CurrentUsage - clone.Budget.LastReset = existingBudget.LastReset + b.CurrentUsage = existingBudget.CurrentUsage + b.LastReset = existingBudget.LastReset } } - gs.budgets.Store(clone.Budget.ID, clone.Budget) - // Clean up old budget if ID changed (e.g., UUID rotation on propagation) - if existingCustomer.Budget != nil && existingCustomer.Budget.ID != clone.Budget.ID { - gs.DeleteBudget(ctx, existingCustomer.Budget.ID) + gs.budgets.Store(b.ID, b) + newBudgetIDs[b.ID] = true + } + for _, existing := range existingCustomer.Budgets { + if !newBudgetIDs[existing.ID] { + gs.DeleteBudget(ctx, existing.ID) } - } else if existingCustomer.Budget != nil { - // Budget was removed from the customer, delete it from memory - gs.DeleteBudget(ctx, existingCustomer.Budget.ID) } // Handle rate limit updates with consistent logic if clone.RateLimit != nil { + clone.RateLimit.IsCalendarAligned = clone.CalendarAligned // Preserve existing usage from memory when updating customer rate limit config if existingRateLimitValue, exists := gs.rateLimits.Load(clone.RateLimit.ID); exists && existingRateLimitValue != nil { if existingRateLimit, ok := existingRateLimitValue.(*configstoreTables.TableRateLimit); ok && existingRateLimit != nil { @@ -2814,9 +3092,8 @@ func (gs *LocalGovernanceStore) DeleteCustomerInMemory(ctx context.Context, cust // Get customer to check for associated budget and rate limit if customerValue, exists := gs.customers.Load(customerID); exists && customerValue != nil { if customer, ok := customerValue.(*configstoreTables.TableCustomer); ok && customer != nil { - // Delete associated budget if exists - if customer.BudgetID != nil { - gs.DeleteBudget(ctx, *customer.BudgetID) + for _, b := range customer.Budgets { + gs.DeleteBudget(ctx, b.ID) } // Delete associated rate limit if exists if customer.RateLimitID != nil { @@ -2892,18 +3169,24 @@ func (gs *LocalGovernanceStore) UpdateModelConfigInMemory(ctx context.Context, m // Clone to avoid modifying the original clone := *mc - // Store associated budget if exists, preserving existing in-memory usage - if clone.Budget != nil { - if existingBudgetValue, exists := gs.budgets.Load(clone.Budget.ID); exists && existingBudgetValue != nil { + // Store associated budgets, preserving existing in-memory usage per budget ID and + // stamping calendar alignment from the model config (consumed by the reset path). + for i := range clone.Budgets { + b := &clone.Budgets[i] + b.IsCalendarAligned = clone.CalendarAligned + if existingBudgetValue, exists := gs.budgets.Load(b.ID); exists && existingBudgetValue != nil { if eb, ok := existingBudgetValue.(*configstoreTables.TableBudget); ok && eb != nil { - clone.Budget.CurrentUsage = eb.CurrentUsage + b.CurrentUsage = eb.CurrentUsage + b.LastReset = eb.LastReset } } - gs.budgets.Store(clone.Budget.ID, clone.Budget) + gs.budgets.Store(b.ID, b) } - // Store associated rate limit if exists, preserving existing in-memory usage + // Store associated rate limit if exists, preserving existing in-memory usage and + // stamping calendar alignment from the owning model config. if clone.RateLimit != nil { + clone.RateLimit.IsCalendarAligned = clone.CalendarAligned if existingRateLimitValue, exists := gs.rateLimits.Load(clone.RateLimit.ID); exists && existingRateLimitValue != nil { if erl, ok := existingRateLimitValue.(*configstoreTables.TableRateLimit); ok && erl != nil { clone.RateLimit.TokenCurrentUsage = erl.TokenCurrentUsage @@ -2913,16 +3196,22 @@ func (gs *LocalGovernanceStore) UpdateModelConfigInMemory(ctx context.Context, m gs.rateLimits.Store(clone.RateLimit.ID, clone.RateLimit) } - // Determine the key based on whether provider is specified - // Key format: "modelName" for global configs, "modelName:provider" for provider-specific configs + // Determine the (scope-aware) key. Global scope keeps the historical key format; + // non-global scopes are namespaced by modelConfigStoreKey. Scope/scope_id are part of + // a config's identity and do not change on update, so this matches the stored key. + scopeID := "" + if clone.ScopeID != nil { + scopeID = *clone.ScopeID + } if clone.Provider != nil { - key := fmt.Sprintf("%s:%s", clone.ModelName, *clone.Provider) + key := modelConfigStoreKey(clone.Scope, scopeID, clone.ModelName, clone.Provider) gs.modelConfigs.Store(key, &clone) } else { - key := clone.ModelName - if gs.modelCatalog != nil { - key = gs.modelCatalog.GetBaseModelName(clone.ModelName) + modelKey := clone.ModelName + if gs.modelCatalog != nil && clone.ModelName != modelConfigWildcard { + modelKey = gs.modelCatalog.GetBaseModelName(clone.ModelName) } + key := modelConfigStoreKey(clone.Scope, scopeID, modelKey, nil) gs.modelConfigs.Store(key, &clone) } @@ -2943,9 +3232,9 @@ func (gs *LocalGovernanceStore) DeleteModelConfigInMemory(ctx context.Context, m } if mc.ID == mcID { - // Delete associated budget if exists - if mc.BudgetID != nil { - gs.DeleteBudget(ctx, *mc.BudgetID) + // Delete associated budgets if any + for i := range mc.Budgets { + gs.DeleteBudget(ctx, mc.Budgets[i].ID) } // Delete associated rate limit if exists @@ -2960,6 +3249,28 @@ func (gs *LocalGovernanceStore) DeleteModelConfigInMemory(ctx context.Context, m }) } +// ScopedModelConfigIDs returns the IDs of all in-memory model configs for the +// given (scope, scopeID). Callers use this to diff against the DB result and +// evict stale entries via DeleteModelConfigInMemory. +func (gs *LocalGovernanceStore) ScopedModelConfigIDs(scope, scopeID string) []string { + var ids []string + gs.modelConfigs.Range(func(key, value interface{}) bool { + mc, ok := value.(*configstoreTables.TableModelConfig) + if !ok || mc == nil { + return true + } + mcScopeID := "" + if mc.ScopeID != nil { + mcScopeID = *mc.ScopeID + } + if mc.Scope == scope && mcScopeID == scopeID { + ids = append(ids, mc.ID) + } + return true + }) + return ids +} + // UpdateProviderInMemory adds or updates a provider in the in-memory store (lock-free) // Preserves existing usage values when updating budgets and rate limits // Returns the updated provider with potentially modified usage values @@ -3074,16 +3385,21 @@ func (gs *LocalGovernanceStore) updateBudgetReferences(ctx context.Context, rese } return true // continue }) - // Update customers that reference this budget + // Update customers that own this budget gs.customers.Range(func(key, value interface{}) bool { customer, ok := value.(*configstoreTables.TableCustomer) if !ok || customer == nil { return true // continue } - if customer.BudgetID != nil && *customer.BudgetID == budgetID { - clone := *customer - clone.Budget = resetBudget - gs.customers.Store(key, &clone) + for i, b := range customer.Budgets { + if b.ID == budgetID { + clone := *customer + clone.Budgets = make([]configstoreTables.TableBudget, len(customer.Budgets)) + copy(clone.Budgets, customer.Budgets) + clone.Budgets[i] = *resetBudget + gs.customers.Store(key, &clone) + break + } } return true // continue }) @@ -3286,65 +3602,17 @@ func (gs *LocalGovernanceStore) GetBudgetAndRateLimitStatus(ctx context.Context, RateLimitRequestPercentUsed: 0, } - // Check model-specific rate limits and budgets (takes precedence) + // Check model-level rate limits and budgets across all tiers (exact model+provider, + // model-only, and wildcard "*:provider" / "*:nil" from provider-governance migration). + // collectModelConfigsFor returns all four tiers so provider-level wildcard configs + // are included and status stays in sync with enforcement. if model != "" { - // Check model+provider config first (most specific) - key := fmt.Sprintf("%s:%s", model, string(provider)) - if modelValue, ok := gs.modelConfigs.Load(key); ok && modelValue != nil { - if modelConfig, ok := modelValue.(*configstoreTables.TableModelConfig); ok && modelConfig != nil { - // Get rate limit status - if modelConfig.RateLimitID != nil { - if rateLimitValue, ok := gs.rateLimits.Load(*modelConfig.RateLimitID); ok && rateLimitValue != nil { - if rateLimit, ok := rateLimitValue.(*configstoreTables.TableRateLimit); ok && rateLimit != nil { - tokensBaseline, exists := tokenBaselines[rateLimit.ID] - if !exists { - tokensBaseline = 0 - } - requestsBaseline, exists := requestBaselines[rateLimit.ID] - if !exists { - requestsBaseline = 0 - } - // Calculate token percent used - if rateLimit.TokenMaxLimit != nil && *rateLimit.TokenMaxLimit > 0 { - tokenPercent := float64(rateLimit.TokenCurrentUsage+tokensBaseline) / float64(*rateLimit.TokenMaxLimit) * 100 - if tokenPercent > result.RateLimitTokenPercentUsed { - result.RateLimitTokenPercentUsed = tokenPercent - } - } - // Calculate request percent used - if rateLimit.RequestMaxLimit != nil && *rateLimit.RequestMaxLimit > 0 { - requestPercent := float64(rateLimit.RequestCurrentUsage+requestsBaseline) / float64(*rateLimit.RequestMaxLimit) * 100 - if requestPercent > result.RateLimitRequestPercentUsed { - result.RateLimitRequestPercentUsed = requestPercent - } - } - } - } - } - // Get budget status - if modelConfig.BudgetID != nil { - if budgetValue, ok := gs.budgets.Load(*modelConfig.BudgetID); ok && budgetValue != nil { - if budget, ok := budgetValue.(*configstoreTables.TableBudget); ok && budget != nil { - baseline, exists := budgetBaselines[budget.ID] - if !exists { - baseline = 0 - } - if budget.MaxLimit > 0 { - budgetPercent := float64(budget.CurrentUsage+baseline) / budget.MaxLimit * 100 - if budgetPercent > result.BudgetPercentUsed { - result.BudgetPercentUsed = budgetPercent - } - } - } - } - } - } + var providerStr *string + if provider != "" { + p := string(provider) + providerStr = &p } - - // Fall back to model-only config (if exists) - // Uses findModelOnlyConfig for cross-provider model name normalization - if modelConfig, _ := gs.findModelOnlyConfig(ctx, model); modelConfig != nil { - // Get rate limit status + for _, modelConfig := range gs.collectModelConfigsFor(ctx, configstoreTables.ModelConfigScopeGlobal, "", model, providerStr) { if modelConfig.RateLimitID != nil { if rateLimitValue, ok := gs.rateLimits.Load(*modelConfig.RateLimitID); ok && rateLimitValue != nil { if rateLimit, ok := rateLimitValue.(*configstoreTables.TableRateLimit); ok && rateLimit != nil { @@ -3373,14 +3641,11 @@ func (gs *LocalGovernanceStore) GetBudgetAndRateLimitStatus(ctx context.Context, } } } - // Get budget status - if modelConfig.BudgetID != nil { - if budgetValue, ok := gs.budgets.Load(*modelConfig.BudgetID); ok && budgetValue != nil { + // Get budget status (max percent across the config's budgets) + for bi := range modelConfig.Budgets { + if budgetValue, ok := gs.budgets.Load(modelConfig.Budgets[bi].ID); ok && budgetValue != nil { if budget, ok := budgetValue.(*configstoreTables.TableBudget); ok && budget != nil { - baseline, exists := budgetBaselines[budget.ID] - if !exists { - baseline = 0 - } + baseline := budgetBaselines[budget.ID] if budget.MaxLimit > 0 { budgetPercent := float64(budget.CurrentUsage+baseline) / budget.MaxLimit * 100 if budgetPercent > result.BudgetPercentUsed { diff --git a/plugins/governance/store_test.go b/plugins/governance/store_test.go index 59738755d4b..fe4dea067a9 100644 --- a/plugins/governance/store_test.go +++ b/plugins/governance/store_test.go @@ -1190,6 +1190,122 @@ func TestCompileAndCacheProgram_EmptyExpression(t *testing.T) { assert.Equal(t, program, program2) } +// TestGovernanceStore_Customer_CalendarAligned_CreateInMemory verifies that +// CreateCustomerInMemory stamps IsCalendarAligned on the in-memory budget and +// rate limit so ResetExpiredBudgetsInMemory uses the calendar-aligned reset path. +func TestGovernanceStore_Customer_CalendarAligned_CreateInMemory(t *testing.T) { + logger := NewMockLogger() + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{}, nil) + require.NoError(t, err) + + budgetID := "cust-bud-1" + rlID := "cust-rl-1" + budget := &configstoreTables.TableBudget{ + ID: budgetID, + MaxLimit: 100.0, + ResetDuration: "1M", + LastReset: time.Now(), + } + rl := &configstoreTables.TableRateLimit{ + ID: rlID, + TokenMaxLimit: ptrInt64(1000), + TokenLastReset: time.Now(), + RequestLastReset: time.Now(), + } + customer := buildCustomer("cust-1", "ACME", budget) + customer.CalendarAligned = true + customer.RateLimit = rl + customer.RateLimitID = &rlID + + store.CreateCustomerInMemory(context.Background(), customer) + + rawBudget, ok := store.budgets.Load(budgetID) + require.True(t, ok, "budget should be in memory after create") + storedBudget, ok := rawBudget.(*configstoreTables.TableBudget) + require.True(t, ok) + assert.True(t, storedBudget.IsCalendarAligned, "budget.IsCalendarAligned should be true when customer.CalendarAligned=true") + + rawRL, ok := store.rateLimits.Load(rlID) + require.True(t, ok, "rate limit should be in memory after create") + storedRL, ok := rawRL.(*configstoreTables.TableRateLimit) + require.True(t, ok) + assert.True(t, storedRL.IsCalendarAligned, "rate_limit.IsCalendarAligned should be true when customer.CalendarAligned=true") +} + +// TestGovernanceStore_Customer_CalendarAligned_CreateInMemory_False verifies that +// IsCalendarAligned is false when the customer does not have calendar alignment enabled. +func TestGovernanceStore_Customer_CalendarAligned_CreateInMemory_False(t *testing.T) { + logger := NewMockLogger() + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{}, nil) + require.NoError(t, err) + + budgetID := "cust-bud-2" + budget := &configstoreTables.TableBudget{ + ID: budgetID, + MaxLimit: 50.0, + ResetDuration: "1d", + LastReset: time.Now(), + } + customer := buildCustomer("cust-2", "Globex", budget) + customer.CalendarAligned = false + + store.CreateCustomerInMemory(context.Background(), customer) + + rawBudget, ok := store.budgets.Load(budgetID) + require.True(t, ok) + storedBudget, ok := rawBudget.(*configstoreTables.TableBudget) + require.True(t, ok) + assert.False(t, storedBudget.IsCalendarAligned, "budget.IsCalendarAligned should be false when customer.CalendarAligned=false") +} + +// TestGovernanceStore_Customer_CalendarAligned_UpdateInMemory verifies that +// UpdateCustomerInMemory re-stamps IsCalendarAligned on the budget and rate limit +// so an in-flight toggle (false→true) takes effect immediately in memory. +func TestGovernanceStore_Customer_CalendarAligned_UpdateInMemory(t *testing.T) { + logger := NewMockLogger() + + budgetID := "cust-bud-3" + rlID := "cust-rl-3" + budget := &configstoreTables.TableBudget{ + ID: budgetID, + MaxLimit: 200.0, + ResetDuration: "1M", + LastReset: time.Now(), + } + rl := &configstoreTables.TableRateLimit{ + ID: rlID, + TokenMaxLimit: ptrInt64(500), + TokenLastReset: time.Now(), + RequestLastReset: time.Now(), + } + customer := buildCustomer("cust-3", "Initech", budget) + customer.CalendarAligned = false + customer.RateLimit = rl + customer.RateLimitID = &rlID + + store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{ + Customers: []configstoreTables.TableCustomer{*customer}, + Budgets: []configstoreTables.TableBudget{*budget}, + }, nil) + require.NoError(t, err) + + // Budget and rate limit should start as non-calendar-aligned + rawBudget, _ := store.budgets.Load(budgetID) + assert.False(t, rawBudget.(*configstoreTables.TableBudget).IsCalendarAligned) + + // Toggle calendar_aligned to true and update in memory + customer.CalendarAligned = true + store.UpdateCustomerInMemory(context.Background(), customer, nil) + + rawBudget, ok := store.budgets.Load(budgetID) + require.True(t, ok) + assert.True(t, rawBudget.(*configstoreTables.TableBudget).IsCalendarAligned, "budget.IsCalendarAligned should be true after update with CalendarAligned=true") + + rawRL, ok := store.rateLimits.Load(rlID) + require.True(t, ok) + assert.True(t, rawRL.(*configstoreTables.TableRateLimit).IsCalendarAligned, "rate_limit.IsCalendarAligned should be true after update with CalendarAligned=true") +} + // Utility functions for tests func ptrInt64(i int64) *int64 { return &i diff --git a/plugins/governance/test_utils.go b/plugins/governance/test_utils.go index e19b4197d5d..6055a64298e 100644 --- a/plugins/governance/test_utils.go +++ b/plugins/governance/test_utils.go @@ -184,8 +184,8 @@ func buildCustomer(id, name string, budget *configstoreTables.TableBudget) *conf Name: name, } if budget != nil { - customer.Budget = budget - customer.BudgetID = &budget.ID + budget.CustomerID = &customer.ID + customer.Budgets = []configstoreTables.TableBudget{*budget} } return customer } @@ -253,8 +253,9 @@ func buildModelConfig(id, modelName string, provider *string, budget *configstor UpdatedAt: time.Now(), } if budget != nil { - mc.Budget = budget - mc.BudgetID = &budget.ID + // Model configs now own budgets via TableBudget.ModelConfigID (multi-budget). + budget.ModelConfigID = &mc.ID + mc.Budgets = []configstoreTables.TableBudget{*budget} } if rateLimit != nil { mc.RateLimit = rateLimit @@ -263,6 +264,14 @@ func buildModelConfig(id, modelName string, provider *string, budget *configstor return mc } +// buildVKScopedModelConfig builds a model config scoped to a specific virtual key. +func buildVKScopedModelConfig(id, modelName string, provider *string, vkID string, budget *configstoreTables.TableBudget, rateLimit *configstoreTables.TableRateLimit) *configstoreTables.TableModelConfig { + mc := buildModelConfig(id, modelName, provider, budget, rateLimit) + mc.Scope = configstoreTables.ModelConfigScopeVirtualKey + mc.ScopeID = &vkID + return mc +} + func buildProviderWithGovernance(name string, budget *configstoreTables.TableBudget, rateLimit *configstoreTables.TableRateLimit) *configstoreTables.TableProvider { provider := &configstoreTables.TableProvider{ Name: name, diff --git a/plugins/governance/tracker.go b/plugins/governance/tracker.go index ecc1b04513b..b5c3f70eecf 100644 --- a/plugins/governance/tracker.go +++ b/plugins/governance/tracker.go @@ -81,8 +81,10 @@ func (t *UsageTracker) UpdateUsage(ctx context.Context, update *UsageUpdate) { // 1. Update rate limit usage for both provider-level and model-level // This applies even when virtual keys are disabled or not present - // Guard: only update when both Provider and Model are set (MCP paths may not have these) - if update.Provider != "" && update.Model != "" { + // Guard: only update when Model is set (MCP paths may not have it); provider is optional — + // the underlying function handles empty provider by skipping provider-level and still + // updating any matching global model-only configs. + if update.Model != "" { if err := t.store.UpdateProviderAndModelRateLimitUsageInMemory(ctx, update.Model, update.Provider, update.TokensUsed, shouldUpdateTokens, shouldUpdateRequests); err != nil { t.logger.Error("failed to update rate limit usage for model %s, provider %s: %v", update.Model, update.Provider, err) } @@ -90,8 +92,10 @@ func (t *UsageTracker) UpdateUsage(ctx context.Context, update *UsageUpdate) { // 2. Update budget usage for both provider-level and model-level // This applies even when virtual keys are disabled or not present - // Guard: only update when both Provider and Model are set (MCP paths may not have these) - if update.Provider != "" && update.Model != "" && shouldUpdateBudget && update.Cost > 0 { + // Guard: only update when Model is set (MCP paths may not have it); provider is optional — + // the underlying function handles empty provider by skipping provider-level and still + // updating any matching global model-only configs. + if update.Model != "" && shouldUpdateBudget && update.Cost > 0 { if err := t.store.UpdateProviderAndModelBudgetUsageInMemory(ctx, update.Model, update.Provider, update.Cost); err != nil { t.logger.Error("failed to update budget usage for model %s, provider %s: %v", update.Model, update.Provider, err) } @@ -109,6 +113,19 @@ func (t *UsageTracker) UpdateUsage(ctx context.Context, update *UsageUpdate) { t.logger.Error("failed to update user budget usage for user %s: %v", update.UserID, err) } } + // Update per-user-scoped model config rate limits and budgets. Mirrors the + // VK-scoped model block below. Gated on model being present — MCP tool + // execution paths (no model) are excluded naturally by this guard. + if update.Model != "" { + if err := t.store.UpdateScopedModelRateLimitUsageInMemory(ctx, configstoreTables.ModelConfigScopeUser, update.UserID, update.Model, update.Provider, update.TokensUsed, shouldUpdateTokens, shouldUpdateRequests); err != nil { + t.logger.Error("failed to update scoped model rate limit usage for user %s: %v", update.UserID, err) + } + if shouldUpdateBudget && update.Cost > 0 { + if err := t.store.UpdateScopedModelBudgetUsageInMemory(ctx, configstoreTables.ModelConfigScopeUser, update.UserID, update.Model, update.Provider, update.Cost); err != nil { + t.logger.Error("failed to update scoped model budget usage for user %s: %v", update.UserID, err) + } + } + } } // 4. Now handle virtual key-level updates (if virtual key exists) @@ -124,6 +141,19 @@ func (t *UsageTracker) UpdateUsage(ctx context.Context, update *UsageUpdate) { return } + // Update per-VK-scoped model config usage (counterpart to the global model updates above). + // Without this, per-VK model limits never increment and so never trip. + if update.Model != "" { + if err := t.store.UpdateScopedModelRateLimitUsageInMemory(ctx, configstoreTables.ModelConfigScopeVirtualKey, vk.ID, update.Model, update.Provider, update.TokensUsed, shouldUpdateTokens, shouldUpdateRequests); err != nil { + t.logger.Error("failed to update scoped model rate limit usage for VK %s: %v", vk.ID, err) + } + if shouldUpdateBudget && update.Cost > 0 { + if err := t.store.UpdateScopedModelBudgetUsageInMemory(ctx, configstoreTables.ModelConfigScopeVirtualKey, vk.ID, update.Model, update.Provider, update.Cost); err != nil { + t.logger.Error("failed to update scoped model budget usage for VK %s: %v", vk.ID, err) + } + } + } + // Update rate limit usage (VK-level, provider-config-level, team-level, customer-level) if applicable // Include TeamID and CustomerID checks since rate limits can be configured at those levels if vk.RateLimit != nil || len(vk.ProviderConfigs) > 0 || vk.TeamID != nil || vk.CustomerID != nil { diff --git a/plugins/jsonparser/changelog.md b/plugins/jsonparser/changelog.md index c5c2bac21f5..998a5bd07ab 100644 --- a/plugins/jsonparser/changelog.md +++ b/plugins/jsonparser/changelog.md @@ -1 +1 @@ -- chore: upgraded core to v1.5.16 and framework to v1.3.16 +- chore: upgraded core to v1.5.16 and framework to v1.3.16 \ No newline at end of file diff --git a/plugins/jsonparser/go.mod b/plugins/jsonparser/go.mod index 14c0bee109d..28e46a6968b 100644 --- a/plugins/jsonparser/go.mod +++ b/plugins/jsonparser/go.mod @@ -11,7 +11,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect - github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.11 // indirect @@ -34,14 +34,14 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/bytedance/gopkg v0.1.3 // indirect - github.com/bytedance/sonic v1.15.0 // indirect - github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/bytedance/sonic v1.15.1 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect - github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mailru/easyjson v0.9.1 // indirect @@ -57,7 +57,7 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.68.0 // indirect + github.com/valyala/fasthttp v1.71.0 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.starlark.net v0.0.0-20260102030733-3fee463870c9 // indirect @@ -67,5 +67,6 @@ require ( golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/plugins/jsonparser/go.sum b/plugins/jsonparser/go.sum index f65b9207bcf..7b57277962c 100644 --- a/plugins/jsonparser/go.sum +++ b/plugins/jsonparser/go.sum @@ -14,8 +14,8 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= @@ -60,10 +60,10 @@ github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJ github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= -github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= -github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= -github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= -github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= +github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= 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/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= @@ -78,8 +78,8 @@ github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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= @@ -90,8 +90,8 @@ github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcI github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -151,8 +151,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok= -github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= @@ -177,8 +177,8 @@ golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/plugins/logging/changelog.md b/plugins/logging/changelog.md index c5c2bac21f5..998a5bd07ab 100644 --- a/plugins/logging/changelog.md +++ b/plugins/logging/changelog.md @@ -1 +1 @@ -- chore: upgraded core to v1.5.16 and framework to v1.3.16 +- chore: upgraded core to v1.5.16 and framework to v1.3.16 \ No newline at end of file diff --git a/plugins/logging/go.mod b/plugins/logging/go.mod index 2a9113b1015..72b583d9c42 100644 --- a/plugins/logging/go.mod +++ b/plugins/logging/go.mod @@ -3,7 +3,7 @@ module github.com/maximhq/bifrost/plugins/logging go 1.26.3 require ( - github.com/bytedance/sonic v1.15.0 + github.com/bytedance/sonic v1.15.1 github.com/maximhq/bifrost/core v1.5.15 github.com/maximhq/bifrost/framework v1.3.15 ) @@ -11,7 +11,7 @@ require ( require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect @@ -24,7 +24,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect - github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect @@ -48,14 +48,14 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/bytedance/gopkg v0.1.3 // indirect - github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect - github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -81,12 +81,12 @@ require ( github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-openapi/validate v0.25.1 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // 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.14 // indirect - github.com/googleapis/gax-go/v2 v2.19.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -94,7 +94,7 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mailru/easyjson v0.9.1 // indirect @@ -121,16 +121,16 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.68.0 // indirect + github.com/valyala/fasthttp v1.71.0 // indirect github.com/weaviate/weaviate v1.36.5 // indirect github.com/weaviate/weaviate-go-client/v5 v5.7.1 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.mongodb.org/mongo-driver v1.17.6 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.40.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk v1.43.0 // indirect @@ -146,12 +146,12 @@ require ( golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/api v0.274.0 // indirect - google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 // indirect + google.golang.org/api v0.282.0 // indirect + google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/grpc v1.80.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/driver/postgres v1.6.0 // indirect gorm.io/driver/sqlite v1.6.0 // indirect diff --git a/plugins/logging/go.sum b/plugins/logging/go.sum index 5c47b428a62..7db77aa7dcd 100644 --- a/plugins/logging/go.sum +++ b/plugins/logging/go.sum @@ -2,8 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= 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/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= @@ -41,8 +41,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= @@ -94,16 +94,16 @@ github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJ github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= -github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= -github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= -github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= -github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= +github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= 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/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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= @@ -113,12 +113,12 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= -github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= -github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= -github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE= github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -180,11 +180,11 @@ github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6 github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-openapi/validate v0.25.1 h1:sSACUI6Jcnbo5IWqbYHgjibrhhmt3vR6lCzKZnmAgBw= github.com/go-openapi/validate v0.25.1/go.mod h1:RMVyVFYte0gbSTaZ0N4KmTn6u/kClvAFp+mAVfS/DQc= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -195,10 +195,10 @@ 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.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= -github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= -github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= +github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= @@ -218,8 +218,8 @@ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/ github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -303,8 +303,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok= -github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= github.com/weaviate/weaviate v1.36.5 h1:lCiuEfQ08+5wK0DkTCUBb6ayNep9QpBH6JJhmZaRfzk= github.com/weaviate/weaviate v1.36.5/go.mod h1:ljzrgEmGKn3CRzDdcxvhmBUUZIcghwIYd1Lmn54f3Z8= github.com/weaviate/weaviate-go-client/v5 v5.7.1 h1:vEMxh486QqRqWaq58UEe/TiTbGbo9T5x7ZPFd5QENvQ= @@ -319,12 +319,12 @@ go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUps go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= 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/contrib/detectors/gcp v1.40.0 h1:Awaf8gmW99tZTOWqkLCOl6aw1/rxAWVlHsHIZ3fT2sA= -go.opentelemetry.io/contrib/detectors/gcp v1.40.0/go.mod h1:99OY9ZCqyLkzJLTh5XhECpLRSxcZl+ZDKBEO+jMBFR4= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= @@ -363,18 +363,18 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA= -google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= -google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE= -google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw= +google.golang.org/api v0.282.0 h1:WmJiSVqUnKqJCpJOx7YADbXaC+9DDsnGSfllFSj7R2I= +google.golang.org/api v0.282.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/plugins/logging/main.go b/plugins/logging/main.go index 7e9688c63c1..6b4e93b4a63 100644 --- a/plugins/logging/main.go +++ b/plugins/logging/main.go @@ -463,15 +463,17 @@ func (p *LoggerPlugin) captureLoggingHeaders(ctx *schemas.BifrostContext) map[st var metadata map[string]any - // Check configured logging headers + // Check configured logging headers (supports wildcard patterns like "x-custom-*") if p.loggingHeaders != nil { for _, h := range *p.loggingHeaders { - key := strings.ToLower(h) - if val, ok := allHeaders[key]; ok { - if metadata == nil { - metadata = make(map[string]any) + pattern := strings.ToLower(strings.TrimSpace(h)) + for hKey, hVal := range allHeaders { + if schemas.MatchHeaderPattern(hKey, pattern) { + if metadata == nil { + metadata = make(map[string]any) + } + metadata[hKey] = hVal } - metadata[key] = val } } } @@ -675,6 +677,7 @@ func (p *LoggerPlugin) PreLLMHook(ctx *schemas.BifrostContext, req *schemas.Bifr Method: req.PassthroughRequest.Method, Path: req.PassthroughRequest.Path, RawQuery: req.PassthroughRequest.RawQuery, + Model: req.PassthroughRequest.Model, } if len(req.PassthroughRequest.Body) > 0 { ct := strings.ToLower(req.PassthroughRequest.SafeHeaders["content-type"]) @@ -1046,6 +1049,13 @@ func (p *LoggerPlugin) PostLLMHook(ctx *schemas.BifrostContext, result *schemas. if isPassthroughErrorResponse(result) { entry.Status = "error" } + // Compute cost for streaming passthrough using StreamUsage set by the accumulator. + if entry.Cost == nil && p.pricingManager != nil && result.PassthroughResponse.PassthroughUsage != nil { + pricingScopes := modelcatalog.PricingLookupScopesFromContext(ctx, string(entry.Provider)) + if cost := p.pricingManager.CalculateCost(result, pricingScopes); cost > 0 { + entry.Cost = &cost + } + } } applyLargePayloadPreviewsToEntry(ctx, entry, contentLoggingEnabled) if tracer != nil && traceID != "" { diff --git a/plugins/logging/operations.go b/plugins/logging/operations.go index ccbcc6b3f42..4421ad72a7d 100644 --- a/plugins/logging/operations.go +++ b/plugins/logging/operations.go @@ -478,6 +478,10 @@ func (p *LoggerPlugin) applyNonStreamingOutputToEntry(entry *logstore.Log, resul } else { usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens } + case result.PassthroughResponse != nil: + if su := result.PassthroughResponse.PassthroughUsage; su != nil { + usage = su.LLMUsage + } } if usage != nil { entry.TokenUsageParsed = usage diff --git a/plugins/logging/writer.go b/plugins/logging/writer.go index fae450c0e4f..4270324f2d6 100644 --- a/plugins/logging/writer.go +++ b/plugins/logging/writer.go @@ -279,6 +279,12 @@ func (p *LoggerPlugin) enqueueLogEntry(entry *logstore.Log, callback func(entry } } +// EnqueueLogEntry pushes a complete log entry through the logging plugin's +// normal async write queue. +func (p *LoggerPlugin) EnqueueLogEntry(entry *logstore.Log) { + p.enqueueLogEntry(entry, p.makePostWriteCallback(nil)) +} + // enqueueMCPToolLogEntry pushes a complete MCP tool log entry to the write queue. // If the queue is full, the entry is dropped to prevent store slowness from // cascading into request handling goroutines. diff --git a/plugins/maxim/changelog.md b/plugins/maxim/changelog.md index c5c2bac21f5..998a5bd07ab 100644 --- a/plugins/maxim/changelog.md +++ b/plugins/maxim/changelog.md @@ -1 +1 @@ -- chore: upgraded core to v1.5.16 and framework to v1.3.16 +- chore: upgraded core to v1.5.16 and framework to v1.3.16 \ No newline at end of file diff --git a/plugins/maxim/go.mod b/plugins/maxim/go.mod index 6e4dee73460..754019827ee 100644 --- a/plugins/maxim/go.mod +++ b/plugins/maxim/go.mod @@ -9,14 +9,14 @@ require ( ) require ( - github.com/bytedance/sonic v1.15.0 + github.com/bytedance/sonic v1.15.1 github.com/google/uuid v1.6.0 ) require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect @@ -29,7 +29,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect - github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect @@ -53,14 +53,14 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/bytedance/gopkg v0.1.3 // indirect - github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect - github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -86,11 +86,11 @@ require ( github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-openapi/validate v0.25.1 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect - github.com/googleapis/gax-go/v2 v2.19.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -98,7 +98,7 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mailru/easyjson v0.9.1 // indirect @@ -125,16 +125,16 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.68.0 // indirect + github.com/valyala/fasthttp v1.71.0 // indirect github.com/weaviate/weaviate v1.36.5 // indirect github.com/weaviate/weaviate-go-client/v5 v5.7.1 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.mongodb.org/mongo-driver v1.17.6 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.40.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk v1.43.0 // indirect @@ -150,12 +150,12 @@ require ( golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/api v0.274.0 // indirect - google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 // indirect + google.golang.org/api v0.282.0 // indirect + google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/grpc v1.80.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/driver/postgres v1.6.0 // indirect gorm.io/driver/sqlite v1.6.0 // indirect diff --git a/plugins/maxim/go.sum b/plugins/maxim/go.sum index de45f009aef..075107f77f4 100644 --- a/plugins/maxim/go.sum +++ b/plugins/maxim/go.sum @@ -2,8 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= 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/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= @@ -41,8 +41,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= @@ -94,16 +94,16 @@ github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJ github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= -github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= -github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= -github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= -github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= +github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= 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/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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= @@ -113,12 +113,12 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= -github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= -github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= -github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE= github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -180,11 +180,11 @@ github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6 github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-openapi/validate v0.25.1 h1:sSACUI6Jcnbo5IWqbYHgjibrhhmt3vR6lCzKZnmAgBw= github.com/go-openapi/validate v0.25.1/go.mod h1:RMVyVFYte0gbSTaZ0N4KmTn6u/kClvAFp+mAVfS/DQc= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -195,10 +195,10 @@ 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.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= -github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= -github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= +github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= @@ -218,8 +218,8 @@ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/ github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -305,8 +305,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok= -github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= github.com/weaviate/weaviate v1.36.5 h1:lCiuEfQ08+5wK0DkTCUBb6ayNep9QpBH6JJhmZaRfzk= github.com/weaviate/weaviate v1.36.5/go.mod h1:ljzrgEmGKn3CRzDdcxvhmBUUZIcghwIYd1Lmn54f3Z8= github.com/weaviate/weaviate-go-client/v5 v5.7.1 h1:vEMxh486QqRqWaq58UEe/TiTbGbo9T5x7ZPFd5QENvQ= @@ -321,12 +321,12 @@ go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUps go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= 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/contrib/detectors/gcp v1.40.0 h1:Awaf8gmW99tZTOWqkLCOl6aw1/rxAWVlHsHIZ3fT2sA= -go.opentelemetry.io/contrib/detectors/gcp v1.40.0/go.mod h1:99OY9ZCqyLkzJLTh5XhECpLRSxcZl+ZDKBEO+jMBFR4= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= @@ -365,18 +365,18 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA= -google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= -google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE= -google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw= +google.golang.org/api v0.282.0 h1:WmJiSVqUnKqJCpJOx7YADbXaC+9DDsnGSfllFSj7R2I= +google.golang.org/api v0.282.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/plugins/maxim/main.go b/plugins/maxim/main.go index eb9ca449277..ace1329e6a6 100644 --- a/plugins/maxim/main.go +++ b/plugins/maxim/main.go @@ -29,8 +29,9 @@ const ( // - APIKey: API key for Maxim SDK authentication // - LogRepoID: Optional default ID for the Maxim logger instance type Config struct { - LogRepoID string `json:"log_repo_id,omitempty"` // Optional - can be empty - APIKey string `json:"api_key"` + LogRepoID string `json:"log_repo_id,omitempty"` // Optional - can be empty + APIKey string `json:"api_key"` + RequestHeaders []string `json:"request_headers,omitempty"` // Optional request-header name patterns (exact or wildcard) to attach as trace tags } // Plugin implements the schemas.LLMPlugin interface for Maxim's logger. @@ -48,6 +49,7 @@ type Plugin struct { loggers map[string]*logging.Logger loggerMutex *sync.RWMutex logger schemas.Logger + requestHeaders []string } // Init initializes and returns a Plugin instance for Maxim's logger. @@ -75,6 +77,7 @@ func Init(config *Config, logger schemas.Logger) (schemas.LLMPlugin, error) { loggers: make(map[string]*logging.Logger), loggerMutex: &sync.RWMutex{}, logger: logger, + requestHeaders: config.RequestHeaders, } // Initialize default logger if LogRepoId is provided @@ -554,6 +557,13 @@ func (plugin *Plugin) PostLLMHook(ctx *schemas.BifrostContext, result *schemas.B tags, hasTags := ctx.Value(TagsKey).(map[string]string) // Also capture x-bf-dim-* dimensions to forward as tags dims, hasDims := ctx.Value(schemas.BifrostContextKeyDimensions).(map[string]string) + // Capture configured request headers (exact or wildcard patterns) to forward as tags. + var reqHeaders map[string]string + if len(plugin.requestHeaders) > 0 { + allHeaders, _ := ctx.Value(schemas.BifrostContextKeyRequestHeaders).(map[string]string) + reqHeaders = schemas.FilterHeaders(allHeaders, plugin.requestHeaders) + } + hasReqHeaders := len(reqHeaders) > 0 isFinalChunk := bifrost.IsFinalChunk(ctx) @@ -687,6 +697,18 @@ func (plugin *Plugin) PostLLMHook(ctx *schemas.BifrostContext, result *schemas.B if hasTraceID && traceID != "" && modelTag != "" { logger.AddTagToTrace(traceID, "model", string(modelTag)) } + // add configured request headers as tags (prefixed to avoid colliding with other tags) + if hasReqHeaders { + for key, value := range reqHeaders { + tagKey := "header." + key + if generationID != "" { + logger.AddTagToGeneration(generationID, tagKey, value) + } + if traceID != "" { + logger.AddTagToTrace(traceID, tagKey, value) + } + } + } // Flush only the effective logger that was used for this request logger.Flush() }() diff --git a/plugins/mocker/changelog.md b/plugins/mocker/changelog.md index c5c2bac21f5..998a5bd07ab 100644 --- a/plugins/mocker/changelog.md +++ b/plugins/mocker/changelog.md @@ -1 +1 @@ -- chore: upgraded core to v1.5.16 and framework to v1.3.16 +- chore: upgraded core to v1.5.16 and framework to v1.3.16 \ No newline at end of file diff --git a/plugins/mocker/go.mod b/plugins/mocker/go.mod index 986825dbde0..c0d9659570c 100644 --- a/plugins/mocker/go.mod +++ b/plugins/mocker/go.mod @@ -14,7 +14,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect - github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.11 // indirect @@ -37,14 +37,14 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/bytedance/gopkg v0.1.3 // indirect - github.com/bytedance/sonic v1.15.0 // indirect - github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/bytedance/sonic v1.15.1 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect - github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mailru/easyjson v0.9.1 // indirect @@ -60,7 +60,7 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.68.0 // indirect + github.com/valyala/fasthttp v1.71.0 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.starlark.net v0.0.0-20260102030733-3fee463870c9 // indirect @@ -70,5 +70,6 @@ require ( golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/plugins/mocker/go.sum b/plugins/mocker/go.sum index 2b7fb36f9e0..b643411be55 100644 --- a/plugins/mocker/go.sum +++ b/plugins/mocker/go.sum @@ -14,8 +14,8 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= @@ -60,10 +60,10 @@ github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJ github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= -github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= -github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= -github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= -github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= +github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= 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/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= @@ -78,8 +78,8 @@ github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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= @@ -92,8 +92,8 @@ github.com/jaswdr/faker/v2 v2.8.0 h1:3AxdXW9U7dJmWckh/P0YgRbNlCcVsTyrUNUnLVP9b3Q github.com/jaswdr/faker/v2 v2.8.0/go.mod h1:jZq+qzNQr8/P+5fHd9t3txe2GNPnthrTfohtnJ7B+68= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -153,8 +153,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok= -github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= @@ -179,8 +179,8 @@ golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/plugins/otel/changelog.md b/plugins/otel/changelog.md index c5c2bac21f5..998a5bd07ab 100644 --- a/plugins/otel/changelog.md +++ b/plugins/otel/changelog.md @@ -1 +1 @@ -- chore: upgraded core to v1.5.16 and framework to v1.3.16 +- chore: upgraded core to v1.5.16 and framework to v1.3.16 \ No newline at end of file diff --git a/plugins/otel/converter.go b/plugins/otel/converter.go index 604dff5a105..894310be0bc 100644 --- a/plugins/otel/converter.go +++ b/plugins/otel/converter.go @@ -127,15 +127,18 @@ func (p *OtelPlugin) buildReparentMap(spans []*schemas.Span) map[string]string { return filtered } -// convertTraceToResourceSpan converts a Bifrost trace to OTEL ResourceSpan -func (p *OtelPlugin) convertTraceToResourceSpan(trace *schemas.Trace) *ResourceSpan { +// convertTraceToResourceSpan converts a Bifrost trace to OTEL ResourceSpan for the given +// profile service name. Span filtering and instance attributes are shared across profiles; +// only the resource service name differs per profile. +func (p *OtelPlugin) convertTraceToResourceSpan(serviceName string, trace *schemas.Trace, requestHeaders []string) *ResourceSpan { reparent := p.buildReparentMap(trace.Spans) + filteredHeaders := schemas.FilterHeaders(trace.RequestHeaders, requestHeaders) otelSpans := make([]*Span, 0, len(trace.Spans)) for _, span := range trace.Spans { if !p.shouldExportSpan(span) { continue } - otelSpan := p.convertSpanToOTELSpan(trace.TraceID, span) + otelSpan := convertSpanToOTELSpan(trace.TraceID, span) // If the span's direct parent was filtered, rewrite its parent ID to the // nearest exported ancestor so the hierarchy stays connected. if effectiveParent, ok := reparent[span.ParentID]; ok { @@ -155,22 +158,25 @@ func (p *OtelPlugin) convertTraceToResourceSpan(trace *schemas.Trace) *ResourceS if len(p.instanceAttrs) > 0 { otelSpan.Attributes = append(otelSpan.Attributes, p.instanceAttrs...) } + for k, v := range filteredHeaders { + otelSpan.Attributes = append(otelSpan.Attributes, kvStr("http.request.header."+k, v)) + } } otelSpans = append(otelSpans, otelSpan) } return &ResourceSpan{ Resource: &resourcepb.Resource{ - Attributes: p.getResourceAttributes(), + Attributes: p.getResourceAttributes(serviceName), }, ScopeSpans: []*ScopeSpan{{ - Scope: p.getInstrumentationScope(), + Scope: p.getInstrumentationScope(serviceName), Spans: otelSpans, }}, } } // convertSpanToOTELSpan converts a single Bifrost span to OTEL format -func (p *OtelPlugin) convertSpanToOTELSpan(traceID string, span *schemas.Span) *Span { +func convertSpanToOTELSpan(traceID string, span *schemas.Span) *Span { otelSpan := &Span{ TraceId: hexToBytes(traceID, 16), SpanId: hexToBytes(span.SpanID, 8), @@ -192,9 +198,9 @@ func (p *OtelPlugin) convertSpanToOTELSpan(traceID string, span *schemas.Span) * } // getResourceAttributes returns the resource attributes for the OTEL span -func (p *OtelPlugin) getResourceAttributes() []*KeyValue { +func (p *OtelPlugin) getResourceAttributes(serviceName string) []*KeyValue { attrs := []*KeyValue{ - kvStr("service.name", p.serviceName), + kvStr("service.name", serviceName), kvStr("service.version", p.bifrostVersion), kvStr("telemetry.sdk.name", "bifrost"), kvStr("telemetry.sdk.language", "go"), @@ -205,9 +211,9 @@ func (p *OtelPlugin) getResourceAttributes() []*KeyValue { } // getInstrumentationScope returns the instrumentation scope for OTEL -func (p *OtelPlugin) getInstrumentationScope() *commonpb.InstrumentationScope { +func (p *OtelPlugin) getInstrumentationScope(serviceName string) *commonpb.InstrumentationScope { return &commonpb.InstrumentationScope{ - Name: p.serviceName, + Name: serviceName, Version: p.bifrostVersion, } } diff --git a/plugins/otel/go.mod b/plugins/otel/go.mod index f744a6d9abd..8e96effed43 100644 --- a/plugins/otel/go.mod +++ b/plugins/otel/go.mod @@ -12,14 +12,14 @@ require ( go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/sdk/metric v1.43.0 go.opentelemetry.io/proto/otlp v1.10.0 - google.golang.org/grpc v1.80.0 - google.golang.org/protobuf v1.36.11 + google.golang.org/grpc v1.81.1 + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af ) require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect @@ -32,7 +32,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect - github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect @@ -56,14 +56,14 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/bytedance/gopkg v0.1.3 // indirect - github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect - github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -89,12 +89,12 @@ require ( github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-openapi/validate v0.25.1 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // 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.14 // indirect - github.com/googleapis/gax-go/v2 v2.19.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -103,7 +103,7 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mailru/easyjson v0.9.1 // indirect github.com/mark3labs/mcp-go v0.43.2 // indirect @@ -128,16 +128,16 @@ require ( github.com/tidwall/pretty v1.2.0 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.68.0 // indirect + github.com/valyala/fasthttp v1.71.0 // indirect github.com/weaviate/weaviate v1.36.5 // indirect github.com/weaviate/weaviate-go-client/v5 v5.7.1 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.mongodb.org/mongo-driver v1.17.6 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.40.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.starlark.net v0.0.0-20260102030733-3fee463870c9 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect @@ -147,10 +147,10 @@ require ( golang.org/x/sync v0.20.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/api v0.274.0 // indirect - google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 // indirect + google.golang.org/api v0.282.0 // indirect + google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/driver/postgres v1.6.0 // indirect gorm.io/driver/sqlite v1.6.0 // indirect @@ -158,7 +158,7 @@ require ( ) require ( - github.com/bytedance/sonic v1.15.0 + github.com/bytedance/sonic v1.15.1 github.com/cloudwego/base64x v0.1.6 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect diff --git a/plugins/otel/go.sum b/plugins/otel/go.sum index 6de72cb3c8f..cde3e7c3b07 100644 --- a/plugins/otel/go.sum +++ b/plugins/otel/go.sum @@ -2,8 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= 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/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= @@ -41,8 +41,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= @@ -94,18 +94,18 @@ github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJ github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= -github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= -github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= -github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= -github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= +github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= 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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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= @@ -115,12 +115,12 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= -github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= -github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= -github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE= github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -182,11 +182,11 @@ github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6 github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-openapi/validate v0.25.1 h1:sSACUI6Jcnbo5IWqbYHgjibrhhmt3vR6lCzKZnmAgBw= github.com/go-openapi/validate v0.25.1/go.mod h1:RMVyVFYte0gbSTaZ0N4KmTn6u/kClvAFp+mAVfS/DQc= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -197,10 +197,10 @@ 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.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= -github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= -github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= +github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= @@ -222,8 +222,8 @@ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/ github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -307,8 +307,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok= -github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= github.com/weaviate/weaviate v1.36.5 h1:lCiuEfQ08+5wK0DkTCUBb6ayNep9QpBH6JJhmZaRfzk= github.com/weaviate/weaviate v1.36.5/go.mod h1:ljzrgEmGKn3CRzDdcxvhmBUUZIcghwIYd1Lmn54f3Z8= github.com/weaviate/weaviate-go-client/v5 v5.7.1 h1:vEMxh486QqRqWaq58UEe/TiTbGbo9T5x7ZPFd5QENvQ= @@ -323,12 +323,12 @@ go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUps go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= 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/contrib/detectors/gcp v1.40.0 h1:Awaf8gmW99tZTOWqkLCOl6aw1/rxAWVlHsHIZ3fT2sA= -go.opentelemetry.io/contrib/detectors/gcp v1.40.0/go.mod h1:99OY9ZCqyLkzJLTh5XhECpLRSxcZl+ZDKBEO+jMBFR4= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc= @@ -373,18 +373,18 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA= -google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= -google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE= -google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw= +google.golang.org/api v0.282.0 h1:WmJiSVqUnKqJCpJOx7YADbXaC+9DDsnGSfllFSj7R2I= +google.golang.org/api v0.282.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/plugins/otel/grpc.go b/plugins/otel/grpc.go index 6cdd5f9a08a..a55c447ccf4 100644 --- a/plugins/otel/grpc.go +++ b/plugins/otel/grpc.go @@ -2,10 +2,6 @@ package otel import ( "context" - "crypto/tls" - "crypto/x509" - "fmt" - "os" collectorpb "go.opentelemetry.io/proto/otlp/collector/trace/v1" "google.golang.org/grpc" @@ -24,33 +20,15 @@ type OtelClientGRPC struct { // NewOtelClientGRPC creates a new OpenTelemetry client for gRPC func NewOtelClientGRPC(endpoint string, headers map[string]string, tlsCACert string, insecureMode bool) (*OtelClientGRPC, error) { var creds credentials.TransportCredentials - // TLS priority: custom CA > system roots > insecure - if tlsCACert != "" { - // Validate the CA cert path to prevent path traversal attacks - if err := validateCACertPath(tlsCACert); err != nil { - return nil, err - } - // Use custom CA certificate with MinVersion - caCert, err := os.ReadFile(tlsCACert) - if err != nil { - return nil, fmt.Errorf("fail to load provided CA cert: %w", err) - } - caCertPool := x509.NewCertPool() - if !caCertPool.AppendCertsFromPEM(caCert) { - return nil, fmt.Errorf("fail to parse provided CA cert") - } - tlsConfig := &tls.Config{ - RootCAs: caCertPool, - MinVersion: tls.VersionTLS12, - } - creds = credentials.NewTLS(tlsConfig) - } else if insecureMode { - // Skip TLS entirely + + // gRPC insecure mode uses plaintext (no TLS at all), not just skip-verify. + // buildTLSConfig is bypassed here to preserve that behaviour. + if tlsCACert == "" && insecureMode { creds = insecure.NewCredentials() } else { - // Use system root CAs with MinVersion - tlsConfig := &tls.Config{ - MinVersion: tls.VersionTLS12, + tlsConfig, err := buildTLSConfig(tlsCACert, false) + if err != nil { + return nil, err } creds = credentials.NewTLS(tlsConfig) } diff --git a/plugins/otel/http.go b/plugins/otel/http.go index 08ba89284d3..9cab25fb23a 100644 --- a/plugins/otel/http.go +++ b/plugins/otel/http.go @@ -3,12 +3,9 @@ package otel import ( "bytes" "context" - "crypto/tls" - "crypto/x509" "fmt" "io" "net/http" - "os" "strings" "time" @@ -30,35 +27,11 @@ func NewOtelClientHTTP(endpoint string, headers map[string]string, tlsCACert str transport.MaxIdleConnsPerHost = 10 transport.IdleConnTimeout = 120 * time.Second - // TLS priority: custom CA > system roots > insecure - if tlsCACert != "" { - // Validate the CA cert path to prevent path traversal attacks - if err := validateCACertPath(tlsCACert); err != nil { - return nil, err - } - caCert, err := os.ReadFile(tlsCACert) - if err != nil { - return nil, fmt.Errorf("fail to load provided CA cert: %w", err) - } - caCertPool := x509.NewCertPool() - if !caCertPool.AppendCertsFromPEM(caCert) { - return nil, fmt.Errorf("fail to add provided CA cert") - } - transport.TLSClientConfig = &tls.Config{ - RootCAs: caCertPool, - MinVersion: tls.VersionTLS12, - } - } else if insecureMode { - transport.TLSClientConfig = &tls.Config{ - InsecureSkipVerify: true, // #nosec G402 - MinVersion: tls.VersionTLS12, - } - } else { - // Use system root CAs with MinVersion - transport.TLSClientConfig = &tls.Config{ - MinVersion: tls.VersionTLS12, - } + tlsConfig, err := buildTLSConfig(tlsCACert, insecureMode) + if err != nil { + return nil, err } + transport.TLSClientConfig = tlsConfig return &OtelClientHTTP{client: &http.Client{ Timeout: 30 * time.Second, diff --git a/plugins/otel/main.go b/plugins/otel/main.go index 005277e7dc3..f4a277df499 100644 --- a/plugins/otel/main.go +++ b/plugins/otel/main.go @@ -4,8 +4,11 @@ package otel import ( "context" "fmt" + "maps" "os" + "slices" "strings" + "sync" "github.com/bytedance/sonic" bifrost "github.com/maximhq/bifrost/core" @@ -30,20 +33,13 @@ type TraceType string // TraceTypeGenAIExtension is the type of trace to use for the OTEL collector const TraceTypeGenAIExtension TraceType = "genai_extension" -// TraceTypeVercel is the type of trace to use for the OTEL collector -const TraceTypeVercel TraceType = "vercel" - -// TraceTypeOpenInference is the type of trace to use for the OTEL collector -const TraceTypeOpenInference TraceType = "open_inference" - // Protocol is the protocol to use for the OTEL collector type Protocol string -// ProtocolHTTP is the default protocol -const ProtocolHTTP Protocol = "http" - -// ProtocolGRPC is the second protocol -const ProtocolGRPC Protocol = "grpc" +const ( + ProtocolHTTP Protocol = "http" // default + ProtocolGRPC Protocol = "grpc" +) // PluginSpanFilterMode controls whether the plugins list is an allowlist or denylist. type PluginSpanFilterMode string @@ -60,82 +56,233 @@ type PluginSpanFilter struct { Plugins []string `json:"plugins"` } -type Config struct { - ServiceName string `json:"service_name"` - CollectorURL *schemas.EnvVar `json:"collector_url"` - Headers map[string]*schemas.EnvVar `json:"headers"` - TraceType TraceType `json:"trace_type"` - Protocol Protocol `json:"protocol"` - TLSCACert string `json:"tls_ca_cert"` - Insecure bool `json:"insecure"` // Skip TLS when true; ignored if TLSCACert is set. Defaults to true when omitted. +// Profile is a single OTEL export target: a collector endpoint and an optional +// metrics-push destination. A Config holds one or more profiles; each profile gets +// its own trace client and (when enabled) metrics exporter at runtime. +// +// Headers are plain strings using the "env.VAR_NAME" convention; they are resolved +// against the environment at Init time via injectEnvToHeaders. +type Profile struct { + // Enabled gates whether this profile exports anything. The plugin itself is always on; + // a disabled profile builds no trace client or metrics exporter, so no traces/metrics + // are sent for it. Defaults to true when omitted. + Enabled bool `json:"enabled"` + ServiceName string `json:"service_name"` + CollectorURL *schemas.EnvVar `json:"collector_url"` + Headers map[string]string `json:"headers,omitempty"` + TraceType TraceType `json:"trace_type"` + Protocol Protocol `json:"protocol"` + TLSCACert string `json:"tls_ca_cert,omitempty"` + Insecure bool `json:"insecure"` // Skip TLS when true; ignored if TLSCACert is set. Defaults to true when omitted. // Metrics push configuration MetricsEnabled bool `json:"metrics_enabled"` - MetricsEndpoint *schemas.EnvVar `json:"metrics_endpoint"` - MetricsPushInterval int `json:"metrics_push_interval"` // in seconds, default 15 + MetricsEndpoint *schemas.EnvVar `json:"metrics_endpoint,omitempty"` + MetricsPushInterval int `json:"metrics_push_interval,omitempty"` // in seconds, default 15 + + // RequestHeaders lists request-header name patterns (exact or wildcard like "x-custom-*" + // or "*") whose captured values are attached to the root span as attributes. + RequestHeaders []string `json:"request_headers,omitempty"` +} - // PluginSpanFilter is the DB-stored fallback when otel_plugin_span_filter is absent in config.json. - // The top-level config.json field takes precedence and is passed via Init's pluginSpanFilter param. +// UnmarshalJSON applies field defaults that the zero-value wouldn't capture. +// Specifically, Insecure defaults to true when the key is omitted so http:// +// collectors work out-of-the-box without forcing users to set it explicitly. +func (p *Profile) UnmarshalJSON(data []byte) error { + type alias Profile + aux := struct { + Enabled *bool `json:"enabled"` + Insecure *bool `json:"insecure"` + *alias + }{ + alias: (*alias)(p), + } + if err := sonic.Unmarshal(data, &aux); err != nil { + return err + } + if aux.Insecure == nil { + p.Insecure = true + } else { + p.Insecure = *aux.Insecure + } + if aux.Enabled == nil { + p.Enabled = true + } else { + p.Enabled = *aux.Enabled + } + return nil +} + +// Config is the OTEL plugin configuration: a set of export profiles plus a single +// shared span filter. It accepts two JSON shapes (see UnmarshalJSON): +// - the canonical wrapper {"profiles": [ ... ], "plugin_span_filter": { ... }} +// - a legacy single profile object, which is normalized into a one-element Profiles slice. +type Config struct { + Profiles []*Profile `json:"profiles"` + + // PluginSpanFilter is a single policy applied across every profile. In a legacy + // single-object config it is read from the object; in a profiles wrapper it is read + // from the top-level field (or hoisted from the first profile that carries one). PluginSpanFilter *PluginSpanFilter `json:"plugin_span_filter,omitempty"` } +// UnmarshalJSON normalizes both supported config shapes into Profiles. A wrapper object +// (one with a "profiles" key) is read directly; any other object is treated as a single +// legacy profile, with its plugin_span_filter hoisted to the shared Config level. +func (c *Config) UnmarshalJSON(data []byte) error { + // Canonical wrapper shape. + if node, err := sonic.Get(data, "profiles"); err == nil && node.Exists() { + type wrapper Config + var w wrapper + if err := sonic.Unmarshal(data, &w); err != nil { + return err + } + *c = Config(w) + // Allow plugin_span_filter to live on the first profile too; hoist it if the + // top-level field was omitted. + if c.PluginSpanFilter == nil { + c.PluginSpanFilter = hoistSpanFilter(data) + } + return nil + } + + // Legacy single-object shape: the whole object is one profile. + var prof Profile + if err := sonic.Unmarshal(data, &prof); err != nil { + return err + } + c.Profiles = []*Profile{&prof} + c.PluginSpanFilter = spanFilterFrom(data) + return nil +} + +// spanFilterCarrier captures only the plugin_span_filter field from a config or profile object. +type spanFilterCarrier struct { + PluginSpanFilter *PluginSpanFilter `json:"plugin_span_filter,omitempty"` +} + +// spanFilterFrom extracts a top-level plugin_span_filter from a JSON object, or nil. +func spanFilterFrom(data []byte) *PluginSpanFilter { + var c spanFilterCarrier + if err := sonic.Unmarshal(data, &c); err != nil { + return nil + } + return c.PluginSpanFilter +} + +// hoistSpanFilter returns the first plugin_span_filter found among the profiles of a +// wrapper-shaped config, used as a fallback when the top-level field is absent. +func hoistSpanFilter(data []byte) *PluginSpanFilter { + var w struct { + Profiles []spanFilterCarrier `json:"profiles"` + } + if err := sonic.Unmarshal(data, &w); err != nil { + return nil + } + for _, p := range w.Profiles { + if p.PluginSpanFilter != nil { + return p.PluginSpanFilter + } + } + return nil +} + +// profileForStorage is the persisted form of a single profile: *EnvVar fields are +// flattened to plain strings ("env.VAR_NAME" or the literal value) for DB/config-file +// persistence. +type profileForStorage struct { + Enabled bool `json:"enabled"` + ServiceName string `json:"service_name"` + CollectorURL string `json:"collector_url"` + Headers map[string]string `json:"headers,omitempty"` + TraceType TraceType `json:"trace_type"` + Protocol Protocol `json:"protocol"` + TLSCACert string `json:"tls_ca_cert,omitempty"` + Insecure bool `json:"insecure"` + MetricsEnabled bool `json:"metrics_enabled"` + MetricsEndpoint string `json:"metrics_endpoint,omitempty"` + MetricsPushInterval int `json:"metrics_push_interval,omitempty"` + RequestHeaders []string `json:"request_headers,omitempty"` +} + +// configForStorage is the persisted wrapper shape. +type configForStorage struct { + Profiles []profileForStorage `json:"profiles"` + PluginSpanFilter *PluginSpanFilter `json:"plugin_span_filter,omitempty"` +} + // MarshalForStorage serializes Config to JSON with *EnvVar fields as plain strings -// ("env.VAR_NAME" or the literal value) for database/config-file persistence. +// ("env.VAR_NAME" or the literal value) for database/config-file persistence. Output is +// always the canonical {"profiles": [...]} wrapper regardless of the input shape. // For HTTP API responses use json.Marshal directly so clients receive full EnvVar objects. func (c *Config) MarshalForStorage() ([]byte, error) { - type alias struct { - ServiceName string `json:"service_name"` - CollectorURL string `json:"collector_url"` - Headers map[string]string `json:"headers,omitempty"` - TraceType TraceType `json:"trace_type"` - Protocol Protocol `json:"protocol"` - TLSCACert string `json:"tls_ca_cert,omitempty"` - Insecure bool `json:"insecure"` - MetricsEnabled bool `json:"metrics_enabled"` - MetricsEndpoint string `json:"metrics_endpoint,omitempty"` - MetricsPushInterval int `json:"metrics_push_interval,omitempty"` - PluginSpanFilter *PluginSpanFilter `json:"plugin_span_filter,omitempty"` - } - a := alias{ - ServiceName: c.ServiceName, - CollectorURL: schemas.EnvVarAsString(c.CollectorURL), - TraceType: c.TraceType, - Protocol: c.Protocol, - TLSCACert: c.TLSCACert, - Insecure: c.Insecure, - MetricsEnabled: c.MetricsEnabled, - MetricsEndpoint: schemas.EnvVarAsString(c.MetricsEndpoint), - MetricsPushInterval: c.MetricsPushInterval, - PluginSpanFilter: c.PluginSpanFilter, - } - if c.Headers != nil { - a.Headers = make(map[string]string, len(c.Headers)) - for k, v := range c.Headers { - a.Headers[k] = schemas.EnvVarAsString(v) - } + out := configForStorage{ + Profiles: make([]profileForStorage, 0, len(c.Profiles)), + PluginSpanFilter: c.PluginSpanFilter, } - return sonic.Marshal(a) + for _, p := range c.Profiles { + if p == nil { + continue + } + out.Profiles = append(out.Profiles, profileForStorage{ + Enabled: p.Enabled, + ServiceName: p.ServiceName, + CollectorURL: schemas.EnvVarAsString(p.CollectorURL), + Headers: p.Headers, + TraceType: p.TraceType, + Protocol: p.Protocol, + TLSCACert: p.TLSCACert, + Insecure: p.Insecure, + MetricsEnabled: p.MetricsEnabled, + MetricsEndpoint: schemas.EnvVarAsString(p.MetricsEndpoint), + MetricsPushInterval: p.MetricsPushInterval, + RequestHeaders: p.RequestHeaders, + }) + } + return sonic.Marshal(out) } -// Redacted returns a copy of the config with sensitive EnvVar fields redacted for API responses. +// Redacted returns a copy of the config with sensitive fields redacted for API responses. // URLs (CollectorURL, MetricsEndpoint) are not secrets and are returned unchanged so the UI // can display and re-submit them without failing URL validation. For env var references on // those fields, only the resolved value is hidden; the env_var name is preserved. -// Header values may carry auth tokens and are masked. +// Header values may carry auth tokens, so literal values are masked while "env." references +// are preserved. func (c *Config) Redacted() *Config { if c == nil { return nil } - redacted := *c - redacted.CollectorURL = hideResolvedEnvValue(c.CollectorURL) - redacted.MetricsEndpoint = hideResolvedEnvValue(c.MetricsEndpoint) - if c.Headers != nil { - redacted.Headers = make(map[string]*schemas.EnvVar, len(c.Headers)) - for k, v := range c.Headers { - redacted.Headers[k] = v.Redacted() + redacted := &Config{PluginSpanFilter: c.PluginSpanFilter} + if c.Profiles != nil { + redacted.Profiles = make([]*Profile, 0, len(c.Profiles)) + for _, p := range c.Profiles { + if p == nil { + redacted.Profiles = append(redacted.Profiles, nil) + continue + } + rp := *p + rp.CollectorURL = hideResolvedEnvValue(p.CollectorURL) + rp.MetricsEndpoint = hideResolvedEnvValue(p.MetricsEndpoint) + if p.Headers != nil { + rp.Headers = make(map[string]string, len(p.Headers)) + for k, v := range p.Headers { + rp.Headers[k] = redactHeaderValue(v) + } + } + redacted.Profiles = append(redacted.Profiles, &rp) } } - return &redacted + return redacted +} + +// redactHeaderValue masks a plain-string header value for API responses. "env." references +// are returned unchanged (they are not secrets), while literal values are masked using the +// same scheme as EnvVar.Redacted so the API surface stays consistent. +func redactHeaderValue(v string) string { + if strings.HasPrefix(v, "env.") { + return v + } + return schemas.EnvVarAsString(schemas.NewEnvVar(v).Redacted()) } // hideResolvedEnvValue returns v unchanged for literal values (URLs are not secrets). @@ -149,53 +296,36 @@ func hideResolvedEnvValue(v *schemas.EnvVar) *schemas.EnvVar { return v.Redacted() } -// UnmarshalJSON applies field defaults that the zero-value wouldn't capture. -// Specifically, Insecure defaults to true when the key is omitted so http:// -// collectors work out-of-the-box without forcing users to set it explicitly. -func (c *Config) UnmarshalJSON(data []byte) error { - type alias Config - aux := struct { - Insecure *bool `json:"insecure"` - *alias - }{ - alias: (*alias)(c), - } - if err := sonic.Unmarshal(data, &aux); err != nil { - return err - } - if aux.Insecure == nil { - c.Insecure = true - } else { - c.Insecure = *aux.Insecure - } - return nil +// otelTarget is the runtime state for a single configured profile: one trace client +// plus an optional metrics exporter, along with the per-profile identity (service name) +// used when converting traces for this destination. +type otelTarget struct { + serviceName string + url string + traceType TraceType + client OtelClient + metricsExporter *MetricsExporter + requestHeaders []string } // OtelPlugin is the plugin for OpenTelemetry. // It implements the ObservabilityPlugin interface to receive completed traces -// from the tracing middleware and forward them to an OTEL collector. +// from the tracing middleware and forward them to one or more OTEL collectors. type OtelPlugin struct { ctx context.Context cancel context.CancelFunc - serviceName string - url string - headers map[string]string - traceType TraceType - protocol Protocol + // targets holds one runtime per configured profile. Each completed trace is exported + // to every target's collector, and metrics are recorded against every target's exporter. + targets []*otelTarget bifrostVersion string attributesFromEnvironment []*commonpb.KeyValue instanceAttrs []*commonpb.KeyValue // machine ID + pod labels, added only to root spans - client OtelClient - pricingManager *modelcatalog.ModelCatalog - // Metrics push support - metricsExporter *MetricsExporter - pluginSpanFilter *PluginSpanFilter } @@ -208,7 +338,9 @@ func Init(ctx context.Context, config *Config, _logger schemas.Logger, pricingMa if pricingManager == nil { logger.Warn("otel plugin requires model catalog to calculate cost, all cost calculations will be skipped.") } - var err error + if len(config.Profiles) == 0 { + return nil, fmt.Errorf("at least one otel profile is required") + } if config.PluginSpanFilter != nil { switch config.PluginSpanFilter.Mode { case PluginSpanFilterModeInclude, PluginSpanFilterModeExclude: @@ -217,9 +349,6 @@ func Init(ctx context.Context, config *Config, _logger schemas.Logger, pricingMa config.PluginSpanFilter.Mode, PluginSpanFilterModeInclude, PluginSpanFilterModeExclude) } } - if config.ServiceName == "" { - config.ServiceName = "bifrost" - } // Loading attributes from environment attributesFromEnvironment := make([]*commonpb.KeyValue, 0) if attributes, ok := os.LookupEnv(OTELResponseAttributesEnvKey); ok { @@ -248,11 +377,6 @@ func Init(ctx context.Context, config *Config, _logger schemas.Logger, pricingMa } // Preparing the plugin p := &OtelPlugin{ - serviceName: config.ServiceName, - url: config.CollectorURL.GetValue(), - traceType: config.TraceType, - headers: resolveHeaders(config.Headers), - protocol: config.Protocol, pricingManager: pricingManager, bifrostVersion: bifrostVersion, attributesFromEnvironment: attributesFromEnvironment, @@ -260,54 +384,103 @@ func Init(ctx context.Context, config *Config, _logger schemas.Logger, pricingMa pluginSpanFilter: config.PluginSpanFilter, } p.ctx, p.cancel = context.WithCancel(ctx) - if config.Protocol == ProtocolGRPC { - p.client, err = NewOtelClientGRPC(config.CollectorURL.GetValue(), p.headers, config.TLSCACert, config.Insecure) - if err != nil { - return nil, err + + for i, profile := range config.Profiles { + // A disabled profile exports nothing — skip building its client/exporter entirely. + if profile != nil && !profile.Enabled { + logger.Info("OTEL profile %d is disabled, skipping", i) + continue } - } - if config.Protocol == ProtocolHTTP { - p.client, err = NewOtelClientHTTP(config.CollectorURL.GetValue(), p.headers, config.TLSCACert, config.Insecure) + target, err := p.buildTarget(i, profile) if err != nil { + // Tear down any targets already initialized so we don't leak clients/exporters. + _ = p.Cleanup() return nil, err } + p.targets = append(p.targets, target) + } + + return p, nil +} + +// buildTarget constructs the runtime for a single profile: it resolves headers, validates +// the protocol, opens the trace client, and (when enabled) starts the metrics exporter. +func (p *OtelPlugin) buildTarget(index int, profile *Profile) (*otelTarget, error) { + if profile == nil { + return nil, fmt.Errorf("profile %d is nil", index) + } + if profile.CollectorURL == nil || profile.CollectorURL.GetValue() == "" { + return nil, fmt.Errorf("profile %d: collector url is required", index) + } + + serviceName := profile.ServiceName + if serviceName == "" { + serviceName = "bifrost" + } + + // Copy headers before resolving so the stored config is never mutated, then resolve + // any "env." references against the environment (errors if a referenced var is unset). + headers := make(map[string]string, len(profile.Headers)) + maps.Copy(headers, profile.Headers) + if err := injectEnvToHeaders(headers); err != nil { + return nil, fmt.Errorf("profile %d: %w", index, err) + } + + url := profile.CollectorURL.GetValue() + target := &otelTarget{ + serviceName: serviceName, + url: url, + traceType: profile.TraceType, + requestHeaders: slices.Clone(profile.RequestHeaders), + } + + var err error + switch profile.Protocol { + case ProtocolGRPC: + target.client, err = NewOtelClientGRPC(url, headers, profile.TLSCACert, profile.Insecure) + case ProtocolHTTP: + target.client, err = NewOtelClientHTTP(url, headers, profile.TLSCACert, profile.Insecure) + default: + return nil, fmt.Errorf("profile %d: invalid protocol type %q", index, profile.Protocol) } - if p.client == nil { - return nil, fmt.Errorf("otel client is not initialized. invalid protocol type") + if err != nil { + return nil, fmt.Errorf("profile %d: %w", index, err) } // Initialize metrics exporter if enabled - if config.MetricsEnabled { - if config.MetricsEndpoint.GetValue() == "" { - return nil, fmt.Errorf("metrics_endpoint is required when metrics_enabled is true") + if profile.MetricsEnabled { + if profile.MetricsEndpoint.GetValue() == "" { + target.client.Close() + return nil, fmt.Errorf("profile %d: metrics_endpoint is required when metrics_enabled is true", index) } - pushInterval := config.MetricsPushInterval + pushInterval := profile.MetricsPushInterval if pushInterval <= 0 { pushInterval = 15 // default 15 seconds } else if pushInterval > 300 { - return nil, fmt.Errorf("metrics_push_interval must be between 1 and 300 seconds, got %d", pushInterval) + target.client.Close() + return nil, fmt.Errorf("profile %d: metrics_push_interval must be between 1 and 300 seconds, got %d", index, pushInterval) } metricsConfig := &MetricsConfig{ - ServiceName: config.ServiceName, - Endpoint: config.MetricsEndpoint.GetValue(), - Headers: p.headers, - Protocol: config.Protocol, - TLSCACert: config.TLSCACert, - Insecure: config.Insecure, + ServiceName: serviceName, + Endpoint: profile.MetricsEndpoint.GetValue(), + Headers: headers, + Protocol: profile.Protocol, + TLSCACert: profile.TLSCACert, + Insecure: profile.Insecure, PushInterval: pushInterval, } - p.metricsExporter, err = NewMetricsExporter(p.ctx, metricsConfig) + target.metricsExporter, err = NewMetricsExporter(p.ctx, metricsConfig) if err != nil { // Clean up trace client if metrics exporter fails - if p.client != nil { - p.client.Close() + if target.client != nil { + target.client.Close() } - return nil, fmt.Errorf("failed to initialize metrics exporter: %w", err) + return nil, fmt.Errorf("profile %d: failed to initialize metrics exporter: %w", index, err) } - logger.Info("OTEL metrics push enabled, pushing to %s every %d seconds", config.MetricsEndpoint.GetValue(), pushInterval) + logger.Info("OTEL metrics push enabled for profile %d, pushing to %s every %d seconds", index, profile.MetricsEndpoint.GetValue(), pushInterval) } - return p, nil + return target, nil } // GetName function for the OTEL plugin @@ -372,50 +545,6 @@ func (p *OtelPlugin) HTTPTransportStreamChunkHook(ctx *schemas.BifrostContext, r return chunk, nil } -// ValidateConfig function for the OTEL plugin -func (p *OtelPlugin) ValidateConfig(config any) (*Config, error) { - var otelConfig Config - // Checking if its a string, then we will JSON parse and confirm - if configStr, ok := config.(string); ok { - if err := sonic.Unmarshal([]byte(configStr), &otelConfig); err != nil { - return nil, err - } - } - // Checking if its a map[string]any, then we will JSON parse and confirm - if configMap, ok := config.(map[string]any); ok { - configString, err := sonic.Marshal(configMap) - if err != nil { - return nil, err - } - if err := sonic.Unmarshal([]byte(configString), &otelConfig); err != nil { - return nil, err - } - } - // Checking if its a Config, then we will confirm - if config, ok := config.(*Config); ok { - otelConfig = *config - } - // Validating fields - if otelConfig.CollectorURL == nil || otelConfig.CollectorURL.GetValue() == "" { - return nil, fmt.Errorf("collector url is required") - } - if otelConfig.TraceType == "" { - return nil, fmt.Errorf("trace type is required") - } - if otelConfig.Protocol == "" { - return nil, fmt.Errorf("protocol is required") - } - if otelConfig.PluginSpanFilter != nil { - switch otelConfig.PluginSpanFilter.Mode { - case PluginSpanFilterModeInclude, PluginSpanFilterModeExclude: - default: - return nil, fmt.Errorf("plugin_span_filter.mode %q is invalid: must be %q or %q", - otelConfig.PluginSpanFilter.Mode, PluginSpanFilterModeInclude, PluginSpanFilterModeExclude) - } - } - return &otelConfig, nil -} - // PreLLMHook is a no-op - tracing is handled via the Inject method. // The OTEL plugin receives completed traces from TracingMiddleware. func (p *OtelPlugin) PreLLMHook(_ *schemas.BifrostContext, req *schemas.BifrostRequest) (*schemas.BifrostRequest, *schemas.LLMPluginShortCircuit, error) { @@ -431,7 +560,7 @@ func (p *OtelPlugin) PreLLMHook(_ *schemas.BifrostContext, req *schemas.BifrostR // This is the ONLY place RecordCacheHit is called — do not also emit it from // recordMetricsFromTrace, or cache hits will double-count. func (p *OtelPlugin) PostLLMHook(ctx *schemas.BifrostContext, resp *schemas.BifrostResponse, bifrostErr *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError, error) { - if p.metricsExporter == nil || resp == nil { + if resp == nil || !p.anyMetricsEnabled() { return resp, bifrostErr, nil } extra := resp.GetExtraFields() @@ -449,11 +578,25 @@ func (p *OtelPlugin) PostLLMHook(ctx *schemas.BifrostContext, resp *schemas.Bifr // cache hit has no span to read. attrs := append(buildContextAttrs(ctx, resp, bifrostErr), attribute.String("cache_type", cacheType)) - p.metricsExporter.RecordCacheHit(ctx, attrs...) + for _, t := range p.targets { + if t.metricsExporter != nil { + t.metricsExporter.RecordCacheHit(ctx, attrs...) + } + } return resp, bifrostErr, nil } +// anyMetricsEnabled reports whether at least one profile has a metrics exporter running. +func (p *OtelPlugin) anyMetricsEnabled() bool { + for _, t := range p.targets { + if t.metricsExporter != nil { + return true + } + } + return false +} + // Inject receives a completed trace and sends it to the OTEL collector. // Implements schemas.ObservabilityPlugin interface. // This method is called asynchronously by TracingMiddleware after the response @@ -462,22 +605,51 @@ func (p *OtelPlugin) Inject(ctx context.Context, trace *schemas.Trace) error { if trace == nil { return nil } - // Emit trace to collector if client is initialized - if p.client != nil { - // Convert schemas.Trace to OTEL ResourceSpan - resourceSpan := p.convertTraceToResourceSpan(trace) - // Emit to collector - if err := p.client.Emit(ctx, []*ResourceSpan{resourceSpan}); err != nil { - logger.Error("failed to emit trace %s: %v", trace.TraceID, err) - } - } - // Record metrics if metrics exporter is enabled - if p.metricsExporter != nil { - p.recordMetricsFromTrace(ctx, trace) + // Emit the trace to every configured profile's collector, and record metrics against + // each profile's exporter. Conversion is per-target because the resource service name + // differs per profile; everything else (filter, instance attrs) is shared. + var wg sync.WaitGroup + for _, t := range p.targets { + wg.Add(1) + go func(t *otelTarget) { + defer wg.Done() + if t.client != nil { + resourceSpan := p.convertTraceToResourceSpan(t.serviceName, trace, t.requestHeaders) + if err := t.client.Emit(ctx, []*ResourceSpan{resourceSpan}); err != nil { + logger.Error("failed to emit trace %s to %s: %v", trace.TraceID, t.url, err) + } + } + if t.metricsExporter != nil { + p.recordMetricsFromTrace(ctx, t.metricsExporter, trace) + } + }(t) } + wg.Wait() return nil } +// RequestHeaderPatterns returns the deduplicated union of request-header name patterns +// across all enabled profiles. The tracing middleware uses this to capture matching +// headers onto the trace; each profile filters to its own subset at conversion time. +func (p *OtelPlugin) RequestHeaderPatterns() []string { + seen := make(map[string]struct{}) + var patterns []string + for _, t := range p.targets { + for _, h := range t.requestHeaders { + normalized := strings.ToLower(strings.TrimSpace(h)) + if normalized == "" { + continue + } + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + patterns = append(patterns, normalized) + } + } + return patterns +} + // Helper functions for type-safe attribute extraction from trace spans func getStringAttr(attrs map[string]any, key string) string { if attrs == nil { @@ -574,8 +746,8 @@ func buildContextAttrs(ctx context.Context, resp *schemas.BifrostResponse, bifro // per llm.call/retry span so fallback attempts and failed retries are counted with // their own provider/model/fallback_index labels. Per-trace metrics (tokens, cost, // TTFT) are recorded once, keyed off the final (latest) attempt span. -func (p *OtelPlugin) recordMetricsFromTrace(ctx context.Context, trace *schemas.Trace) { - if trace == nil || p.metricsExporter == nil { +func (p *OtelPlugin) recordMetricsFromTrace(ctx context.Context, exporter *MetricsExporter, trace *schemas.Trace) { + if trace == nil || exporter == nil { return } @@ -587,17 +759,17 @@ func (p *OtelPlugin) recordMetricsFromTrace(ctx context.Context, trace *schemas. spanAttrs := buildSpanAttrs(span) - p.metricsExporter.RecordUpstreamRequest(ctx, spanAttrs...) + exporter.RecordUpstreamRequest(ctx, spanAttrs...) if !span.StartTime.IsZero() && !span.EndTime.IsZero() { latencySeconds := span.EndTime.Sub(span.StartTime).Seconds() - p.metricsExporter.RecordUpstreamLatency(ctx, latencySeconds, spanAttrs...) + exporter.RecordUpstreamLatency(ctx, latencySeconds, spanAttrs...) } if span.Status == schemas.SpanStatusError { - p.metricsExporter.RecordErrorRequest(ctx, spanAttrs...) + exporter.RecordErrorRequest(ctx, spanAttrs...) } else { - p.metricsExporter.RecordSuccessRequest(ctx, spanAttrs...) + exporter.RecordSuccessRequest(ctx, spanAttrs...) } if finalSpan == nil || span.EndTime.After(finalSpan.EndTime) { @@ -618,7 +790,7 @@ func (p *OtelPlugin) recordMetricsFromTrace(ctx context.Context, trace *schemas. // Record retries used for this request. Read off the final span (the last attempt's // attempt index) so the value is "total retries used", matching the Prometheus side. retries := getIntAttr(attrs, schemas.AttrNumberOfRetries) - p.metricsExporter.RecordRequestRetries(ctx, float64(retries), otelAttrs...) + exporter.RecordRequestRetries(ctx, float64(retries), otelAttrs...) // Record token usage - try both naming conventions inputTokens := getIntAttr(attrs, schemas.AttrPromptTokens) @@ -626,7 +798,7 @@ func (p *OtelPlugin) recordMetricsFromTrace(ctx context.Context, trace *schemas. inputTokens = getIntAttr(attrs, schemas.AttrInputTokens) } if inputTokens > 0 { - p.metricsExporter.RecordInputTokens(ctx, int64(inputTokens), otelAttrs...) + exporter.RecordInputTokens(ctx, int64(inputTokens), otelAttrs...) } outputTokens := getIntAttr(attrs, schemas.AttrCompletionTokens) @@ -634,20 +806,20 @@ func (p *OtelPlugin) recordMetricsFromTrace(ctx context.Context, trace *schemas. outputTokens = getIntAttr(attrs, schemas.AttrOutputTokens) } if outputTokens > 0 { - p.metricsExporter.RecordOutputTokens(ctx, int64(outputTokens), otelAttrs...) + exporter.RecordOutputTokens(ctx, int64(outputTokens), otelAttrs...) } // Record cost if available cost := getFloat64Attr(attrs, schemas.AttrUsageCost) if cost > 0 { - p.metricsExporter.RecordCost(ctx, cost, otelAttrs...) + exporter.RecordCost(ctx, cost, otelAttrs...) } // Record streaming latency metrics if available ttft := getFloat64Attr(attrs, schemas.AttrTimeToFirstToken) if ttft > 0 { // Convert from nanoseconds to seconds if needed (check the unit) - p.metricsExporter.RecordStreamFirstTokenLatency(ctx, ttft/1e9, otelAttrs...) + exporter.RecordStreamFirstTokenLatency(ctx, ttft/1e9, otelAttrs...) } // Record provider-side prompt cache tokens (cache_read / cache_creation). Unlike the @@ -657,59 +829,59 @@ func (p *OtelPlugin) recordMetricsFromTrace(ctx context.Context, trace *schemas. // API-family-specific keys that are mutually exclusive per request, so a fallback read // covers both. if n := getIntAttr(attrs, schemas.AttrUsageCacheReadInputTokens); n > 0 { - p.metricsExporter.RecordCacheReadInputTokens(ctx, int64(n), otelAttrs...) + exporter.RecordCacheReadInputTokens(ctx, int64(n), otelAttrs...) } if n := getIntAttr(attrs, schemas.AttrUsageCacheCreationInputTokens); n > 0 { - p.metricsExporter.RecordCacheWriteInputTokens(ctx, int64(n), otelAttrs...) + exporter.RecordCacheWriteInputTokens(ctx, int64(n), otelAttrs...) } cacheWrite5m := getIntAttr(attrs, schemas.AttrPromptTokenDetailsCachedWrite5m) if cacheWrite5m == 0 { cacheWrite5m = getIntAttr(attrs, schemas.AttrInputTokenDetailsCachedWrite5m) } if cacheWrite5m > 0 { - p.metricsExporter.RecordCacheWriteInputTokens5m(ctx, int64(cacheWrite5m), otelAttrs...) + exporter.RecordCacheWriteInputTokens5m(ctx, int64(cacheWrite5m), otelAttrs...) } cacheWrite1h := getIntAttr(attrs, schemas.AttrPromptTokenDetailsCachedWrite1h) if cacheWrite1h == 0 { cacheWrite1h = getIntAttr(attrs, schemas.AttrInputTokenDetailsCachedWrite1h) } if cacheWrite1h > 0 { - p.metricsExporter.RecordCacheWriteInputTokens1h(ctx, int64(cacheWrite1h), otelAttrs...) + exporter.RecordCacheWriteInputTokens1h(ctx, int64(cacheWrite1h), otelAttrs...) } } -// Cleanup function for the OTEL plugin +// Cleanup function for the OTEL plugin. It shuts down every profile's metrics exporter +// and closes every trace client, returning the first client-close error encountered. func (p *OtelPlugin) Cleanup() error { if p.cancel != nil { p.cancel() } - // Shutdown metrics exporter first - if p.metricsExporter != nil { - if err := p.metricsExporter.Shutdown(context.Background()); err != nil { - logger.Error("failed to shutdown metrics exporter: %v", err) + var firstErr error + for _, t := range p.targets { + // Shutdown metrics exporter first + if t.metricsExporter != nil { + if err := t.metricsExporter.Shutdown(context.Background()); err != nil { + logger.Error("failed to shutdown metrics exporter: %v", err) + } + } + if t.client != nil { + if err := t.client.Close(); err != nil && firstErr == nil { + firstErr = err + } } } - if p.client != nil { - return p.client.Close() - } - return nil + return firstErr } -// GetMetricsExporter returns the metrics exporter for external use (e.g., by telemetry plugin) +// GetMetricsExporter returns the first profile's metrics exporter for external use +// (e.g., by the telemetry plugin). Returns nil if no profile has metrics enabled. func (p *OtelPlugin) GetMetricsExporter() *MetricsExporter { - return p.metricsExporter -} - -// resolveHeaders converts a map of EnvVar header values to plain strings for use in HTTP/gRPC clients. -func resolveHeaders(in map[string]*schemas.EnvVar) map[string]string { - if in == nil { - return nil - } - out := make(map[string]string, len(in)) - for k, v := range in { - out[k] = v.GetValue() + for _, t := range p.targets { + if t.metricsExporter != nil { + return t.metricsExporter + } } - return out + return nil } // firstNonEmpty returns the first non-empty string from the provided values. diff --git a/plugins/otel/metrics.go b/plugins/otel/metrics.go index 57e41bac119..ccde3635c02 100644 --- a/plugins/otel/metrics.go +++ b/plugins/otel/metrics.go @@ -2,11 +2,8 @@ package otel import ( "context" - "crypto/tls" - "crypto/x509" "fmt" "os" - "path/filepath" "sync" "time" @@ -244,43 +241,6 @@ func NewMetricsExporter(ctx context.Context, config *MetricsConfig) (*MetricsExp return m, nil } -// validateCACertPath validates the CA certificate path to prevent path traversal attacks. -// It ensures the path is absolute, cleaned of traversal sequences, and exists as a regular file. -func validateCACertPath(certPath string) error { - if certPath == "" { - return nil - } - - // Clean the path to resolve any .. or . components - cleanPath := filepath.Clean(certPath) - - // Require absolute paths to prevent relative path attacks - if !filepath.IsAbs(cleanPath) { - return fmt.Errorf("TLS CA cert path must be absolute: %s", certPath) - } - - // Check that the cleaned path doesn't differ significantly from input - // (indicates attempted traversal) - if cleanPath != filepath.Clean(filepath.FromSlash(certPath)) { - return fmt.Errorf("invalid TLS CA cert path: %s", certPath) - } - - // Verify the file exists and is not a symlink - info, err := os.Lstat(cleanPath) - if err != nil { - return fmt.Errorf("TLS CA cert path not accessible: %w", err) - } - // Reject symlinks to prevent symlink-based path traversal - if info.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("TLS CA cert path cannot be a symlink: %s", certPath) - } - if !info.Mode().IsRegular() { - return fmt.Errorf("TLS CA cert path is not a regular file: %s", certPath) - } - - return nil -} - func createHTTPExporter(ctx context.Context, config *MetricsConfig) (sdkmetric.Exporter, error) { opts := []otlpmetrichttp.Option{ otlpmetrichttp.WithEndpointURL(config.Endpoint), @@ -290,34 +250,16 @@ func createHTTPExporter(ctx context.Context, config *MetricsConfig) (sdkmetric.E opts = append(opts, otlpmetrichttp.WithHeaders(config.Headers)) } - // TLS priority: custom CA > system roots > insecure - if config.TLSCACert != "" { - // Validate the CA cert path to prevent path traversal attacks - if err := validateCACertPath(config.TLSCACert); err != nil { - return nil, err - } - // Use custom CA certificate - caCert, err := os.ReadFile(config.TLSCACert) + // HTTP metrics insecure mode disables TLS entirely (unlike the trace HTTP client + // which uses InsecureSkipVerify). buildTLSConfig is bypassed for that case. + if config.TLSCACert == "" && config.Insecure { + opts = append(opts, otlpmetrichttp.WithInsecure()) + } else { + tlsConfig, err := buildTLSConfig(config.TLSCACert, false) if err != nil { - return nil, fmt.Errorf("failed to read CA cert: %w", err) - } - caCertPool := x509.NewCertPool() - if !caCertPool.AppendCertsFromPEM(caCert) { - return nil, fmt.Errorf("failed to parse CA cert") - } - tlsConfig := &tls.Config{ - RootCAs: caCertPool, - MinVersion: tls.VersionTLS12, + return nil, err } opts = append(opts, otlpmetrichttp.WithTLSClientConfig(tlsConfig)) - } else if config.Insecure { - // Skip TLS entirely - opts = append(opts, otlpmetrichttp.WithInsecure()) - } else { - // Use system root CAs (empty tls.Config uses system roots) - opts = append(opts, otlpmetrichttp.WithTLSClientConfig(&tls.Config{ - MinVersion: tls.VersionTLS12, - })) } return otlpmetrichttp.New(ctx, opts...) @@ -332,37 +274,15 @@ func createGRPCExporter(ctx context.Context, config *MetricsConfig) (sdkmetric.E opts = append(opts, otlpmetricgrpc.WithHeaders(config.Headers)) } - // TLS priority: custom CA > system roots > insecure - if config.TLSCACert != "" { - // Validate the CA cert path to prevent path traversal attacks - if err := validateCACertPath(config.TLSCACert); err != nil { - return nil, err - } - // Use custom CA certificate with MinVersion - caCert, err := os.ReadFile(config.TLSCACert) - if err != nil { - return nil, fmt.Errorf("failed to read CA cert: %w", err) - } - caCertPool := x509.NewCertPool() - if !caCertPool.AppendCertsFromPEM(caCert) { - return nil, fmt.Errorf("failed to parse CA cert") - } - tlsConfig := &tls.Config{ - RootCAs: caCertPool, - MinVersion: tls.VersionTLS12, - } - creds := credentials.NewTLS(tlsConfig) - opts = append(opts, otlpmetricgrpc.WithTLSCredentials(creds)) - } else if config.Insecure { - // Skip TLS entirely + // gRPC insecure mode uses plaintext (no TLS at all). buildTLSConfig is bypassed for that case. + if config.TLSCACert == "" && config.Insecure { opts = append(opts, otlpmetricgrpc.WithTLSCredentials(insecure.NewCredentials())) } else { - // Use system root CAs with MinVersion - tlsConfig := &tls.Config{ - MinVersion: tls.VersionTLS12, + tlsConfig, err := buildTLSConfig(config.TLSCACert, false) + if err != nil { + return nil, err } - creds := credentials.NewTLS(tlsConfig) - opts = append(opts, otlpmetricgrpc.WithTLSCredentials(creds)) + opts = append(opts, otlpmetricgrpc.WithTLSCredentials(credentials.NewTLS(tlsConfig))) } return otlpmetricgrpc.New(ctx, opts...) diff --git a/plugins/otel/profiles_test.go b/plugins/otel/profiles_test.go new file mode 100644 index 00000000000..61ce2380c43 --- /dev/null +++ b/plugins/otel/profiles_test.go @@ -0,0 +1,369 @@ +package otel + +import ( + "context" + "encoding/json" + "testing" + + "github.com/bytedance/sonic" + "github.com/maximhq/bifrost/core/schemas" +) + +// TestConfigUnmarshalLegacySingleObject verifies that a legacy single-object config +// (no "profiles" key) is normalized into a one-element Profiles slice, with its +// plugin_span_filter hoisted to the shared Config level. +func TestConfigUnmarshalLegacySingleObject(t *testing.T) { + raw := `{ + "service_name": "svc", + "collector_url": "localhost:4317", + "trace_type": "genai_extension", + "protocol": "grpc", + "headers": {"Authorization": "env.OTEL_TOKEN"}, + "plugin_span_filter": {"mode": "exclude", "plugins": ["logging"]} + }` + + var cfg Config + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(cfg.Profiles) != 1 { + t.Fatalf("Profiles len = %d, want 1", len(cfg.Profiles)) + } + p := cfg.Profiles[0] + if p.ServiceName != "svc" { + t.Errorf("ServiceName = %q, want svc", p.ServiceName) + } + if p.CollectorURL.GetValue() != "localhost:4317" { + t.Errorf("CollectorURL = %q, want localhost:4317", p.CollectorURL.GetValue()) + } + if p.Protocol != ProtocolGRPC { + t.Errorf("Protocol = %q, want grpc", p.Protocol) + } + if p.Headers["Authorization"] != "env.OTEL_TOKEN" { + t.Errorf("Headers[Authorization] = %q, want env.OTEL_TOKEN", p.Headers["Authorization"]) + } + if cfg.PluginSpanFilter == nil || cfg.PluginSpanFilter.Mode != PluginSpanFilterModeExclude { + t.Fatalf("PluginSpanFilter not hoisted: %+v", cfg.PluginSpanFilter) + } +} + +// TestConfigUnmarshalWrapperArray verifies the canonical wrapper with multiple profiles. +func TestConfigUnmarshalWrapperArray(t *testing.T) { + raw := `{ + "profiles": [ + {"collector_url": "host-a:4317", "trace_type": "genai_extension", "protocol": "grpc"}, + {"collector_url": "host-b:4318", "trace_type": "genai_extension", "protocol": "http"} + ], + "plugin_span_filter": {"mode": "include", "plugins": ["guardrails"]} + }` + + var cfg Config + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(cfg.Profiles) != 2 { + t.Fatalf("Profiles len = %d, want 2", len(cfg.Profiles)) + } + if cfg.Profiles[0].CollectorURL.GetValue() != "host-a:4317" || cfg.Profiles[0].Protocol != ProtocolGRPC { + t.Errorf("profile 0 wrong: %+v", cfg.Profiles[0]) + } + if cfg.Profiles[1].CollectorURL.GetValue() != "host-b:4318" || cfg.Profiles[1].Protocol != ProtocolHTTP { + t.Errorf("profile 1 wrong: %+v", cfg.Profiles[1]) + } + if cfg.PluginSpanFilter == nil || cfg.PluginSpanFilter.Mode != PluginSpanFilterModeInclude { + t.Fatalf("PluginSpanFilter = %+v, want include", cfg.PluginSpanFilter) + } +} + +// TestConfigUnmarshalHoistFromFirstProfile verifies that when the top-level +// plugin_span_filter is absent in a wrapper, it is hoisted from the first profile +// that carries one. +func TestConfigUnmarshalHoistFromFirstProfile(t *testing.T) { + raw := `{ + "profiles": [ + {"collector_url": "a:4317", "trace_type": "genai_extension", "protocol": "grpc"}, + {"collector_url": "b:4317", "trace_type": "genai_extension", "protocol": "grpc", + "plugin_span_filter": {"mode": "exclude", "plugins": ["telemetry"]}} + ] + }` + + var cfg Config + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if cfg.PluginSpanFilter == nil || cfg.PluginSpanFilter.Mode != PluginSpanFilterModeExclude { + t.Fatalf("PluginSpanFilter not hoisted from profile: %+v", cfg.PluginSpanFilter) + } + if len(cfg.PluginSpanFilter.Plugins) != 1 || cfg.PluginSpanFilter.Plugins[0] != "telemetry" { + t.Errorf("hoisted filter plugins = %v, want [telemetry]", cfg.PluginSpanFilter.Plugins) + } +} + +// TestProfileInsecureDefault verifies Insecure defaults to true when omitted and is +// honored when set explicitly — per profile. +func TestProfileInsecureDefault(t *testing.T) { + raw := `{ + "profiles": [ + {"collector_url": "a:4317", "trace_type": "genai_extension", "protocol": "grpc"}, + {"collector_url": "b:4317", "trace_type": "genai_extension", "protocol": "grpc", "insecure": false} + ] + }` + + var cfg Config + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !cfg.Profiles[0].Insecure { + t.Errorf("profile 0 Insecure = false, want true (default)") + } + if cfg.Profiles[1].Insecure { + t.Errorf("profile 1 Insecure = true, want false (explicit)") + } +} + +// TestProfileEnabledDefault verifies Enabled defaults to true when omitted and is honored +// when set explicitly. +func TestProfileEnabledDefault(t *testing.T) { + raw := `{ + "profiles": [ + {"collector_url": "a:4317", "trace_type": "genai_extension", "protocol": "grpc"}, + {"collector_url": "b:4317", "trace_type": "genai_extension", "protocol": "grpc", "enabled": false} + ] + }` + + var cfg Config + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !cfg.Profiles[0].Enabled { + t.Errorf("profile 0 Enabled = false, want true (default)") + } + if cfg.Profiles[1].Enabled { + t.Errorf("profile 1 Enabled = true, want false (explicit)") + } +} + +// TestInitSkipsDisabledProfile verifies a disabled profile is not field-validated, +// so an incomplete-but-disabled profile is allowed alongside a valid enabled one. +func TestInitSkipsDisabledProfile(t *testing.T) { + raw := `{"profiles": [ + {"collector_url": "a:4317", "trace_type": "genai_extension", "protocol": "grpc"}, + {"enabled": false} + ]}` + + var cfg Config + if err := sonic.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(cfg.Profiles) != 2 { + t.Errorf("profiles len = %d, want 2", len(cfg.Profiles)) + } + plugin, err := Init(context.Background(), &cfg, testLogger{}, nil, "") + if err != nil { + t.Fatalf("Init with disabled incomplete profile: %v", err) + } + t.Cleanup(func() { _ = plugin.Cleanup() }) + if len(plugin.targets) != 1 { + t.Errorf("targets len = %d, want 1", len(plugin.targets)) + } +} + +// TestMarshalForStorageRoundTrip verifies storage marshalling produces the canonical +// wrapper with EnvVar fields flattened to strings, and that it round-trips back. +func TestMarshalForStorageRoundTrip(t *testing.T) { + t.Setenv("OTEL_TOKEN", "secret-token") + t.Setenv("OTEL_SECOND_TOKEN", "second-token") + t.Setenv("OTEL_URL", "collector:4317") + raw := `{ + "profiles": [ + { + "service_name": "svc-a", + "collector_url": "env.OTEL_URL", + "trace_type": "genai_extension", + "protocol": "grpc", + "headers": {"Authorization": "env.OTEL_TOKEN", "X-Tenant": "acme"} + }, + { + "service_name": "svc-b", + "collector_url": "http://collector-b:4318/v1/traces", + "trace_type": "genai_extension", + "protocol": "http", + "headers": {"Authorization": "env.OTEL_SECOND_TOKEN", "X-Tenant": "beta"} + } + ], + "plugin_span_filter": {"mode": "exclude", "plugins": ["logging"]} + }` + + var cfg Config + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + stored, err := cfg.MarshalForStorage() + if err != nil { + t.Fatalf("MarshalForStorage: %v", err) + } + + // Storage form must be a wrapper object with a profiles array. + var asMap map[string]any + if err := sonic.Unmarshal(stored, &asMap); err != nil { + t.Fatalf("stored not an object: %v", err) + } + profiles, ok := asMap["profiles"].([]any) + if !ok || len(profiles) != 2 { + t.Fatalf("stored profiles = %v, want 2-element array", asMap["profiles"]) + } + if _, ok := asMap["plugin_span_filter"]; !ok { + t.Errorf("plugin_span_filter missing from stored config") + } + + // Round-trip back into a Config. + var back Config + if err := json.Unmarshal(stored, &back); err != nil { + t.Fatalf("round-trip unmarshal: %v", err) + } + if len(back.Profiles) != 2 { + t.Fatalf("round-trip profiles len = %d, want 2", len(back.Profiles)) + } + if back.PluginSpanFilter == nil || back.PluginSpanFilter.Mode != PluginSpanFilterModeExclude { + t.Fatalf("round-trip plugin_span_filter = %+v, want exclude", back.PluginSpanFilter) + } + if len(back.PluginSpanFilter.Plugins) != 1 || back.PluginSpanFilter.Plugins[0] != "logging" { + t.Errorf("round-trip plugin_span_filter plugins = %v, want [logging]", back.PluginSpanFilter.Plugins) + } + // Profile 0 CollectorURL was an env ref; stored as "env.OTEL_URL" and re-resolved on load. + if back.Profiles[0].CollectorURL.GetValue() != "collector:4317" { + t.Errorf("round-trip profile 0 collector_url = %q, want collector:4317", back.Profiles[0].CollectorURL.GetValue()) + } + if back.Profiles[0].Headers["Authorization"] != "env.OTEL_TOKEN" { + t.Errorf("round-trip profile 0 header env ref not preserved: %q", back.Profiles[0].Headers["Authorization"]) + } + if back.Profiles[0].Headers["X-Tenant"] != "acme" { + t.Errorf("round-trip profile 0 literal header lost: %q", back.Profiles[0].Headers["X-Tenant"]) + } + if back.Profiles[1].CollectorURL.GetValue() != "http://collector-b:4318/v1/traces" { + t.Errorf("round-trip profile 1 collector_url = %q, want http://collector-b:4318/v1/traces", back.Profiles[1].CollectorURL.GetValue()) + } + if back.Profiles[1].Headers["Authorization"] != "env.OTEL_SECOND_TOKEN" { + t.Errorf("round-trip profile 1 header env ref not preserved: %q", back.Profiles[1].Headers["Authorization"]) + } + if back.Profiles[1].Headers["X-Tenant"] != "beta" { + t.Errorf("round-trip profile 1 literal header lost: %q", back.Profiles[1].Headers["X-Tenant"]) + } +} + +// TestRedactedHeaders verifies header redaction: env references are preserved while +// literal values are masked. +func TestRedactedHeaders(t *testing.T) { + raw := `{ + "profiles": [ + { + "collector_url": "localhost:4317", + "trace_type": "genai_extension", + "protocol": "grpc", + "headers": {"Authorization": "env.OTEL_TOKEN", "X-Api-Key": "supersecretvalue123"} + } + ] + }` + var cfg Config + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !cfg.Profiles[0].Enabled { + t.Fatalf("profile Enabled = false, want true from JSON default") + } + if !cfg.Profiles[0].Insecure { + t.Fatalf("profile Insecure = false, want true from JSON default") + } + red := cfg.Redacted() + got := red.Profiles[0].Headers + if got["Authorization"] != "env.OTEL_TOKEN" { + t.Errorf("env header redacted = %q, want env.OTEL_TOKEN (preserved)", got["Authorization"]) + } + if got["X-Api-Key"] == "supersecretvalue123" { + t.Errorf("literal header was not masked") + } + // Original must be untouched. + if cfg.Profiles[0].Headers["X-Api-Key"] != "supersecretvalue123" { + t.Errorf("Redacted mutated the original config") + } +} + +// TestInjectEnvToHeaders verifies env resolution and the missing-var error. +func TestInjectEnvToHeaders(t *testing.T) { + t.Setenv("OTEL_TOKEN", "resolved") + h := map[string]string{"Authorization": "env.OTEL_TOKEN", "X-Plain": "literal"} + if err := injectEnvToHeaders(h); err != nil { + t.Fatalf("injectEnvToHeaders: %v", err) + } + if h["Authorization"] != "resolved" { + t.Errorf("Authorization = %q, want resolved", h["Authorization"]) + } + if h["X-Plain"] != "literal" { + t.Errorf("X-Plain = %q, want literal (unchanged)", h["X-Plain"]) + } + + missing := map[string]string{"Authorization": "env.OTEL_MISSING_VAR"} + if err := injectEnvToHeaders(missing); err == nil { + t.Errorf("expected error for missing env var, got nil") + } +} + +// TestInitMultiProfileValidation verifies per-profile validation errors. +func TestInitMultiProfileValidation(t *testing.T) { + // Missing profiles entirely. + var empty Config + if err := sonic.Unmarshal([]byte(`{"profiles": []}`), &empty); err != nil { + t.Fatalf("unmarshal empty profiles: %v", err) + } + if _, err := Init(context.Background(), &empty, testLogger{}, nil, ""); err == nil { + t.Errorf("expected error for empty profiles") + } + + // Second profile missing protocol. + bad := `{"profiles": [ + {"collector_url": "a:4317", "trace_type": "genai_extension", "protocol": "grpc"}, + {"collector_url": "b:4317", "trace_type": "genai_extension"} + ]}` + var badCfg Config + if err := sonic.Unmarshal([]byte(bad), &badCfg); err != nil { + t.Fatalf("unmarshal bad profiles: %v", err) + } + if _, err := Init(context.Background(), &badCfg, testLogger{}, nil, ""); err == nil { + t.Errorf("expected error for profile missing protocol") + } + + // Valid multi-profile. + good := `{"profiles": [ + {"collector_url": "a:4317", "trace_type": "genai_extension", "protocol": "grpc"}, + {"collector_url": "b:4318", "trace_type": "genai_extension", "protocol": "http"} + ]}` + var cfg Config + if err := sonic.Unmarshal([]byte(good), &cfg); err != nil { + t.Fatalf("unmarshal valid profiles: %v", err) + } + if len(cfg.Profiles) != 2 { + t.Errorf("profiles len = %d, want 2", len(cfg.Profiles)) + } + plugin, err := Init(context.Background(), &cfg, testLogger{}, nil, "") + if err != nil { + t.Fatalf("Init valid profiles: %v", err) + } + t.Cleanup(func() { _ = plugin.Cleanup() }) + if len(plugin.targets) != 2 { + t.Errorf("targets len = %d, want 2", len(plugin.targets)) + } +} + +type testLogger struct{} + +func (testLogger) Debug(string, ...any) {} +func (testLogger) Info(string, ...any) {} +func (testLogger) Warn(string, ...any) {} +func (testLogger) Error(string, ...any) {} +func (testLogger) Fatal(string, ...any) {} +func (testLogger) SetLevel(schemas.LogLevel) {} +func (testLogger) SetOutputType(schemas.LoggerOutputType) {} +func (testLogger) LogHTTPRequest(schemas.LogLevel, string) schemas.LogEventBuilder { + return schemas.NoopLogEvent +} diff --git a/plugins/otel/utils.go b/plugins/otel/utils.go new file mode 100644 index 00000000000..7dec1977e1a --- /dev/null +++ b/plugins/otel/utils.go @@ -0,0 +1,91 @@ +package otel + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "os" + "path/filepath" + "strings" +) + +// injectEnvToHeaders converts any headers that start with "env." with their corresponding environment variable value +// errors out if any environment variable is not found +func injectEnvToHeaders(headers map[string]string) error { + if headers == nil { + return nil + } + for k, v := range headers { + if envKey, ok := strings.CutPrefix(v, "env."); ok { + envVal, found := os.LookupEnv(envKey) + if !found { + return fmt.Errorf("environment variable %s not found", envKey) + } + headers[k] = envVal + } + } + return nil +} + +// validateCACertPath validates the CA certificate path to prevent path traversal attacks. +// It ensures the path is absolute, cleaned of traversal sequences, and exists as a regular file. +func validateCACertPath(certPath string) error { + if certPath == "" { + return nil + } + + // Clean the path to resolve any .. or . components + cleanPath := filepath.Clean(certPath) + + // Require absolute paths to prevent relative path attacks + if !filepath.IsAbs(cleanPath) { + return fmt.Errorf("TLS CA cert path must be absolute: %s", certPath) + } + + // Verify the file exists and is not a symlink + info, err := os.Lstat(cleanPath) + if err != nil { + return fmt.Errorf("TLS CA cert path not accessible: %w", err) + } + // Reject symlinks to prevent symlink-based path traversal + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("TLS CA cert path cannot be a symlink: %s", certPath) + } + // Ensure path is a regular file, not directories, sockets, pipes, devices, etc. + if !info.Mode().IsRegular() { + return fmt.Errorf("TLS CA cert path is not a regular file: %s", certPath) + } + + return nil +} + +// Builds a TLS config with custom CA, insecure mode, or system roots CAs +// - use a custom CA pool if tlsCACert is provided +// - otherwise skip verification if insecureMode is enabled +// - otherwise use the system root CAs +func buildTLSConfig(tlsCACert string, insecureMode bool) (*tls.Config, error) { + cfg := tls.Config{ + InsecureSkipVerify: false, + MinVersion: tls.VersionTLS12, + } + + // TLS priority: custom CA > system roots > insecure + if tlsCACert != "" { + if err := validateCACertPath(tlsCACert); err != nil { + return nil, err + } + caCert, err := os.ReadFile(tlsCACert) + if err != nil { + return nil, fmt.Errorf("failed to load provided CA cert: %w", err) + } + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCert) { + return nil, fmt.Errorf("failed to add provided CA cert") + } + cfg.RootCAs = caCertPool + } else if insecureMode { + cfg.InsecureSkipVerify = true // #nosec G402 + } + + return &cfg, nil +} diff --git a/plugins/prompts/changelog.md b/plugins/prompts/changelog.md index c5c2bac21f5..998a5bd07ab 100644 --- a/plugins/prompts/changelog.md +++ b/plugins/prompts/changelog.md @@ -1 +1 @@ -- chore: upgraded core to v1.5.16 and framework to v1.3.16 +- chore: upgraded core to v1.5.16 and framework to v1.3.16 \ No newline at end of file diff --git a/plugins/prompts/go.mod b/plugins/prompts/go.mod index 338aede26a6..88f89a30613 100644 --- a/plugins/prompts/go.mod +++ b/plugins/prompts/go.mod @@ -15,7 +15,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect - github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.11 // indirect @@ -38,17 +38,17 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/bytedance/gopkg v0.1.3 // indirect - github.com/bytedance/sonic v1.15.0 // indirect - github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/bytedance/sonic v1.15.1 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mailru/easyjson v0.9.1 // indirect @@ -65,7 +65,7 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.68.0 // indirect + github.com/valyala/fasthttp v1.71.0 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.starlark.net v0.0.0-20260102030733-3fee463870c9 // indirect @@ -75,6 +75,7 @@ require ( golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/gorm v1.31.1 // indirect ) diff --git a/plugins/prompts/go.sum b/plugins/prompts/go.sum index 71f3666df31..56309e2c803 100644 --- a/plugins/prompts/go.sum +++ b/plugins/prompts/go.sum @@ -14,8 +14,8 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= @@ -60,10 +60,10 @@ github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJ github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= -github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= -github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= -github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= -github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= +github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= 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/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= @@ -78,8 +78,8 @@ github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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= @@ -102,8 +102,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -167,8 +167,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok= -github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= @@ -195,8 +195,8 @@ golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/plugins/semanticcache/changelog.md b/plugins/semanticcache/changelog.md index c5c2bac21f5..998a5bd07ab 100644 --- a/plugins/semanticcache/changelog.md +++ b/plugins/semanticcache/changelog.md @@ -1 +1 @@ -- chore: upgraded core to v1.5.16 and framework to v1.3.16 +- chore: upgraded core to v1.5.16 and framework to v1.3.16 \ No newline at end of file diff --git a/plugins/semanticcache/go.mod b/plugins/semanticcache/go.mod index 165e633b446..2e3eaadf4ac 100644 --- a/plugins/semanticcache/go.mod +++ b/plugins/semanticcache/go.mod @@ -17,7 +17,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect - github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect @@ -41,8 +41,8 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/bytedance/gopkg v0.1.3 // indirect - github.com/bytedance/sonic v1.15.0 // indirect - github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/bytedance/sonic v1.15.1 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect @@ -69,11 +69,11 @@ require ( github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-openapi/validate v0.25.1 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/jaswdr/faker/v2 v2.8.0 // indirect - github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mailru/easyjson v0.9.1 // indirect @@ -97,7 +97,7 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.68.0 // indirect + github.com/valyala/fasthttp v1.71.0 // indirect github.com/weaviate/weaviate v1.36.5 // indirect github.com/weaviate/weaviate-go-client/v5 v5.7.1 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect @@ -117,8 +117,8 @@ require ( golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/grpc v1.80.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/plugins/semanticcache/go.sum b/plugins/semanticcache/go.sum index 0db2ecd0725..625a0e6519a 100644 --- a/plugins/semanticcache/go.sum +++ b/plugins/semanticcache/go.sum @@ -15,8 +15,8 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mo github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= @@ -68,10 +68,10 @@ github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJ github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= -github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= -github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= -github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= -github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= +github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= 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/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= @@ -140,11 +140,11 @@ github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6 github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-openapi/validate v0.25.1 h1:sSACUI6Jcnbo5IWqbYHgjibrhhmt3vR6lCzKZnmAgBw= github.com/go-openapi/validate v0.25.1/go.mod h1:RMVyVFYte0gbSTaZ0N4KmTn6u/kClvAFp+mAVfS/DQc= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -160,8 +160,8 @@ github.com/jaswdr/faker/v2 v2.8.0/go.mod h1:jZq+qzNQr8/P+5fHd9t3txe2GNPnthrTfoht github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -240,8 +240,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok= -github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= github.com/weaviate/weaviate v1.36.5 h1:lCiuEfQ08+5wK0DkTCUBb6ayNep9QpBH6JJhmZaRfzk= github.com/weaviate/weaviate v1.36.5/go.mod h1:ljzrgEmGKn3CRzDdcxvhmBUUZIcghwIYd1Lmn54f3Z8= github.com/weaviate/weaviate-go-client/v5 v5.7.1 h1:vEMxh486QqRqWaq58UEe/TiTbGbo9T5x7ZPFd5QENvQ= @@ -292,12 +292,12 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/plugins/telemetry/changelog.md b/plugins/telemetry/changelog.md index c5c2bac21f5..998a5bd07ab 100644 --- a/plugins/telemetry/changelog.md +++ b/plugins/telemetry/changelog.md @@ -1 +1 @@ -- chore: upgraded core to v1.5.16 and framework to v1.3.16 +- chore: upgraded core to v1.5.16 and framework to v1.3.16 \ No newline at end of file diff --git a/plugins/telemetry/go.mod b/plugins/telemetry/go.mod index 6dfe4fafade..c20e3cd26d1 100644 --- a/plugins/telemetry/go.mod +++ b/plugins/telemetry/go.mod @@ -3,17 +3,17 @@ module github.com/maximhq/bifrost/plugins/telemetry go 1.26.3 require ( - github.com/bytedance/sonic v1.15.0 + github.com/bytedance/sonic v1.15.1 github.com/maximhq/bifrost/core v1.5.15 github.com/maximhq/bifrost/framework v1.3.15 github.com/prometheus/client_golang v1.23.2 - github.com/valyala/fasthttp v1.68.0 + github.com/valyala/fasthttp v1.71.0 ) require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect @@ -26,7 +26,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect - github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect @@ -51,14 +51,14 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/bytedance/gopkg v0.1.3 // indirect - github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect - github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -84,12 +84,12 @@ require ( github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-openapi/validate v0.25.1 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // 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.14 // indirect - github.com/googleapis/gax-go/v2 v2.19.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -97,7 +97,7 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mailru/easyjson v0.9.1 // indirect @@ -114,8 +114,8 @@ require ( github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.17.0 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.19.2 // indirect github.com/qdrant/go-client v1.16.2 // indirect github.com/redis/go-redis/v9 v9.17.2 // indirect github.com/rs/zerolog v1.34.0 // indirect @@ -134,16 +134,16 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.mongodb.org/mongo-driver v1.17.6 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.40.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.starlark.net v0.0.0-20260102030733-3fee463870c9 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.23.0 // indirect golang.org/x/crypto v0.52.0 // indirect @@ -153,12 +153,12 @@ require ( golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/api v0.274.0 // indirect - google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 // indirect + google.golang.org/api v0.282.0 // indirect + google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/grpc v1.80.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/driver/postgres v1.6.0 // indirect gorm.io/driver/sqlite v1.6.0 // indirect diff --git a/plugins/telemetry/go.sum b/plugins/telemetry/go.sum index b3fb6713c88..f15df341abb 100644 --- a/plugins/telemetry/go.sum +++ b/plugins/telemetry/go.sum @@ -2,8 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= 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/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= @@ -41,8 +41,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= @@ -96,16 +96,16 @@ github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJ github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= -github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= -github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= -github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= -github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= +github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= 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/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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= @@ -115,12 +115,12 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= -github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= -github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= -github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE= github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -182,11 +182,11 @@ github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6 github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-openapi/validate v0.25.1 h1:sSACUI6Jcnbo5IWqbYHgjibrhhmt3vR6lCzKZnmAgBw= github.com/go-openapi/validate v0.25.1/go.mod h1:RMVyVFYte0gbSTaZ0N4KmTn6u/kClvAFp+mAVfS/DQc= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -197,10 +197,10 @@ 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.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= -github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= -github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= +github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= @@ -220,8 +220,8 @@ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/ github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -268,10 +268,10 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= -github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/qdrant/go-client v1.16.2 h1:UUMJJfvXTByhwhH1DwWdbkhZ2cTdvSqVkXSIfBrVWSg= github.com/qdrant/go-client v1.16.2/go.mod h1:I+EL3h4HRoRTeHtbfOd/4kDXwCukZfkd41j/9wryGkw= github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= @@ -315,8 +315,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok= -github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= github.com/weaviate/weaviate v1.36.5 h1:lCiuEfQ08+5wK0DkTCUBb6ayNep9QpBH6JJhmZaRfzk= github.com/weaviate/weaviate v1.36.5/go.mod h1:ljzrgEmGKn3CRzDdcxvhmBUUZIcghwIYd1Lmn54f3Z8= github.com/weaviate/weaviate-go-client/v5 v5.7.1 h1:vEMxh486QqRqWaq58UEe/TiTbGbo9T5x7ZPFd5QENvQ= @@ -331,12 +331,12 @@ go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUps go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= 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/contrib/detectors/gcp v1.40.0 h1:Awaf8gmW99tZTOWqkLCOl6aw1/rxAWVlHsHIZ3fT2sA= -go.opentelemetry.io/contrib/detectors/gcp v1.40.0/go.mod h1:99OY9ZCqyLkzJLTh5XhECpLRSxcZl+ZDKBEO+jMBFR4= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= @@ -353,8 +353,8 @@ go.starlark.net v0.0.0-20260102030733-3fee463870c9 h1:nV1OyvU+0CYrp5eKfQ3rD03TpF go.starlark.net v0.0.0-20260102030733-3fee463870c9/go.mod h1:YKMCv9b1WrfWmeqdV5MAuEHWsu5iC+fe6kYl2sQjdI8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= @@ -379,18 +379,18 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA= -google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= -google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE= -google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw= +google.golang.org/api v0.282.0 h1:WmJiSVqUnKqJCpJOx7YADbXaC+9DDsnGSfllFSj7R2I= +google.golang.org/api v0.282.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/tests/e2e/api/collections/provider-harness.json b/tests/e2e/api/collections/provider-harness.json index d7ba29f6f05..7fa5a333368 100644 --- a/tests/e2e/api/collections/provider-harness.json +++ b/tests/e2e/api/collections/provider-harness.json @@ -476,8 +476,8 @@ { "name": "gemini/gemini-2.5-flash", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"gemini/gemini-2.5-flash\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }]\n}" }, "url": { "raw": "{{baseUrl}}/v1/chat/completions", "host": ["{{baseUrl}}"], "path": ["v1", "chat", "completions"] } } }, { "name": "gemini/gemini-2.5-flash-lite", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"gemini/gemini-2.5-flash-lite\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }]\n}" }, "url": { "raw": "{{baseUrl}}/v1/chat/completions", "host": ["{{baseUrl}}"], "path": ["v1", "chat", "completions"] } } }, { "name": "gemini/gemini-3.1-pro-preview", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"gemini/gemini-3.1-pro-preview\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }]\n}" }, "url": { "raw": "{{baseUrl}}/v1/chat/completions", "host": ["{{baseUrl}}"], "path": ["v1", "chat", "completions"] } } }, - { "name": "gemini/gemini-2.0-flash", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"gemini/gemini-2.0-flash\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }]\n}" }, "url": { "raw": "{{baseUrl}}/v1/chat/completions", "host": ["{{baseUrl}}"], "path": ["v1", "chat", "completions"] } } }, - { "name": "gemini/gemini-2.0-flash-lite", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"gemini/gemini-2.0-flash-lite\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }]\n}" }, "url": { "raw": "{{baseUrl}}/v1/chat/completions", "host": ["{{baseUrl}}"], "path": ["v1", "chat", "completions"] } } }, + { "name": "gemini/gemini-2.5-flash", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"gemini/gemini-2.5-flash\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }]\n}" }, "url": { "raw": "{{baseUrl}}/v1/chat/completions", "host": ["{{baseUrl}}"], "path": ["v1", "chat", "completions"] } } }, + { "name": "gemini/gemini-2.5-flash-lite", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"gemini/gemini-2.5-flash-lite\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }]\n}" }, "url": { "raw": "{{baseUrl}}/v1/chat/completions", "host": ["{{baseUrl}}"], "path": ["v1", "chat", "completions"] } } }, { "name": "gemini/gemini-3-flash-preview", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"gemini/gemini-3-flash-preview\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }]\n}" }, "url": { "raw": "{{baseUrl}}/v1/chat/completions", "host": ["{{baseUrl}}"], "path": ["v1", "chat", "completions"] } } }, { "name": "gemini/gemini-3.1-flash-lite-preview", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"gemini/gemini-3.1-flash-lite-preview\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }]\n}" }, "url": { "raw": "{{baseUrl}}/v1/chat/completions", "host": ["{{baseUrl}}"], "path": ["v1", "chat", "completions"] } } }, { "name": "gemini/gemini-flash-latest", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"gemini/gemini-flash-latest\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }]\n}" }, "url": { "raw": "{{baseUrl}}/v1/chat/completions", "host": ["{{baseUrl}}"], "path": ["v1", "chat", "completions"] } } }, @@ -489,7 +489,7 @@ "item": [ { "name": "vertex/gemini-2.5-pro", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"vertex/gemini-2.5-pro\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }]\n}" }, "url": { "raw": "{{baseUrl}}/v1/chat/completions", "host": ["{{baseUrl}}"], "path": ["v1", "chat", "completions"] } } }, { "name": "vertex/gemini-2.5-flash", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"vertex/gemini-2.5-flash\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }]\n}" }, "url": { "raw": "{{baseUrl}}/v1/chat/completions", "host": ["{{baseUrl}}"], "path": ["v1", "chat", "completions"] } } }, - { "name": "vertex/gemini-2.0-flash", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"vertex/gemini-2.0-flash\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }]\n}" }, "url": { "raw": "{{baseUrl}}/v1/chat/completions", "host": ["{{baseUrl}}"], "path": ["v1", "chat", "completions"] } } }, + { "name": "vertex/gemini-2.5-flash", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"vertex/gemini-2.5-flash\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }]\n}" }, "url": { "raw": "{{baseUrl}}/v1/chat/completions", "host": ["{{baseUrl}}"], "path": ["v1", "chat", "completions"] } } }, { "name": "vertex/claude-opus-4-7", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"vertex/claude-opus-4-7\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }]\n}" }, "url": { "raw": "{{baseUrl}}/v1/chat/completions", "host": ["{{baseUrl}}"], "path": ["v1", "chat", "completions"] } } }, { "name": "vertex/gemini-2.5-flash-lite", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"vertex/gemini-2.5-flash-lite\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }]\n}" }, "url": { "raw": "{{baseUrl}}/v1/chat/completions", "host": ["{{baseUrl}}"], "path": ["v1", "chat", "completions"] } } }, { "name": "[PREVIEW] vertex/gemini-3-flash-preview", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"vertex/gemini-3-flash-preview\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hello\" }]\n}" }, "url": { "raw": "{{baseUrl}}/v1/chat/completions", "host": ["{{baseUrl}}"], "path": ["v1", "chat", "completions"] } } }, @@ -516,7 +516,8 @@ { "name": "bedrock/global.anthropic.claude-opus-4-7", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-opus-4-7\",\n \"input\": \"Hello\"\n}" }, "url": { "raw": "{{baseUrl}}/v1/responses", "host": ["{{baseUrl}}"], "path": ["v1", "responses"] } } }, { "name": "bedrock/us.amazon.nova-lite-v1:0", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"bedrock/us.amazon.nova-lite-v1:0\",\n \"input\": \"Hello\"\n}" }, "url": { "raw": "{{baseUrl}}/v1/responses", "host": ["{{baseUrl}}"], "path": ["v1", "responses"] } } }, { "name": "azure/gpt-4o-mini", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"azure/gpt-4o-mini\",\n \"input\": \"Hello\"\n}" }, "url": { "raw": "{{baseUrl}}/v1/responses", "host": ["{{baseUrl}}"], "path": ["v1", "responses"] } } }, - { "name": "azure/gpt-4o", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"azure/gpt-4o\",\n \"input\": \"Hello\"\n}" }, "url": { "raw": "{{baseUrl}}/v1/responses", "host": ["{{baseUrl}}"], "path": ["v1", "responses"] } } } + { "name": "azure/gpt-4o", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"azure/gpt-4o\",\n \"input\": \"Hello\"\n}" }, "url": { "raw": "{{baseUrl}}/v1/responses", "host": ["{{baseUrl}}"], "path": ["v1", "responses"] } } }, + { "name": "xai/grok-4-0709", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"xai/grok-4-0709\",\n \"input\": \"Hello\"\n}" }, "url": { "raw": "{{baseUrl}}/v1/responses", "host": ["{{baseUrl}}"], "path": ["v1", "responses"] } } } ] }, { @@ -633,7 +634,8 @@ { "name": "gemini/gemini-2.5-flash", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"gemini/gemini-2.5-flash\",\n \"input\": \"Count to 5\",\n \"stream\": true\n}" }, "url": { "raw": "{{baseUrl}}/v1/responses", "host": ["{{baseUrl}}"], "path": ["v1", "responses"] } } }, { "name": "vertex/gemini-2.5-flash", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"vertex/gemini-2.5-flash\",\n \"input\": \"Count to 5\",\n \"stream\": true\n}" }, "url": { "raw": "{{baseUrl}}/v1/responses", "host": ["{{baseUrl}}"], "path": ["v1", "responses"] } } }, { "name": "bedrock/us.amazon.nova-lite-v1:0", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"bedrock/us.amazon.nova-lite-v1:0\",\n \"input\": \"Count to 5\",\n \"stream\": true\n}" }, "url": { "raw": "{{baseUrl}}/v1/responses", "host": ["{{baseUrl}}"], "path": ["v1", "responses"] } } }, - { "name": "azure/gpt-4o-mini", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"azure/gpt-4o-mini\",\n \"input\": \"Count to 5\",\n \"stream\": true\n}" }, "url": { "raw": "{{baseUrl}}/v1/responses", "host": ["{{baseUrl}}"], "path": ["v1", "responses"] } } } + { "name": "azure/gpt-4o-mini", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"azure/gpt-4o-mini\",\n \"input\": \"Count to 5\",\n \"stream\": true\n}" }, "url": { "raw": "{{baseUrl}}/v1/responses", "host": ["{{baseUrl}}"], "path": ["v1", "responses"] } } }, + { "name": "xai/grok-4-0709", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"xai/grok-4-0709\",\n \"input\": \"Count to 5\",\n \"stream\": true\n}" }, "url": { "raw": "{{baseUrl}}/v1/responses", "host": ["{{baseUrl}}"], "path": ["v1", "responses"] } } } ] }, { @@ -717,7 +719,7 @@ { "name": "[SKIP] anthropic/claude-haiku-4-5 (no embedding endpoint)", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"content\": { \"parts\": [{ \"text\": \"Hello world\" }] }\n}" }, "url": { "raw": "{{baseUrl}}/genai/v1beta/models/anthropic/claude-haiku-4-5:embedContent", "host": ["{{baseUrl}}"], "path": ["genai", "v1beta", "models", "anthropic", "claude-haiku-4-5:embedContent"] } } }, { "name": "vertex/text-embedding-005", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"content\": { \"parts\": [{ \"text\": \"Hello world\" }] }\n}" }, "url": { "raw": "{{baseUrl}}/genai/v1beta/models/vertex/text-embedding-005:embedContent", "host": ["{{baseUrl}}"], "path": ["genai", "v1beta", "models", "vertex", "text-embedding-005:embedContent"] } } }, { "name": "bedrock/amazon.titan-embed-text-v2:0", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"content\": { \"parts\": [{ \"text\": \"Hello world\" }] }\n}" }, "url": { "raw": "{{baseUrl}}/genai/v1beta/models/bedrock/amazon.titan-embed-text-v2:0:embedContent", "host": ["{{baseUrl}}"], "path": ["genai", "v1beta", "models", "bedrock", "amazon.titan-embed-text-v2:0:embedContent"] } } }, - { "name": "azure/text-embedding-ada-002", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"content\": { \"parts\": [{ \"text\": \"Hello world\" }] }\n}" }, "url": { "raw": "{{baseUrl}}/genai/v1beta/models/azure/text-embedding-ada-002:embedContent", "host": ["{{baseUrl}}"], "path": ["genai", "v1beta", "models", "azure", "text-embedding-3-small:embedContent"] } } } + { "name": "azure/text-embedding-ada-002", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"content\": { \"parts\": [{ \"text\": \"Hello world\" }] }\n}" }, "url": { "raw": "{{baseUrl}}/genai/v1beta/models/azure/text-embedding-ada-002:embedContent", "host": ["{{baseUrl}}"], "path": ["genai", "v1beta", "models", "azure", "text-embedding-ada-002:embedContent"] } } } ] } ] @@ -857,6 +859,216 @@ { "name": "8.6.4.A native chat → gemini/gemini-2.5-flash (reasoning_effort translated)", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"gemini/gemini-2.5-flash\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"What is 17 * 23? Think step by step.\" }],\n \"reasoning_effort\": \"low\"\n}" }, "url": { "raw": "{{baseUrl}}/v1/chat/completions", "host": ["{{baseUrl}}"], "path": ["v1", "chat", "completions"] } } }, { "name": "8.6.4.E /anthropic/v1/messages → openai/gpt-5 (thinking translated)", "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], "body": { "mode": "raw", "raw": "{\n \"model\": \"openai/gpt-5\",\n \"max_tokens\": 2048,\n \"messages\": [{ \"role\": \"user\", \"content\": \"What is 17 * 23? Think step by step.\" }],\n \"thinking\": { \"type\": \"enabled\", \"budget_tokens\": 1024 }\n}" }, "url": { "raw": "{{baseUrl}}/anthropic/v1/messages", "host": ["{{baseUrl}}"], "path": ["anthropic", "v1", "messages"] } } } ] + }, + { + "name": "8.6.5 xAI x_search (server-side tool)", + "description": "xAI-native x_search tool tests via the native /v1/responses endpoint.\n\nThe x_search tool is a server-side search tool exclusive to xAI (grok models). When invoked the model internally calls x_semantic_search and/or x_keyword_search, returning custom_tool_call output items. The final message output_text content block carries url_citation annotations pointing at the source tweets/posts.\n\nDocs: https://docs.x.ai/developers/tools/x-search\n\nCovered here:\n 8.6.5.A – basic x_search, no extra params → verifies custom_tool_call items + final message\n 8.6.5.B – x_search with allowed_x_handles filter → tool_choice=required, verifies sub-tool name\n 8.6.5.C – x_search with from_date/to_date → date-range filtering passes through\n 8.6.5.D – x_search with all optional params (exact bug-report repro)\n 8.6.5.E – x_search streaming → custom_tool_call_input events flow through the SSE stream\n 8.6.5.F – x_search with url_citation annotations → verifies annotations array in output_text block", + "item": [ + { + "name": "8.6.5.A x_search basic (no params)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('x_search: output contains at least one custom_tool_call', function () {", + " var j = pm.response.json();", + " var calls = (j.output || []).filter(function(o) { return o.type === 'custom_tool_call'; });", + " pm.expect(calls.length, 'expected ≥1 custom_tool_call in output').to.be.above(0);", + " calls.forEach(function(c) {", + " pm.expect(c.name, 'sub-tool name must start with x_').to.match(/^x_/);", + " });", + "});", + "pm.test('x_search: output contains a final message with text', function () {", + " var j = pm.response.json();", + " var msg = (j.output || []).find(function(o) { return o.type === 'message'; });", + " pm.expect(msg, 'expected a message item in output').to.exist;", + " var hasText = (msg.content || []).some(function(c) { return c.type === 'output_text' && c.text && c.text.length > 0; });", + " pm.expect(hasText, 'message should contain non-empty output_text').to.be.true;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"xai/grok-4-0709\",\n \"input\": \"What are people saying about artificial intelligence on X today?\",\n \"tools\": [{ \"type\": \"x_search\" }],\n \"max_output_tokens\": 500\n}" + }, + "url": { "raw": "{{baseUrl}}/v1/responses", "host": ["{{baseUrl}}"], "path": ["v1", "responses"] } + } + }, + { + "name": "8.6.5.B x_search with allowed_x_handles (tool_choice=required)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('x_search handles: at least one custom_tool_call present', function () {", + " var j = pm.response.json();", + " var calls = (j.output || []).filter(function(o) { return o.type === 'custom_tool_call'; });", + " pm.expect(calls.length, 'tool_choice=required must produce ≥1 custom_tool_call').to.be.above(0);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"xai/grok-4-0709\",\n \"input\": \"What has xAI been posting about recently?\",\n \"tools\": [{ \"type\": \"x_search\", \"allowed_x_handles\": [\"xai\", \"grok\"] }],\n \"tool_choice\": \"required\",\n \"max_output_tokens\": 500\n}" + }, + "url": { "raw": "{{baseUrl}}/v1/responses", "host": ["{{baseUrl}}"], "path": ["v1", "responses"] } + } + }, + { + "name": "8.6.5.C x_search with from_date/to_date", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('x_search date range: custom_tool_call present', function () {", + " var j = pm.response.json();", + " var calls = (j.output || []).filter(function(o) { return o.type === 'custom_tool_call'; });", + " pm.expect(calls.length, 'expected ≥1 custom_tool_call').to.be.above(0);", + "});", + "pm.test('x_search date range: final message has content', function () {", + " var j = pm.response.json();", + " var msg = (j.output || []).find(function(o) { return o.type === 'message'; });", + " pm.expect(msg, 'expected a message item').to.exist;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"xai/grok-4-0709\",\n \"input\": \"What were people saying about machine learning on X recently?\",\n \"tools\": [{ \"type\": \"x_search\", \"from_date\": \"2025-12-01\", \"to_date\": \"2025-12-15\" }],\n \"max_output_tokens\": 500\n}" + }, + "url": { "raw": "{{baseUrl}}/v1/responses", "host": ["{{baseUrl}}"], "path": ["v1", "responses"] } + } + }, + { + "name": "8.6.5.D x_search all optional params (bug-report repro)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('x_search all params: tool present in request and custom_tool_call in output', function () {", + " var j = pm.response.json();", + " pm.expect(j.output, 'output should be a non-empty array').to.be.an('array').with.length.above(0);", + " var calls = (j.output || []).filter(function(o) { return o.type === 'custom_tool_call'; });", + " pm.expect(calls.length, 'tool_choice=required must yield ≥1 custom_tool_call').to.be.above(0);", + "});", + "pm.test('x_search all params: usage reports x_search_calls > 0', function () {", + " var j = pm.response.json();", + " pm.expect(j.usage, 'response must include usage').to.exist;", + " if (j.usage.server_side_tool_usage_details) {", + " pm.expect(j.usage.server_side_tool_usage_details.x_search_calls, 'x_search_calls should be > 0').to.be.above(0);", + " }", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"xai/grok-4-0709\",\n \"input\": \"Find recent tweets about artificial intelligence developments.\",\n \"tools\": [{\n \"type\": \"x_search\",\n \"allowed_x_handles\": [\"xai\", \"openai\", \"GoogleAI\"],\n \"from_date\": \"2025-12-10\",\n \"to_date\": \"2025-12-15\",\n \"enable_image_understanding\": false,\n \"enable_video_understanding\": false\n }],\n \"tool_choice\": \"required\",\n \"max_output_tokens\": 500\n}" + }, + "url": { "raw": "{{baseUrl}}/v1/responses", "host": ["{{baseUrl}}"], "path": ["v1", "responses"] } + } + }, + { + "name": "8.6.5.E x_search streaming", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Streaming responses return text/event-stream — the collection-level content check skips those.", + "// We verify only that Bifrost emits a 200 and the SSE prefix.", + "pm.test('x_search stream: 200 with SSE content-type', function () {", + " pm.expect(pm.response.code).to.equal(200);", + " var ct = pm.response.headers.get('content-type') || '';", + " pm.expect(ct, 'expected text/event-stream').to.include('text/event-stream');", + "});", + "pm.test('x_search stream: body includes x_search tool event', function () {", + " var body = pm.response.text() || '';", + " var hasToolEvent = body.indexOf('custom_tool_call_input') !== -1 || body.indexOf('\"type\":\"custom_tool_call\"') !== -1;", + " pm.expect(hasToolEvent, 'expected x_search tool event (custom_tool_call_input or custom_tool_call) in SSE body').to.be.true;", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"xai/grok-4-0709\",\n \"input\": \"What are people saying about xAI on X?\",\n \"tools\": [{ \"type\": \"x_search\" }],\n \"tool_choice\": \"required\",\n \"max_output_tokens\": 500,\n \"stream\": true\n}" + }, + "url": { "raw": "{{baseUrl}}/v1/responses", "host": ["{{baseUrl}}"], "path": ["v1", "responses"] } + } + }, + { + "name": "8.6.5.F x_search url_citation annotations", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// x_search grounds its answer in real X posts and surfaces them as url_citation", + "// annotations on the output_text content block. Verify the annotation array is", + "// present and each entry has the required url/start_index/end_index fields.", + "pm.test('x_search citations: output_text block has url_citation annotations', function () {", + " var j = pm.response.json();", + " var msg = (j.output || []).find(function(o) { return o.type === 'message'; });", + " pm.expect(msg, 'output must contain a message item').to.exist;", + " var textBlock = (msg.content || []).find(function(c) { return c.type === 'output_text'; });", + " pm.expect(textBlock, 'message must have an output_text block').to.exist;", + " pm.expect(textBlock.annotations, 'output_text must have annotations array').to.be.an('array').with.length.above(0);", + " textBlock.annotations.forEach(function(a) {", + " pm.expect(a.type, 'annotation type must be url_citation').to.equal('url_citation');", + " pm.expect(a.url, 'url_citation must have a url').to.be.a('string').with.length.above(0);", + " pm.expect(a).to.have.property('start_index');", + " pm.expect(a).to.have.property('end_index');", + " });", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"xai/grok-4-0709\",\n \"input\": \"What are people saying about xAI on X? Summarise with sources.\",\n \"tools\": [{ \"type\": \"x_search\" }],\n \"tool_choice\": \"required\",\n \"max_output_tokens\": 600\n}" + }, + "url": { "raw": "{{baseUrl}}/v1/responses", "host": ["{{baseUrl}}"], "path": ["v1", "responses"] } + } + } + ] } ] } diff --git a/tests/integrations/python/tests/test_openai.py b/tests/integrations/python/tests/test_openai.py index cd01bc8a4f9..e7eef3b8076 100644 --- a/tests/integrations/python/tests/test_openai.py +++ b/tests/integrations/python/tests/test_openai.py @@ -76,6 +76,13 @@ 64. Realtime client secret HTTP API - raw routes 65. Realtime client secret HTTP API - OpenAI constructor base_url compatibility 66. Realtime client secret HTTP API - unsupported provider +xAI x_search tool tests (xAI-only): +- xai_x_search_basic: x_search with no params, non-streaming +- xai_x_search_with_handles: x_search with allowed_x_handles, non-streaming +- xai_x_search_with_date_range: x_search with from_date/to_date, non-streaming +- xai_x_search_all_params: x_search with all optional params, non-streaming +- xai_x_search_streaming: x_search streaming, no params +- xai_x_search_streaming_with_params: x_search streaming with handles + date range Batch API uses OpenAI SDK with x-model-provider header to route to different providers. """ @@ -83,6 +90,7 @@ import json import os import time +from datetime import datetime, timedelta from typing import Any from urllib.parse import quote @@ -2505,6 +2513,234 @@ def test_38a_responses_reasoning_streaming_with_summary( print(f"Success: Reasoning streaming with summary completed ({chunk_count} chunks)") + # ========================================================================= + # XAI x_search TOOL TEST CASES + # Tested via the OpenAI integration — model "xai/" routes through + # Bifrost to xAI's API. The openai_client fixture hits Bifrost's /openai + # endpoint; the "xai/" prefix in the model name selects the xAI provider. + # ========================================================================= + + @skip_if_no_api_key("xai") + def test_xai_x_search_basic(self, openai_client, test_config): + """xAI x_search: non-streaming, no extra params — verifies custom_tool_call items appear.""" + model = format_provider_model("xai", get_config().get_provider_model("xai", "chat")) + + response = openai_client.responses.create( + model=model, + input="What are people saying about artificial intelligence on X today?", + tools=[{"type": "x_search"}], + max_output_tokens=500, + ) + + assert response is not None, "Response should not be None" + assert hasattr(response, "output") and len(response.output) > 0, "Output should not be empty" + + x_search_calls = [ + item for item in response.output + if getattr(item, "type", None) == "custom_tool_call" + ] + assert len(x_search_calls) > 0, ( + f"Response should contain at least one custom_tool_call (x_semantic_search / " + f"x_keyword_search). Output types: {[getattr(i, 'type', None) for i in response.output]}" + ) + + for call in x_search_calls: + assert getattr(call, "name", None) in {"x_semantic_search", "x_keyword_search"}, ( + f"Unexpected custom_tool_call name: {getattr(call, 'name', None)}" + ) + + message_content = "" + for item in response.output: + if getattr(item, "type", None) == "message" and hasattr(item, "content"): + for block in (item.content if isinstance(item.content, list) else []): + if hasattr(block, "text") and block.text: + message_content += block.text + + assert len(message_content) > 20, ( + f"Message content should be non-trivial. Got: {message_content!r}" + ) + + @skip_if_no_api_key("xai") + def test_xai_x_search_with_handles(self, openai_client, test_config): + """xAI x_search: non-streaming, restricted to specific X handles via allowed_x_handles.""" + model = format_provider_model("xai", get_config().get_provider_model("xai", "chat")) + + response = openai_client.responses.create( + model=model, + input="What has xAI been posting about recently?", + tools=[ + { + "type": "x_search", + "allowed_x_handles": ["xai", "grok"], + } + ], + tool_choice="required", + max_output_tokens=500, + ) + + assert response is not None + assert len(response.output) > 0 + + x_search_calls = [ + item for item in response.output + if getattr(item, "type", None) == "custom_tool_call" + ] + assert len(x_search_calls) > 0, ( + "Response should contain at least one x_search call when tool_choice=required" + ) + + @skip_if_no_api_key("xai") + def test_xai_x_search_with_date_range(self, openai_client, test_config): + """xAI x_search: non-streaming, with from_date/to_date filters.""" + model = format_provider_model("xai", get_config().get_provider_model("xai", "chat")) + _today = datetime.now().date() + _from_date = ((_today - timedelta(days=30)).isoformat()) + _to_date = ((_today - timedelta(days=15)).isoformat()) + + response = openai_client.responses.create( + model=model, + input="What were people saying about machine learning on X recently?", + tools=[ + { + "type": "x_search", + "from_date": _from_date, + "to_date": _to_date, + } + ], + max_output_tokens=500, + ) + + assert response is not None + assert len(response.output) > 0 + + x_search_calls = [ + item for item in response.output + if getattr(item, "type", None) == "custom_tool_call" + ] + assert len(x_search_calls) > 0, "Response should contain x_search calls" + + message_content = "" + for item in response.output: + if getattr(item, "type", None) == "message" and hasattr(item, "content"): + for block in (item.content if isinstance(item.content, list) else []): + if hasattr(block, "text") and block.text: + message_content += block.text + + assert len(message_content) > 20, f"Expected text content, got: {message_content!r}" + + @skip_if_no_api_key("xai") + def test_xai_x_search_all_params(self, openai_client, test_config): + """xAI x_search: non-streaming, all optional parameters (exact repro of the bug report).""" + model = format_provider_model("xai", get_config().get_provider_model("xai", "chat")) + _today = datetime.now().date() + _from_date = ((_today - timedelta(days=30)).isoformat()) + _to_date = ((_today - timedelta(days=15)).isoformat()) + + response = openai_client.responses.create( + model=model, + input="Find recent tweets about artificial intelligence developments.", + tools=[ + { + "type": "x_search", + "allowed_x_handles": ["xai", "openai", "GoogleAI"], + "from_date": _from_date, + "to_date": _to_date, + "enable_image_understanding": False, + "enable_video_understanding": False, + } + ], + tool_choice="required", + max_output_tokens=500, + ) + + assert response is not None + assert len(response.output) > 0, "Output should not be empty" + + x_search_calls = [ + item for item in response.output + if getattr(item, "type", None) == "custom_tool_call" + ] + assert len(x_search_calls) > 0, ( + "tool_choice=required with x_search must produce at least one custom_tool_call" + ) + + if hasattr(response, "usage") and response.usage is not None: + if hasattr(response.usage, "total_tokens"): + assert response.usage.total_tokens > 0, "Token usage should be reported" + + @skip_if_no_api_key("xai") + def test_xai_x_search_streaming(self, openai_client, test_config): + """xAI x_search: streaming, no extra params — verifies custom_tool_call events flow through.""" + model = format_provider_model("xai", get_config().get_provider_model("xai", "chat")) + + stream = openai_client.responses.create( + model=model, + input="What are people saying about xAI on X?", + tools=[{"type": "x_search"}], + max_output_tokens=500, + stream=True, + ) + + content, chunk_count, _tool_calls_detected, event_types = ( + collect_responses_streaming_content(stream, timeout=300) + ) + + assert chunk_count > 0, "Should receive at least one streaming chunk" + assert len(content) > 10, f"Should receive substantive text content. Got: {content!r}" + + has_x_search_events = ( + event_types.get("response.custom_tool_call_input.delta", 0) > 0 + or event_types.get("response.custom_tool_call_input.done", 0) > 0 + or any("custom_tool_call" in evt for evt in event_types) + ) + has_output_item_events = event_types.get("response.output_item.added", 0) > 0 + + assert has_x_search_events or has_output_item_events, ( + f"Expected x_search-related streaming events. Got: {list(event_types.keys())}" + ) + + @skip_if_no_api_key("xai") + def test_xai_x_search_streaming_with_params(self, openai_client, test_config): + """xAI x_search: streaming with allowed_x_handles, date range, and tool_choice=required.""" + model = format_provider_model("xai", get_config().get_provider_model("xai", "chat")) + _today = datetime.now().date() + _from_date = ((_today - timedelta(days=30)).isoformat()) + _to_date = ((_today - timedelta(days=15)).isoformat()) + + stream = openai_client.responses.create( + model=model, + input="What has the xAI team been posting about recently?", + tools=[ + { + "type": "x_search", + "allowed_x_handles": ["xai", "grok"], + "from_date": _from_date, + "to_date": _to_date, + "enable_image_understanding": False, + "enable_video_understanding": False, + } + ], + tool_choice="required", + max_output_tokens=500, + stream=True, + ) + + content, chunk_count, _tool_calls_detected, event_types = ( + collect_responses_streaming_content(stream, timeout=300) + ) + + assert chunk_count > 0, "Should receive at least one chunk" + + has_output_events = any( + "output_item" in evt or "output_text" in evt or "custom_tool_call" in evt + for evt in event_types + ) + assert has_output_events, ( + f"Expected output-related events in stream. Got: {list(event_types.keys())}" + ) + + assert len(content) > 0, "Streaming response should contain content from x_search results" + # ========================================================================= # TEXT COMPLETIONS API TEST CASES # ========================================================================= diff --git a/tests/integrations/typescript/package-lock.json b/tests/integrations/typescript/package-lock.json index c254ff97d6a..f589d73ebd5 100644 --- a/tests/integrations/typescript/package-lock.json +++ b/tests/integrations/typescript/package-lock.json @@ -8,48 +8,34 @@ "name": "bifrost-integration-tests-typescript", "version": "0.1.0", "dependencies": { - "@anthropic-ai/sdk": "^0.71.2", - "@aws-sdk/client-bedrock": "^3.966.0", - "@aws-sdk/client-bedrock-runtime": "^3.965.0", - "@google/generative-ai": "^0.24.1", - "@langchain/anthropic": "^1.3.26", - "@langchain/core": "^1.1.39", - "@langchain/google-genai": "^2.1.26", - "@langchain/openai": "^1.4.4", + "@anthropic-ai/sdk": "0.71.2", + "@aws-sdk/client-bedrock": "3.1015.0", + "@aws-sdk/client-bedrock-runtime": "3.1015.0", + "@google/generative-ai": "0.24.1", + "@langchain/anthropic": "1.3.26", + "@langchain/core": "1.1.39", + "@langchain/google-genai": "2.1.26", + "@langchain/openai": "1.4.4", "langsmith": "0.6.0", - "openai": "^6.15.0", - "yaml": "^2.6.0", - "zod": "^3.24.0" + "openai": "6.15.0", + "yaml": "2.9.0", + "zod": "3.25.0" }, "devDependencies": { - "@types/node": "^22.10.0", - "@typescript-eslint/eslint-plugin": "^8.0.0", - "@typescript-eslint/parser": "^8.0.0", - "@vitest/coverage-v8": "^2.1.0", - "@vitest/ui": "^2.1.0", - "dotenv": "^16.4.0", - "eslint": "^9.0.0", - "typescript": "^5.7.0", - "vitest": "^2.1.0" + "@types/node": "22.10.0", + "@typescript-eslint/eslint-plugin": "8.0.0", + "@typescript-eslint/parser": "8.0.0", + "@vitest/coverage-v8": "4.1.6", + "@vitest/ui": "4.1.6", + "dotenv": "16.4.0", + "eslint": "9.0.0", + "typescript": "5.7.3", + "vitest": "4.1.6" }, "engines": { "node": ">=25.0.0" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@anthropic-ai/sdk": { "version": "0.71.2", "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz", @@ -99,31 +85,6 @@ "tslib": "^2.6.2" } }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", @@ -171,31 +132,6 @@ "tslib": "^2.6.2" } }, - "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", @@ -210,49 +146,49 @@ } }, "node_modules/@aws-sdk/client-bedrock": { - "version": "3.1029.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock/-/client-bedrock-3.1029.0.tgz", - "integrity": "sha512-Z7YJTonT4lCbkU8ViH+A2DNzQjei+9+gGCOQjk/WVZwjGBc+riw+ExSlG+dQ0Juor9MrqtcwjWzHvQ37l3ZZ/Q==", + "version": "3.1015.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock/-/client-bedrock-3.1015.0.tgz", + "integrity": "sha512-YTmbADMdEP4bWiGi1EZ5/5CNuzo2ZhLucJge4GZ5blOnd3qlAsQ6au1+1A/hmAlYOM4tg+z6m40WHncGd6Tatw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/token-providers": "3.1029.0", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@aws-sdk/core": "^3.973.24", + "@aws-sdk/credential-provider-node": "^3.972.25", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.8", + "@aws-sdk/middleware-user-agent": "^3.972.25", + "@aws-sdk/region-config-resolver": "^3.972.9", + "@aws-sdk/token-providers": "3.1015.0", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.11", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.12", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.27", + "@smithy/middleware-retry": "^4.4.44", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@smithy/util-defaults-mode-browser": "^4.3.43", + "@smithy/util-defaults-mode-node": "^4.2.47", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -261,56 +197,56 @@ } }, "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1029.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1029.0.tgz", - "integrity": "sha512-LFmNV+rLPXS87vdQBfNOmhlo+3T+t07tvyEmHeGec8jUAbOFckKbU7TTy7ePe9xVYOXQYcLw+pwslJ/VZvxDkw==", + "version": "3.1015.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1015.0.tgz", + "integrity": "sha512-At8Ex5NXj3xjDHCyu/savuu8RTTbLBohaZGvll6DIi0kOXmI/P6T0+oKkRnHFOeKmlNWkeHD2z4vEjNnSMgonw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/eventstream-handler-node": "^3.972.13", - "@aws-sdk/middleware-eventstream": "^3.972.9", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/middleware-websocket": "^3.972.15", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/token-providers": "3.1029.0", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/eventstream-serde-browser": "^4.2.13", - "@smithy/eventstream-serde-config-resolver": "^4.3.13", - "@smithy/eventstream-serde-node": "^4.2.13", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@aws-sdk/core": "^3.973.24", + "@aws-sdk/credential-provider-node": "^3.972.25", + "@aws-sdk/eventstream-handler-node": "^3.972.11", + "@aws-sdk/middleware-eventstream": "^3.972.8", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.8", + "@aws-sdk/middleware-user-agent": "^3.972.25", + "@aws-sdk/middleware-websocket": "^3.972.13", + "@aws-sdk/region-config-resolver": "^3.972.9", + "@aws-sdk/token-providers": "3.1015.0", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.11", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.12", + "@smithy/eventstream-serde-browser": "^4.2.12", + "@smithy/eventstream-serde-config-resolver": "^4.3.12", + "@smithy/eventstream-serde-node": "^4.2.12", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.27", + "@smithy/middleware-retry": "^4.4.44", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", - "@smithy/util-stream": "^4.5.22", + "@smithy/util-defaults-mode-browser": "^4.3.43", + "@smithy/util-defaults-mode-node": "^4.2.47", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-stream": "^4.5.20", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -319,23 +255,18 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.973.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.27.tgz", - "integrity": "sha512-CUZ5m8hwMCH6OYI4Li/WgMfIEx10Q2PLI9Y3XOUTPGZJ53aZ0007jCv+X/ywsaERyKPdw5MRZWk877roQksQ4A==", + "version": "3.974.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.13.tgz", + "integrity": "sha512-+Y5/4tHki0uYgyx8eun146DegRVQBpdKGK5RbV0FTKJPpaKTchvqVxrrRFK6Wk0JksO4iAZKw3eqxGEIwtO98w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/xml-builder": "^3.972.17", - "@smithy/core": "^3.23.14", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/signature-v4": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-utf8": "^4.2.2", + "@aws-sdk/types": "^3.973.9", + "@aws-sdk/xml-builder": "^3.972.25", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.3", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.2", + "bowser": "^2.11.0", "tslib": "^2.6.2" }, "engines": { @@ -343,15 +274,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.25", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.25.tgz", - "integrity": "sha512-6QfI0wv4jpG5CrdO/AO0JfZ2ux+tKwJPrUwmvxXF50vI5KIypKVGNF6b4vlkYEnKumDTI1NX2zUBi8JoU5QU3A==", + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.39.tgz", + "integrity": "sha512-29wX9zpAvEt1vcj0psha+y6ygBHy2V/S72mp6e7q0KARLWXq+pwE/lR6qGkwknQvruh52lXvlqZIga8Hdxkucw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -359,20 +290,17 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.27.tgz", - "integrity": "sha512-3V3Usj9Gs93h865DqN4M2NWJhC5kXU9BvZskfN3+69omuYlE3TZxOEcVQtBGLOloJB7BVfJKXVLqeNhOzHqSlQ==", + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.41.tgz", + "integrity": "sha512-IA3CQTjtJkb6u1H4mE4936c8OPBMa9Jggtwe8U2Mqw/vvb/tZ5Ebd0mcZcX0uKWQhOyYo/+qNIwkV5Xh+FeJJA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/types": "^3.973.7", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/property-provider": "^4.2.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/util-stream": "^4.5.22", + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/fetch-http-handler": "^5.4.3", + "@smithy/node-http-handler": "^4.7.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -380,24 +308,23 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.29.tgz", - "integrity": "sha512-SiBuAnXecCbT/OpAf3vqyI/AVE3mTaYr9ShXLybxZiPLBiPCCOIWSGAtYYGQWMRvobBTiqOewaB+wcgMMZI2Aw==", + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.43.tgz", + "integrity": "sha512-4mzII+3mZEVXXE1xzrLQrCJL7/r62A63bA6SVzZoNL5rqCJghpf+xgGltVrIBBs0n+mOZBKrQl2tRREtvZ5l6A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/credential-provider-env": "^3.972.25", - "@aws-sdk/credential-provider-http": "^3.972.27", - "@aws-sdk/credential-provider-login": "^3.972.29", - "@aws-sdk/credential-provider-process": "^3.972.25", - "@aws-sdk/credential-provider-sso": "^3.972.29", - "@aws-sdk/credential-provider-web-identity": "^3.972.29", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/types": "^3.973.7", - "@smithy/credential-provider-imds": "^4.2.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/credential-provider-env": "^3.972.39", + "@aws-sdk/credential-provider-http": "^3.972.41", + "@aws-sdk/credential-provider-login": "^3.972.43", + "@aws-sdk/credential-provider-process": "^3.972.39", + "@aws-sdk/credential-provider-sso": "^3.972.43", + "@aws-sdk/credential-provider-web-identity": "^3.972.43", + "@aws-sdk/nested-clients": "^3.997.11", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -405,18 +332,16 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.29.tgz", - "integrity": "sha512-OGOslTbOlxXexKMqhxCEbBQbUIfuhGxU5UXw3Fm56ypXHvrXH4aTt/xb5Y884LOoteP1QST1lVZzHfcTnWhiPQ==", + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.43.tgz", + "integrity": "sha512-HG7kQCwXtbv3oBV61Ins0oNX8KKyvrMqqRkb6ZiAfQHbMuHaiNaEb2KnpKLPkNpqImSBK82UkVE/kaY6IfWikA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/nested-clients": "^3.997.11", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -424,22 +349,21 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.30", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.30.tgz", - "integrity": "sha512-FMnAnWxc8PG+ZrZ2OBKzY4luCUJhe9CG0B9YwYr4pzrYGLXBS2rl+UoUvjGbAwiptxRL6hyA3lFn03Bv1TLqTw==", + "version": "3.972.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.44.tgz", + "integrity": "sha512-sDaBIT0yrNNIPfvlsiTCmANm07zKju+ipWODjEXgZlsjMeIJR3LVp7RDyAOzUoAsTbDfYKDWp+i5WrFiQP6rmQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.25", - "@aws-sdk/credential-provider-http": "^3.972.27", - "@aws-sdk/credential-provider-ini": "^3.972.29", - "@aws-sdk/credential-provider-process": "^3.972.25", - "@aws-sdk/credential-provider-sso": "^3.972.29", - "@aws-sdk/credential-provider-web-identity": "^3.972.29", - "@aws-sdk/types": "^3.973.7", - "@smithy/credential-provider-imds": "^4.2.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/credential-provider-env": "^3.972.39", + "@aws-sdk/credential-provider-http": "^3.972.41", + "@aws-sdk/credential-provider-ini": "^3.972.43", + "@aws-sdk/credential-provider-process": "^3.972.39", + "@aws-sdk/credential-provider-sso": "^3.972.43", + "@aws-sdk/credential-provider-web-identity": "^3.972.43", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -447,16 +371,15 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.25", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.25.tgz", - "integrity": "sha512-HR7ynNRdNhNsdVCOCegy1HsfsRzozCOPtD3RzzT1JouuaHobWyRfJzCBue/3jP7gECHt+kQyZUvwg/cYLWurNQ==", + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.39.tgz", + "integrity": "sha512-2k/amBifLd75eXNwgvPw/2lKYSQ3NhvHQgkVKVjfUq13/eJ3JRtHmznuFenn74OK3sSfp4SMy1YB2w+UVXoKqA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -464,18 +387,17 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.29.tgz", - "integrity": "sha512-HWv4SEq3jZDYPlwryZVef97+U8CxxRos5mK8sgGO1dQaFZpV5giZLzqGE5hkDmh2csYcBO2uf5XHjPTpZcJlig==", + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.43.tgz", + "integrity": "sha512-LPc3+Y4vhH1T4x6CMqwCM6hk5+SRf/Lwmgm8INm95wxTtIRHcMwQUVkDzWu4Iw/RSncxYM2BC01OrYbxOPZvyg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/token-providers": "3.1026.0", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/nested-clients": "^3.997.11", + "@aws-sdk/token-providers": "3.1052.0", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -483,17 +405,16 @@ } }, "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { - "version": "3.1026.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1026.0.tgz", - "integrity": "sha512-Ieq/HiRrbEtrYP387Nes0XlR7H1pJiJOZKv+QyQzMYpvTiDs0VKy2ZB3E2Zf+aFovWmeE7lRE4lXyF7dYM6GgA==", + "version": "3.1052.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1052.0.tgz", + "integrity": "sha512-QqZNB3so7UIDxZtroc85TQaLVxdZRFm0eWM1CSR2N+b06as9TOrilvrlTZuj3guYlxMs6yLOgGxnklJ5qMYtTw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/nested-clients": "^3.997.11", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -501,17 +422,16 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.29.tgz", - "integrity": "sha512-PdMBza1WEKEUPFEmMGCfnU2RYCz9MskU2e8JxjyUOsMKku7j9YaDKvbDi2dzC0ihFoM6ods2SbhfAAro+Gwlew==", + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.43.tgz", + "integrity": "sha512-wQtL34lUD/09VXjwAUo2T+I3aEXRDxMB3DKmTJL/Zj0Gi6sLDTrVhae1XVt01yzkquOWajI/sZW72JGDZ1ciTw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/nested-clients": "^3.997.11", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -519,14 +439,14 @@ } }, "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.13.tgz", - "integrity": "sha512-2Pi1kD0MDkMAxDHqvpi/hKMs9hXUYbj2GLEjCwy+0jzfLChAsF50SUYnOeTI+RztA+Ic4pnLAdB03f1e8nggxQ==", + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.17.tgz", + "integrity": "sha512-WFwdNcjchKZr7jKYgGimUZO8sSKQF/le7GGqgeCzz/lHozInE6b0gFJ1YMr8NaIeAoWJwgtrF7RE4/qMgosAdQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/eventstream-codec": "^4.2.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -534,14 +454,14 @@ } }, "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.9.tgz", - "integrity": "sha512-ypgOvpWxQTCnQyDHGxnTviqqANE7FIIzII7VczJnTPCJcJlu17hMQXnvE47aKSKsawVJAaaRsyOEbHQuLJF9ng==", + "version": "3.972.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.13.tgz", + "integrity": "sha512-ECfsw7mf6G/sxNbKbGE3/h1xeIArY/yRI1IjDGYkLgDIankh+aDOtDRSr40LVlIHGL9+jEH1cVuxmbJ8NLL/1A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -549,14 +469,12 @@ } }, "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.9.tgz", - "integrity": "sha512-je5vRdNw4SkuTnmRbFZLdye4sQ0faLt8kwka5wnnSU30q1mHO4X+idGEJOOE+Tn1ME7Oryn05xxkDvIb3UaLaQ==", + "version": "3.972.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.14.tgz", + "integrity": "sha512-Q1wVLhOwOiifMJt12IK/reHZpGERbeom8QirjX4JxfxYYqhSjBR50JSZAXhrheI1pSYkL5wLGXJLUMJLdyS75g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.13", "tslib": "^2.6.2" }, "engines": { @@ -564,13 +482,12 @@ } }, "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.9.tgz", - "integrity": "sha512-HsVgDrruhqI28RkaXALm8grJ7Agc1wF6Et0xh6pom8NdO2VdO/SD9U/tPwUjewwK/pVoka+EShBxyCvgsPCtog==", + "version": "3.972.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.13.tgz", + "integrity": "sha512-uvoAP8dpzA2tAYek8fKaP9iGOYmrnZzWPlWAAs74gQdF0YbixpXE1ZOSClKq4PB5VADiVIIB43Vjc5rdOrw10A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.13", "tslib": "^2.6.2" }, "engines": { @@ -578,15 +495,12 @@ } }, "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.10.tgz", - "integrity": "sha512-RVQQbq5orQ/GHUnXvqEOj2HHPBJm+mM+ySwZKS5UaLBwra5ugRtiH09PLUoOZRl7a1YzaOzXSuGbn9iD5j60WQ==", + "version": "3.972.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.15.tgz", + "integrity": "sha512-VDMUHLeQ/yTr658HMm2eWS7e6qIFSxUeVbSA5zh4SNfSQ7ygIkJ0WeBoCnefw00Nsr5wNT8FIiAknJRTt058iw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.13", "tslib": "^2.6.2" }, "engines": { @@ -594,18 +508,12 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.29.tgz", - "integrity": "sha512-f/sIRzuTfEjg6NsbMYvye2VsmnQoNgntntleQyx5uGacUYzszbfIlO3GcI6G6daWUmTm0IDZc11qMHWwF0o0mQ==", + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.43.tgz", + "integrity": "sha512-zXD7MSFgaxGi2CeURo9ZWKLNyXtfBUF0ByCHu4fdWa3WMu3YI8L0Mv3JHEDYBbYrUBPWBqSYhaXotxq0ADAGJg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@smithy/core": "^3.23.14", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", - "@smithy/util-retry": "^4.3.0", + "@aws-sdk/core": "^3.974.13", "tslib": "^2.6.2" }, "engines": { @@ -613,22 +521,17 @@ } }, "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.15.tgz", - "integrity": "sha512-hsZ35FORQsN5hwNdMD6zWmHCphbXkDxO6j+xwCUiuMb0O6gzS/PWgttQNl1OAn7h/uqZAMUG4yOS0wY/yhAieg==", + "version": "3.972.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.21.tgz", + "integrity": "sha512-yr+5+C7v9R55sAJ89A55Wrm7wIKPVn5cm6J3Hztnd5s/iwEUKxyJqCnIxJu4fVXgG9XBQD1Jc4rsWC1ozahJjA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-format-url": "^3.972.9", - "@smithy/eventstream-codec": "^4.2.13", - "@smithy/eventstream-serde-browser": "^4.2.13", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/protocol-http": "^5.3.13", - "@smithy/signature-v4": "^5.3.13", - "@smithy/types": "^4.14.0", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/fetch-http-handler": "^5.4.3", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -636,48 +539,20 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.996.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.19.tgz", - "integrity": "sha512-uFkmCDXvmQYLanlYdOFS0+MQWkrj9wPMt/ZCc/0J0fjPim6F5jBVBmEomvGY/j77ILW6GTPwN22Jc174Mhkw6Q==", + "version": "3.997.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.11.tgz", + "integrity": "sha512-nWXXJ1r/r8N2Gw1pWolRgED38/A9A8DHR2ETWIv220zh4PZHcybbR4hUVWWktmNXTRHzDJwRluapHn0rZxuoqA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", - "@smithy/util-utf8": "^4.2.2", + "@aws-sdk/core": "^3.974.13", + "@aws-sdk/signature-v4-multi-region": "^3.996.28", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/fetch-http-handler": "^5.4.3", + "@smithy/node-http-handler": "^4.7.3", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -685,77 +560,73 @@ } }, "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.11.tgz", - "integrity": "sha512-6Q8B1dcx6BBqUTY1Mc/eROKA0FImEEY5VPSd6AGPEUf0ErjExz4snVqa9kNJSoVDV1rKaNf3qrWojgcKW+SdDg==", + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.17.tgz", + "integrity": "sha512-Jz0mg/eqfChGZm0G4bzm6CpyEEtu9ThG1WHY3uE/hGjIUIXPsyVyTuQOetUsrU9QiYWMknmyIKIEHpZ69BHzrA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/config-resolver": "^4.4.14", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.13", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.1029.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1029.0.tgz", - "integrity": "sha512-oU3a9wEBUYHuWsoMpahiRIIQMUy2RSRb9NhlJ9DtKTwYWV2OXZ0hEM+RTjIC8T8I8v/C83OqbZrj7NBg1ATAhw==", + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.28.tgz", + "integrity": "sha512-qs9z5LqXO/CZC2Lg9SGKpoLU8Rhi+m2pFKZqfO9pytX1clc0katqtsDNupJxFy0xT9wsZSPzM2v1y+/H/zfp5Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/types": { - "version": "3.973.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.7.tgz", - "integrity": "sha512-reXRwoJ6CfChoqAsBszUYajAF8Z2LRE+CRcKocvFSMpIiLOtYU3aJ9trmn6VVPAzbbY5LXF+FfmUslbXk1SYFg==", + "node_modules/@aws-sdk/token-providers": { + "version": "3.1015.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1015.0.tgz", + "integrity": "sha512-3OSD4y110nisRhHzFOjoEeHU4GQL4KpzkX9PxzWaiZe0Yg2+thZKM0Pn9DjYwezH5JYfh/K++xK/SE0IHGrmCQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.973.24", + "@aws-sdk/nested-clients": "^3.996.14", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.6.tgz", - "integrity": "sha512-2nUQ+2ih7CShuKHpGSIYvvAIOHy52dOZguYG36zptBukhw6iFwcvGfG0tes0oZFWQqEWvgZe9HLWaNlvXGdOrg==", + "node_modules/@aws-sdk/types": { + "version": "3.973.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.9.tgz", + "integrity": "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", - "@smithy/util-endpoints": "^3.3.4", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/util-format-url": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.9.tgz", - "integrity": "sha512-fNJXHrs0ZT7Wx0KGIqKv7zLxlDXt2vqjx9z6oKUQFmpE5o4xxnSryvVHfHpIifYHWKz94hFccIldJ0YSZjlCBw==", + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.12.tgz", + "integrity": "sha512-6Y8t0HT3M5GNVyLCnEwfI4maKZ5ATWJlXqemCH56/DMfsWhhSmR26FFE6LPTBYbwlifwAkNxqE0YTyvUfoUhEg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/querystring-builder": "^4.2.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.13", + "@smithy/core": "^3.24.3", "tslib": "^2.6.2" }, "engines": { @@ -763,100 +634,55 @@ } }, "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.0.tgz", - "integrity": "sha512-9LJFand4bIoOjOF4x3wx0UZYiFZRo4oUauxQSiEX2dVg+5qeBOJSjp2SeWykIE6+6frCZ5wvWm2fGLK8D32aJw==", + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.9.tgz", - "integrity": "sha512-sn/LMzTbGjYqCCF24390WxPd6hkpoSptiUn5DzVp4cD71yqw+yGEGm1YCxyEoPXyc8qciM8UzLJcZBFslxo5Uw==", + "version": "3.972.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.14.tgz", + "integrity": "sha512-LiMxdKWw55ZJP4iABqeIuF3RPgWMa2Uc9ZXjZRXZywStvH1IlzU6t+dKzcjS5ZQpHsl2A0G8UXxuy1zDY93YTQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/types": "^4.14.0", - "bowser": "^2.11.0", + "@aws-sdk/core": "^3.974.13", "tslib": "^2.6.2" } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.15.tgz", - "integrity": "sha512-fYn3s9PtKdgQkczGZCFMgkNEe8aq1JCVbnRqjqN9RSVW43xn2RV9xdcZ3z01a48Jpkuh/xCmBKJxdLOo4Ozg7w==", + "version": "3.973.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.29.tgz", + "integrity": "sha512-sVUv711QtRMT8NYql9elQaAKCz8qopg+Y2Vf5ROLXeOqEWdYZp2g+9HBesTmLn48jDvI0i1khxPFKSwCjWaawA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/types": "^3.973.7", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/types": "^4.14.0", - "@smithy/util-config-provider": "^4.2.2", + "@aws-sdk/core": "^3.974.13", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.17", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.17.tgz", - "integrity": "sha512-Ra7hjqAZf1OXRRMueB13qex7mFJRDK/pgCvdSFemXBT8KCGnQDPoKzHY1SjN+TjJVmnpSF14W5tJ1vDamFu+Gg==", + "version": "3.972.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.25.tgz", + "integrity": "sha512-GH+Kjz4nPKWKHnsiQpnhP1MJdTGIcK4rAka6tzakgjjUkVgNsmPeEbbRAf09SzS1hjGu6duGHCBsxYke0BhHjQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", - "fast-xml-parser": "5.5.8", + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.2", + "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/xml-builder/node_modules/fast-xml-parser": { - "version": "5.5.8", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", - "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "fast-xml-builder": "^1.1.4", - "path-expression-matcher": "^1.2.0", - "strnum": "^2.2.0" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/@aws-sdk/xml-builder/node_modules/strnum": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", - "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, "node_modules/@aws/lambda-invoke-store": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", @@ -867,9 +693,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -877,9 +703,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -887,13 +713,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -903,34 +729,37 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/@cfworker/json-schema": { "version": "4.1.1", @@ -939,9 +768,9 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], @@ -952,13 +781,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -969,13 +798,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], @@ -986,13 +815,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], @@ -1003,13 +832,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -1020,13 +849,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -1037,13 +866,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -1054,13 +883,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -1071,13 +900,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -1088,13 +917,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -1105,13 +934,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], @@ -1122,13 +951,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], @@ -1139,13 +968,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -1156,13 +985,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], @@ -1173,13 +1002,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -1190,13 +1019,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -1207,13 +1036,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -1224,13 +1053,30 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -1241,13 +1087,30 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -1258,13 +1121,30 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -1275,13 +1155,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -1292,13 +1172,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -1309,13 +1189,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -1326,7 +1206,7 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -1358,25 +1238,34 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -1384,7 +1273,7 @@ "concat-map": "0.0.1" } }, - "node_modules/@eslint/config-array/node_modules/minimatch": { + "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", @@ -1397,60 +1286,45 @@ "node": "*" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "node_modules/@eslint/js": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.0.0.tgz", + "integrity": "sha512-RThY/MnKrhubF6+s1JflwUjPEsnCEmYCWwqa/aRISKWNXGZ9epUwft4bUMM35SdKF9xvBrLydAM1RDHd1Z//ZQ==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, + "node_modules/@google/generative-ai": { + "version": "0.24.1", + "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz", + "integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==", "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18.0.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "node_modules/@humanwhocodes/config-array": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.12.3.tgz", + "integrity": "sha512-jsNnTBlMWuTpDkeE3on7+dWJi0D6fdDfeANj/w7MpS8ztROCoLvIO2nG0CcFj+E4k8j4QrSTh4Oryi3i2G669g==", + "deprecated": "Use @eslint/config-array instead", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=10.10.0" } }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -1458,17 +1332,7 @@ "concat-map": "0.0.1" } }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", @@ -1481,76 +1345,6 @@ "node": "*" } }, - "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@google/generative-ai": { - "version": "0.24.1", - "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz", - "integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1565,58 +1359,13 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } + "license": "BSD-3-Clause" }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", @@ -1682,6 +1431,15 @@ } } }, + "node_modules/@langchain/anthropic/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@langchain/core": { "version": "1.1.39", "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.39.tgz", @@ -1704,6 +1462,15 @@ "node": ">=20" } }, + "node_modules/@langchain/core/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@langchain/google-genai": { "version": "2.1.26", "resolved": "https://registry.npmjs.org/@langchain/google-genai/-/google-genai-2.1.26.tgz", @@ -1737,15 +1504,84 @@ "@langchain/core": "^1.1.39" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "node_modules/@langchain/openai/node_modules/openai": { + "version": "6.39.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.39.0.tgz", + "integrity": "sha512-O61LIsimY3acVabwvomwFhwrnN36yvHY2quIfy9keEcFytGgWeV35yLHQ6NVMLSBxRpHmcg2yuhCnlu2HT4pLQ==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@langchain/openai/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, "license": "MIT", - "optional": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, "engines": { - "node": ">=14" + "node": ">= 8" } }, "node_modules/@polka/url": { @@ -1756,9 +1592,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", "cpu": [ "arm" ], @@ -1770,9 +1606,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", "cpu": [ "arm64" ], @@ -1784,9 +1620,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", "cpu": [ "arm64" ], @@ -1798,9 +1634,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", - "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", "cpu": [ "x64" ], @@ -1812,9 +1648,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", - "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", "cpu": [ "arm64" ], @@ -1826,9 +1662,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", - "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", "cpu": [ "x64" ], @@ -1840,9 +1676,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", - "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", "cpu": [ "arm" ], @@ -1854,9 +1690,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", - "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", "cpu": [ "arm" ], @@ -1868,9 +1704,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", "cpu": [ "arm64" ], @@ -1882,9 +1718,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", - "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", "cpu": [ "arm64" ], @@ -1896,9 +1732,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", - "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", "cpu": [ "loong64" ], @@ -1910,9 +1746,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", - "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", "cpu": [ "loong64" ], @@ -1924,9 +1760,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", - "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", "cpu": [ "ppc64" ], @@ -1938,9 +1774,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", - "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", "cpu": [ "ppc64" ], @@ -1952,9 +1788,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", - "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", "cpu": [ "riscv64" ], @@ -1966,9 +1802,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", - "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", "cpu": [ "riscv64" ], @@ -1980,9 +1816,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", - "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", "cpu": [ "s390x" ], @@ -1994,9 +1830,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", "cpu": [ "x64" ], @@ -2008,9 +1844,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", - "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", "cpu": [ "x64" ], @@ -2022,9 +1858,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", - "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", "cpu": [ "x64" ], @@ -2036,9 +1872,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", - "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", "cpu": [ "arm64" ], @@ -2050,9 +1886,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", "cpu": [ "arm64" ], @@ -2064,9 +1900,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", - "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", "cpu": [ "ia32" ], @@ -2078,9 +1914,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", "cpu": [ "x64" ], @@ -2092,9 +1928,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", "cpu": [ "x64" ], @@ -2106,16 +1942,12 @@ ] }, "node_modules/@smithy/config-resolver": { - "version": "4.4.14", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.14.tgz", - "integrity": "sha512-N55f8mPEccpzKetUagdvmAy8oohf0J5cuj9jLI1TaSceRlq0pJsIZepY3kmAXAhyxqXPV6hDerDQhqQPKWgAoQ==", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.5.4.tgz", + "integrity": "sha512-jqADOFCkuSqluoEPjxWTFQ/6Xfsmt4Xi3IelA+c+4WdavqCijGGfWi873VqfIZeSFvaBpYeH+PKHC3POE98KlQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.13", - "@smithy/types": "^4.14.0", - "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2123,20 +1955,13 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.14", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.14.tgz", - "integrity": "sha512-vJ0IhpZxZAkFYOegMKSrxw7ujhhT2pass/1UEcZ4kfl5srTAqtPU5I7MdYQoreVas3204ykCiNhY1o7Xlz6Yyg==", + "version": "3.24.4", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.4.tgz", + "integrity": "sha512-3UNRKEyQyAgVgM0LGlerCLm+ChZWZ1GPfde+jBEW6bm6bSBGU1p0EbblaUV3unbhwvidjLA5Zs3sOs7mnZwvAw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-stream": "^4.5.22", - "@smithy/util-utf8": "^4.2.2", - "@smithy/uuid": "^1.1.2", + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -2144,30 +1969,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.13.tgz", - "integrity": "sha512-wboCPijzf6RJKLOvnjDAiBxGSmSnGXj35o5ZAWKDaHa/cvQ5U3ZJ13D4tMCE8JG4dxVAZFy/P0x/V9CwwdfULQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-codec": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.13.tgz", - "integrity": "sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.4.tgz", + "integrity": "sha512-vKW0MEFRU4Y3MkVZUkpJm+g9qyPGLCXhc0YLggUdSdBB4g7IaSSsCE75P9rBXyWHrXY1UYSQUl8/DwsTR7QciA==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.0", - "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/core": "^3.24.4", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -2175,13 +1983,12 @@ } }, "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.13.tgz", - "integrity": "sha512-wwybfcOX0tLqCcBP378TIU9IqrDuZq/tDV48LlZNydMpCnqnYr+hWBAYbRE+rFFf/p7IkDJySM3bgiMKP2ihPg==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.3.4.tgz", + "integrity": "sha512-9szC3PfHhYSvWA98CIrD6rB8jS60tfKOPvDlzyD87gsDm8KDnsSpXnwPO1J3bPxg0tWE6Ljzk2YzZV2GBe3nUQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.13", - "@smithy/types": "^4.14.0", + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2189,12 +1996,12 @@ } }, "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.3.13", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.13.tgz", - "integrity": "sha512-ied1lO559PtAsMJzg2TKRlctLnEi1PfkNeMMpdwXDImk1zV9uvS/Oxoy/vcy9uv1GKZAjDAB5xT6ziE9fzm5wA==", + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.4.4.tgz", + "integrity": "sha512-Q28S5qVeHIGXY4xCO43IFglVCc11HXZlxdhUhcNgiI/ArVDi6SWOMLvWEq1woUQtThNxH3CPbz6l1Z2PT6gl8A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2202,27 +2009,12 @@ } }, "node_modules/@smithy/eventstream-serde-node": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.13.tgz", - "integrity": "sha512-hFyK+ORJrxAN3RYoaD6+gsGDQjeix8HOEkosoajvXYZ4VeqonM3G4jd9IIRm/sWGXUKmudkY9KdYjzosUqdM8A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.13", - "@smithy/types": "^4.14.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.13.tgz", - "integrity": "sha512-kRrq4EKLGeOxhC2CBEhRNcu1KSzNJzYY7RK3S7CxMPgB5dRrv55WqQOtRwQxQLC04xqORFLUgnDlc6xrNUULaA==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.3.4.tgz", + "integrity": "sha512-QxrsfEjVwpx2rzu0ZRc+F1MFSVh9pnjJayHzxjy3l3ru2zp7yt9FsYnDBHmdZV7389wqc1poK84vf5v3lArSaw==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-codec": "^4.2.13", - "@smithy/types": "^4.14.0", + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2230,15 +2022,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.16", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.16.tgz", - "integrity": "sha512-nYDRUIvNd4mFmuXraRWt6w5UsZTNqtj4hXJA/iiOD4tuseIdLP9Lq38teH/SZTcIFCa2f+27o7hYpIsWktJKEQ==", + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.4.tgz", + "integrity": "sha512-qM7AUKI4G6d7lNgaZD3lA1tWSolh5r6gcixfTZAPstVURfjIbvreVTPz+994M0yC3HbX4YYhDRgr31Xy3XwWOQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.13", - "@smithy/querystring-builder": "^4.2.13", - "@smithy/types": "^4.14.0", - "@smithy/util-base64": "^4.3.2", + "@smithy/core": "^3.24.4", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -2246,14 +2036,12 @@ } }, "node_modules/@smithy/hash-node": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.13.tgz", - "integrity": "sha512-4/oy9h0jjmY80a2gOIo75iLl8TOPhmtx4E2Hz+PfMjvx/vLtGY4TMU/35WRyH2JHPfT5CVB38u4JRow7gnmzJA==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.3.4.tgz", + "integrity": "sha512-LfXN/tUjjmUkEaMWto96a3Xetk7u4WMruzFop7mtsIYY2njTvTQm/zsok9KpwztzOL3WSBfv+hikxkJhArv8xQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2261,12 +2049,12 @@ } }, "node_modules/@smithy/invalid-dependency": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.13.tgz", - "integrity": "sha512-jvC0RB/8BLj2SMIkY0Npl425IdnxZJxInpZJbu563zIRnVjpDMXevU3VMCRSabaLB0kf/eFIOusdGstrLJ8IDg==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.3.4.tgz", + "integrity": "sha512-lByqayJi0EC8wAysIA93QwN4C1ofppNk5YXt8QS4Zo2AVHxGWspkwvYGP/5WLO4jsdHDsEc+KAdmqJBP9eN46g==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2274,25 +2062,24 @@ } }, "node_modules/@smithy/is-array-buffer": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", - "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=14.0.0" } }, "node_modules/@smithy/middleware-content-length": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.13.tgz", - "integrity": "sha512-IPMLm/LE4AZwu6qiE8Rr8vJsWhs9AtOdySRXrOM7xnvclp77Tyh7hMs/FRrMf26kgIe67vFJXXOSmVxS7oKeig==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.3.4.tgz", + "integrity": "sha512-dI6ysYleXIHUDVsJ8JKR8m9zUNo29y43D6/evJcfY/JREgBrXpWbBavs1EAJIPA5+d7DBlepqSCIWveWiyO1jw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2300,18 +2087,12 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.29", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.29.tgz", - "integrity": "sha512-R9Q/58U+qBiSARGWbAbFLczECg/RmysRksX6Q8BaQEpt75I7LI6WGDZnjuC9GXSGKljEbA7N118LhGaMbfrTXw==", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.5.4.tgz", + "integrity": "sha512-vfaUGI2plIGPeiYlUwtC2IccLKR5XwPLCPzMwRF/dDlvMtVuy6L7Klx2LThoU3nENR294j/48Tn9alg/3teV1Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.14", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", - "@smithy/util-middleware": "^4.2.13", + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2319,20 +2100,12 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.1.tgz", - "integrity": "sha512-/zY+Gp7Qj2D2hVm3irkCyONER7E9MiX3cUUm/k2ZmhkzZkrPgwVS4aJ5NriZUEN/M0D1hhjrgjUmX04HhRwdWA==", + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.6.4.tgz", + "integrity": "sha512-KOAlkv0/6yYLLXcJNTWq116q+ezv3i0+TQNg13hExZLUBwLvBj9ipP7f1+sAfVUsfYG/BFuF2nX6BRoKHFqt1Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.14", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/service-error-classification": "^4.2.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.1", - "@smithy/uuid": "^1.1.2", + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2340,14 +2113,12 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.17", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.17.tgz", - "integrity": "sha512-0T2mcaM6v9W1xku86Dk0bEW7aEseG6KenFkPK98XNw0ZhOqOiD1MrMsdnQw9QsL3/Oa85T53iSMlm0SZdSuIEQ==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.3.4.tgz", + "integrity": "sha512-J6JfVBmp3Z8ALEnIVJOyuBYr+xl/oIEvDY4qc9vbGXdgPZRYEYOrenXGhH7NnC2SDOWtkg8pIGw/yaTZTYDzrA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.14", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2355,12 +2126,12 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.13.tgz", - "integrity": "sha512-g72jN/sGDLyTanrCLH9fhg3oysO3f7tQa6eWWsMyn2BiYNCgjF24n4/I9wff/5XidFvjj9ilipAoQrurTUrLvw==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.3.4.tgz", + "integrity": "sha512-fMuimMAsXCcDjWSNXeVitzQeWYKxvFmBbWVnYf1qLC5PaFbDBF0DcWQKSnqDY+QaaSzLIh+iAU3TaEWdGEeCfA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2368,14 +2139,12 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.3.13", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.13.tgz", - "integrity": "sha512-iGxQ04DsKXLckbgnX4ipElrOTk+IHgTyu0q0WssZfYhDm9CQWHmu6cOeI5wmWRxpXbBDhIIfXMWz5tPEtcVqbw==", + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.4.4.tgz", + "integrity": "sha512-mD/K1A5WrTZh6I23x1ScYo3K7/+Ujvp/zvLtaZT+xkDeXksWAQ/fKp60SudeUHUHQe/3Q3rgnfedJDqnxSKdpA==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2383,14 +2152,13 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.2.tgz", - "integrity": "sha512-/oD7u8M0oj2ZTFw7GkuuHWpIxtWdLlnyNkbrWcyVYhd5RJNDuczdkb0wfnQICyNFrVPlr8YHOhamjNy3zidhmA==", + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.4.tgz", + "integrity": "sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.13", - "@smithy/querystring-builder": "^4.2.13", - "@smithy/types": "^4.14.0", + "@smithy/core": "^3.24.4", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -2398,12 +2166,12 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.13.tgz", - "integrity": "sha512-bGzUCthxRmezuxkbu9wD33wWg9KX3hJpCXpQ93vVkPrHn9ZW6KNNdY5xAUWNuRCwQ+VyboFuWirG1lZhhkcyRQ==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.3.4.tgz", + "integrity": "sha512-ozP4y+MVRgiJJ1WEkT3/cFHungnv7g1ED9A9lVFlIlOUc9QkEfEYOu+AKUpyRqS9lxKWsdWWcdgSvX6aoRxV/A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2411,64 +2179,25 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.3.13", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.13.tgz", - "integrity": "sha512-+HsmuJUF4u8POo6s8/a2Yb/AQ5t/YgLovCuHF9oxbocqv+SZ6gd8lC2duBFiCA/vFHoHQhoq7QjqJqZC6xOxxg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-builder": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.13.tgz", - "integrity": "sha512-tG4aOYFCZdPMjbgfhnIQ322H//ojujldp1SrHPHpBSb3NqgUp3dwiUGRJzie87hS1DYwWGqDuPaowoDF+rYCbQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.0", - "@smithy/util-uri-escape": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-parser": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.13.tgz", - "integrity": "sha512-hqW3Q4P+CDzUyQ87GrboGMeD7XYNMOF+CuTwu936UQRB/zeYn3jys8C3w+wMkDfY7CyyyVwZQ5cNFoG0x1pYmA==", + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.4.4.tgz", + "integrity": "sha512-5VdJYIYsVt2GT+i0fp5gvWoJNrdFEFN16TrpNnAZHngYC/xgk5yni6O/qV3WlIpJjeLC8RfwoQiNTljCdbNXgw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/service-error-classification": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.13.tgz", - "integrity": "sha512-a0s8XZMfOC/qpqq7RCPvJlk93rWFrElH6O++8WJKz0FqnA4Y7fkNi/0mnGgSH1C4x6MFsuBA8VKu4zxFrMe5Vw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.8.tgz", - "integrity": "sha512-VZCZx2bZasxdqxVgEAhREvDSlkatTPnkdWy1+Kiy8w7kYPBosW0V5IeDwzDUMvWBt56zpK658rx1cOBFOYaPaw==", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.5.4.tgz", + "integrity": "sha512-TmY6TLysVCxeTlVF3weqEAu11Yx6W64Q5Y7m38ojS2UrXNmHiijkgCIPhcDRA6JDlbZoj6u8QRn7PmMjrZpKKA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2476,18 +2205,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.13", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.13.tgz", - "integrity": "sha512-YpYSyM0vMDwKbHD/JA7bVOF6kToVRpa+FM5ateEVRpsTNu564g1muBlkTubXhSKKYXInhpADF46FPyrZcTLpXg==", + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.4.tgz", + "integrity": "sha512-e5UtkMvsatzBfbeBZjEOt0k0Z3BEsjTFL/n6fdO5vtBLe67tdy0dX7xw2DU7uZ3acwoHyeCqpU2Fzb7pxwHb6Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-uri-escape": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", + "@smithy/core": "^3.24.4", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -2495,17 +2219,13 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.12.9", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.9.tgz", - "integrity": "sha512-ovaLEcTU5olSeHcRXcxV6viaKtpkHZumn6Ps0yn7dRf2rRSfy794vpjOtrWDO0d1auDSvAqxO+lyhERSXQ03EQ==", + "version": "4.13.4", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.13.4.tgz", + "integrity": "sha512-Lg3hCVv8oVYlnQus1x+1hlNoLSrcdOhkg2+Be5YUxkI1LbCEPpcwEdYfz+0j1sQSmEixA/UUbxW41CiN/+aigA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.14", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", - "@smithy/util-stream": "^4.5.22", + "@smithy/core": "^3.24.4", + "@smithy/types": "^4.14.2", "tslib": "^2.6.2" }, "engines": { @@ -2513,9 +2233,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.0.tgz", - "integrity": "sha512-OWgntFLW88kx2qvf/c/67Vno1yuXm/f9M7QFAtVkkO29IJXGBIg0ycEaBTH0kvCtwmvZxRujrgP5a86RvsXJAQ==", + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2525,13 +2245,12 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.13.tgz", - "integrity": "sha512-2G03yoboIRZlZze2+PT4GZEjgwQsJjUgn6iTsvxA02bVceHR6vp4Cuk7TUnPFWKF+ffNUk3kj4COwkENS2K3vw==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.3.4.tgz", + "integrity": "sha512-Acgxr0W3vdmDNZKafjpDFaG2t32zNYVd7B5D3Y9LQep264+6pP/K/4ZXiAfW+ztMYB0iBG1kZx19EmRBd9zA/g==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.13", - "@smithy/types": "^4.14.0", + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2539,13 +2258,12 @@ } }, "node_modules/@smithy/util-base64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", - "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.4.4.tgz", + "integrity": "sha512-f3zLXiAzY3oYDdubxW//QLk5KEngThcNQhKvcLGGiYNEzYD7B2PXwLjUZO7joB9wfvihflzPJilMest9Q9bj4Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2553,11 +2271,12 @@ } }, "node_modules/@smithy/util-body-length-browser": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", - "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.3.4.tgz", + "integrity": "sha512-ddbTlVHnjDflrReo1VlhPpomb0DlgqEhk/I++OS44Y4PEE0QnzOdJemUo439vNYEFjtJvZd1p9CBe/lcxpontg==", "license": "Apache-2.0", "dependencies": { + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2565,11 +2284,12 @@ } }, "node_modules/@smithy/util-body-length-node": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", - "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.3.4.tgz", + "integrity": "sha512-e3pKOHP/UjTV4/2gMdjcgelvX8DGS6Yy3jSLWh47HvsyeD0fc/V4kkSYfhOjEnV4CizPn9gQojj2q9MiZQcJDg==", "license": "Apache-2.0", "dependencies": { + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2577,39 +2297,25 @@ } }, "node_modules/@smithy/util-buffer-from": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", - "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-config-provider": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", - "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", "license": "Apache-2.0", "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=14.0.0" } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.45", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.45.tgz", - "integrity": "sha512-ag9sWc6/nWZAuK3Wm9KlFJUnRkXLrXn33RFjIAmCTFThqLHY+7wCst10BGq56FxslsDrjhSie46c8OULS+BiIw==", + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.4.4.tgz", + "integrity": "sha512-/TWNfyCtJHHIS5taeOQ1qcMUCr5xPqdFntDL5+Sp8sjGj29ZaFUUxlCP+6V//J7MhHZZ2PIe2kMh1YdOpaEPnA==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2617,17 +2323,12 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.49", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.49.tgz", - "integrity": "sha512-jlN6vHwE8gY5AfiFBavtD3QtCX2f7lM3BKkz7nFKSNfFR5nXLXLg6sqXTJEEyDwtxbztIDBQCfjsGVXlIru2lQ==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.3.4.tgz", + "integrity": "sha512-kFGsCILX13YE8troSVPB6AdEAzjbhJ/XFCaEgFGEBz1I17+wMVMBO1WxKxU27GlxBFQy643Jy42RgT8wf8X++g==", "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.4.14", - "@smithy/credential-provider-imds": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2635,25 +2336,12 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.4.tgz", - "integrity": "sha512-BKoR/ubPp9KNKFxPpg1J28N1+bgu8NGAtJblBP7yHy8yQPBWhIAv9+l92SlQLpolGm71CVO+btB60gTgzT0wog==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.13", - "@smithy/types": "^4.14.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-hex-encoding": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", - "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.5.4.tgz", + "integrity": "sha512-RtzPUniH4R49dG8X2MeOi9UzcNwh8C8lEADOGItnAMifxljQgCbuUOpvciX7EnEEJ5H2T2AXvEdOuXSe0bKdaQ==", "license": "Apache-2.0", "dependencies": { + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2661,12 +2349,12 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.13.tgz", - "integrity": "sha512-GTooyrlmRTqvUen4eK7/K1p6kryF7bnDfq6XsAbIsf2mo51B/utaH+XThY6dKgNCWzMAaH/+OLmqaBuLhLWRow==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.3.4.tgz", + "integrity": "sha512-jzWo5fD5FYdGlfqx+kpp5BoOSG+TYQczYY6Ue2QX4linDq+5q6t2/RtO53nABOZjD+qYSSaVd9RalyMIPbxk9Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2674,13 +2362,12 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.1.tgz", - "integrity": "sha512-FwmicpgWOkP5kZUjN3y+3JIom8NLGqSAJBeoIgK0rIToI817TEBHCrd0A2qGeKQlgDeP+Jzn4i0H/NLAXGy9uQ==", + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.4.4.tgz", + "integrity": "sha512-4upfJJ+jayyqd523zopC5Ad7XxMp+rpeiqh0QtiZGBvdBB7KBBtHVEtraHNnlzkQuytvkU5yyg6Ckf3ApJ3A5Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.13", - "@smithy/types": "^4.14.0", + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2688,30 +2375,12 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.5.22", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.22.tgz", - "integrity": "sha512-3H8iq/0BfQjUs2/4fbHZ9aG9yNzcuZs24LPkcX1Q7Z+qpqaGM8+qbGmE8zo9m2nCRgamyvS98cHdcWvR6YUsew==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/types": "^4.14.0", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-uri-escape": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", - "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.6.4.tgz", + "integrity": "sha512-mkc/JN/fPiaHBAhhp7LbwAQz6RFjrCkYZ4F3OK2ZAWbmkjDQmAyNUmoDcQDVGWF9U+13+fWPszCXFHLP/8NnAA==", "license": "Apache-2.0", "dependencies": { + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2719,24 +2388,12 @@ } }, "node_modules/@smithy/util-utf8": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", - "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/uuid": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", - "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.3.4.tgz", + "integrity": "sha512-s8lfXcv+5C2GjBwGUBqFLgNmhyp9/n4TSKbOzKlIqJ/x0L/zwIxjNBC6DN4xUy59NvOrsiZI1t3tWi4ADUDyNw==", "license": "Apache-2.0", "dependencies": { + "@smithy/core": "^3.24.4", "tslib": "^2.6.2" }, "engines": { @@ -2749,45 +2406,57 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", - "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", + "version": "22.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.0.tgz", + "integrity": "sha512-XC70cRZVElFHfIUB40FgZOBbgJYFKKMa5nb9lxcwYstFG/Mi+/Y0bGS+rs6Dmhmkpq4pnNiLiuZAbc02YCOnmA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~6.20.0" } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.52.0.tgz", - "integrity": "sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.0.0.tgz", + "integrity": "sha512-STIZdwEQRXAHvNUS6ILDf5z3u95Gc8jzywunxSNqX00OooIemaaNIA0vEgynJlycL5AjabYLLrIyHd4iazyvtg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.52.0", - "@typescript-eslint/type-utils": "8.52.0", - "@typescript-eslint/utils": "8.52.0", - "@typescript-eslint/visitor-keys": "8.52.0", - "ignore": "^7.0.5", + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.0.0", + "@typescript-eslint/type-utils": "8.0.0", + "@typescript-eslint/utils": "8.0.0", + "@typescript-eslint/visitor-keys": "8.0.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^1.3.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2797,23 +2466,27 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.52.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@typescript-eslint/parser": { - "version": "8.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.52.0.tgz", - "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.0.0.tgz", + "integrity": "sha512-pS1hdZ+vnrpDIxuFXYQpLTILglTjSYJ9MbetZctrUawogUsPdz31DIIRZ9+rab0LhYNTsk88w4fIzVheiTbWOQ==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.52.0", - "@typescript-eslint/types": "8.52.0", - "@typescript-eslint/typescript-estree": "8.52.0", - "@typescript-eslint/visitor-keys": "8.52.0", - "debug": "^4.4.3" + "@typescript-eslint/scope-manager": "8.0.0", + "@typescript-eslint/types": "8.0.0", + "@typescript-eslint/typescript-estree": "8.0.0", + "@typescript-eslint/visitor-keys": "8.0.0", + "debug": "^4.3.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2823,79 +2496,43 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.52.0.tgz", - "integrity": "sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.52.0", - "@typescript-eslint/types": "^8.52.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "eslint": "^8.57.0 || ^9.0.0" }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.52.0.tgz", - "integrity": "sha512-ixxqmmCcc1Nf8S0mS0TkJ/3LKcC8mruYJPOU6Ia2F/zUUR4pApW7LzrpU3JmtePbRUTes9bEqRc1Gg4iyRnDzA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.0.0.tgz", + "integrity": "sha512-V0aa9Csx/ZWWv2IPgTfY7T4agYwJyILESu/PVqFtTFz9RIS823mAze+NbnBI8xiwdX3iqeQbcTYlvB04G9wyQw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.52.0", - "@typescript-eslint/visitor-keys": "8.52.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@typescript-eslint/types": "8.0.0", + "@typescript-eslint/visitor-keys": "8.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.52.0.tgz", - "integrity": "sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg==", - "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.52.0.tgz", - "integrity": "sha512-JD3wKBRWglYRQkAtsyGz1AewDu3mTc7NtRjR/ceTyGoPqmdS5oCdx/oZMWD5Zuqmo6/MpsYs0wp6axNt88/2EQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.0.0.tgz", + "integrity": "sha512-mJAFP2mZLTBwAn5WI4PMakpywfWFH5nQZezUQdSKV23Pqo6o9iShQg1hP2+0hJJXP2LnZkWPphdIq4juYYwCeg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.52.0", - "@typescript-eslint/typescript-estree": "8.52.0", - "@typescript-eslint/utils": "8.52.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "@typescript-eslint/typescript-estree": "8.0.0", + "@typescript-eslint/utils": "8.0.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2904,15 +2541,16 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@typescript-eslint/types": { - "version": "8.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.52.0.tgz", - "integrity": "sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.0.0.tgz", + "integrity": "sha512-wgdSGs9BTMWQ7ooeHtu5quddKKs5Z5dS+fHLbrQI+ID0XWJLODGMHRfhwImiHoeO2S5Wir2yXuadJN6/l4JRxw==", "dev": true, "license": "MIT", "engines": { @@ -2924,21 +2562,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.52.0.tgz", - "integrity": "sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.0.0.tgz", + "integrity": "sha512-5b97WpKMX+Y43YKi4zVcCVLtK5F98dFls3Oxui8LbnmRsseKenbbDinmvxrWegKDMmlkIq/XHuyy0UGLtpCDKg==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/project-service": "8.52.0", - "@typescript-eslint/tsconfig-utils": "8.52.0", - "@typescript-eslint/types": "8.52.0", - "@typescript-eslint/visitor-keys": "8.52.0", - "debug": "^4.4.3", - "minimatch": "^9.0.5", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "@typescript-eslint/types": "8.0.0", + "@typescript-eslint/visitor-keys": "8.0.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2947,21 +2584,23 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@typescript-eslint/utils": { - "version": "8.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.52.0.tgz", - "integrity": "sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.0.0.tgz", + "integrity": "sha512-k/oS/A/3QeGLRvOWCg6/9rATJL5rec7/5s1YmdS0ZU6LHveJyGFwBvLhSRBv6i9xaj7etmosp+l+ViN1I9Aj/Q==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.52.0", - "@typescript-eslint/types": "8.52.0", - "@typescript-eslint/typescript-estree": "8.52.0" + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.0.0", + "@typescript-eslint/types": "8.0.0", + "@typescript-eslint/typescript-estree": "8.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2971,19 +2610,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.52.0.tgz", - "integrity": "sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.0.0.tgz", + "integrity": "sha512-oN0K4nkHuOyF3PVMyETbpP5zp6wfyOvm7tWhTMfoqxSSsPmJIh6JNASuZDlODE8eE+0EB9uar+6+vxr9DBTYOA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.52.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.0.0", + "eslint-visitor-keys": "^3.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2993,45 +2631,30 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@vitest/coverage-v8": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", - "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz", + "integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@bcoe/v8-coverage": "^0.2.3", - "debug": "^4.3.7", + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.6", + "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.12", - "magicast": "^0.3.5", - "std-env": "^3.8.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^1.2.0" + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "2.1.9", - "vitest": "2.1.9" + "@vitest/browser": "4.1.6", + "vitest": "4.1.6" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -3040,38 +2663,40 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", + "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", + "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.9", + "@vitest/spy": "4.1.6", "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -3083,101 +2708,99 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", + "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^1.2.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", + "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" + "@vitest/utils": "4.1.6", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", + "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" + "@vitest/pretty-format": "4.1.6", + "@vitest/utils": "4.1.6", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", + "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/ui": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-2.1.9.tgz", - "integrity": "sha512-izzd2zmnk8Nl5ECYkW27328RbQ1nKvkm6Bb5DAaz1Gk59EbLkiCMa6OLT0NoaAYTjOFS6N+SMYW1nh4/9ljPiw==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.6.tgz", + "integrity": "sha512-wiu5em68DfGv/2HFvI1Njr7JI2CHcBlQvereSzVG8my53PRxjTNOCsD9VOkRKrsJBDHmyuXvosxWZw7T91a2mw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.9", + "@vitest/utils": "4.1.6", "fflate": "^0.8.2", - "flatted": "^3.3.1", - "pathe": "^1.1.2", - "sirv": "^3.0.0", - "tinyglobby": "^0.2.10", - "tinyrainbow": "^1.2.0" + "flatted": "^3.4.2", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "2.1.9" + "vitest": "4.1.6" } }, "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", + "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" + "@vitest/pretty-format": "4.1.6", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -3198,9 +2821,9 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -3215,16 +2838,13 @@ } }, "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": ">=8" } }, "node_modules/ansi-styles": { @@ -3246,6 +2866,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3256,6 +2886,18 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3290,21 +2932,24 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, "engines": { "node": ">=8" } @@ -3332,18 +2977,11 @@ } }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } @@ -3381,16 +3019,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3418,6 +3046,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3460,16 +3095,6 @@ "node": ">=0.10.0" } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3477,44 +3102,43 @@ "dev": true, "license": "MIT" }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.0.tgz", + "integrity": "sha512-WvImr5kpN5NGNn7KaDjJnLTh5rDVLZiDf/YLA8T1ZEZEBZNEDOE+mnkS0PVjPax8ZxBP5zC5SLMB3/9VV5de9g==", "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" }, "funding": { - "url": "https://dotenvx.com" + "url": "https://github.com/motdotla/dotenv?sponsor=1" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3522,32 +3146,35 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, "node_modules/escape-string-regexp": { @@ -3564,46 +3191,46 @@ } }, "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.0.0.tgz", + "integrity": "sha512-IMryZ5SudxzQvuod6rUdIUz29qFItWx281VhtFVc2Psy/ZhlCeD/5DT6lBIJ4H3G+iamGJoTln1v+QSuPw0p7Q==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^3.0.2", + "@eslint/js": "9.0.0", + "@humanwhocodes/config-array": "^0.12.3", "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", + "@nodelib/fs.walk": "^1.2.8", "ajv": "^6.12.4", "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", + "cross-spawn": "^7.0.2", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^8.0.1", + "eslint-visitor-keys": "^4.0.0", + "espree": "^10.0.1", + "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", + "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.3" + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" @@ -3612,15 +3239,7 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-scope": { @@ -3654,9 +3273,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -3677,16 +3296,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/eslint/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -3810,6 +3419,36 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3825,9 +3464,9 @@ "license": "MIT" }, "node_modules/fast-xml-builder": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", - "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", "funding": [ { "type": "github", @@ -3836,31 +3475,45 @@ ], "license": "MIT", "dependencies": { - "path-expression-matcher": "^1.1.3" + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" } }, "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "dev": true, "license": "MIT" }, @@ -3877,6 +3530,19 @@ "node": ">=16.0.0" } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -3915,23 +3581,6 @@ "dev": true, "license": "ISC" }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3947,27 +3596,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3994,6 +3622,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4012,9 +3668,9 @@ "license": "MIT" }, "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { @@ -4058,16 +3714,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -4081,13 +3727,33 @@ "node": ">=0.10.0" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, - "license": "ISC" - }, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -4113,21 +3779,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", @@ -4142,22 +3793,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/js-tiktoken": { "version": "1.0.21", "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", @@ -4167,6 +3802,13 @@ "base64-js": "^1.5.1" } }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -4294,20 +3936,6 @@ "dev": true, "license": "MIT" }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4319,15 +3947,15 @@ } }, "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" } }, "node_modules/make-dir": { @@ -4346,6 +3974,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -4362,16 +4014,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -4399,9 +4041,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -4424,10 +4066,21 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/openai": { - "version": "6.34.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.34.0.tgz", - "integrity": "sha512-yEr2jdGf4tVFYG6ohmr3pF6VJuveP0EA/sS8TBx+4Eq5NT10alu5zg2dmxMXMgqpihRDQlFGpRt2XwsGj+Fyxw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.15.0.tgz", + "integrity": "sha512-F1Lvs5BoVvmZtzkUEVyh8mDQPPFolq4F+xdsx/DO8Hee8YF3IGAlZqUIsF+DVGhqf4aU0a3bTghsxB6OIsRy1g==", "license": "Apache-2.0", "bin": { "openai": "bin/cli" @@ -4532,13 +4185,6 @@ "node": ">=8" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4587,40 +4233,23 @@ "node": ">=8" } }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, + "license": "MIT", "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=8" } }, "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4629,22 +4258,22 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=8.6" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -4662,7 +4291,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4690,6 +4319,27 @@ "node": ">=6" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4700,10 +4350,21 @@ "node": ">=4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rollup": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", - "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", "dev": true, "license": "MIT", "dependencies": { @@ -4717,38 +4378,69 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.1", - "@rollup/rollup-android-arm64": "4.60.1", - "@rollup/rollup-darwin-arm64": "4.60.1", - "@rollup/rollup-darwin-x64": "4.60.1", - "@rollup/rollup-freebsd-arm64": "4.60.1", - "@rollup/rollup-freebsd-x64": "4.60.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", - "@rollup/rollup-linux-arm-musleabihf": "4.60.1", - "@rollup/rollup-linux-arm64-gnu": "4.60.1", - "@rollup/rollup-linux-arm64-musl": "4.60.1", - "@rollup/rollup-linux-loong64-gnu": "4.60.1", - "@rollup/rollup-linux-loong64-musl": "4.60.1", - "@rollup/rollup-linux-ppc64-gnu": "4.60.1", - "@rollup/rollup-linux-ppc64-musl": "4.60.1", - "@rollup/rollup-linux-riscv64-gnu": "4.60.1", - "@rollup/rollup-linux-riscv64-musl": "4.60.1", - "@rollup/rollup-linux-s390x-gnu": "4.60.1", - "@rollup/rollup-linux-x64-gnu": "4.60.1", - "@rollup/rollup-linux-x64-musl": "4.60.1", - "@rollup/rollup-openbsd-x64": "4.60.1", - "@rollup/rollup-openharmony-arm64": "4.60.1", - "@rollup/rollup-win32-arm64-msvc": "4.60.1", - "@rollup/rollup-win32-ia32-msvc": "4.60.1", - "@rollup/rollup-win32-x64-gnu": "4.60.1", - "@rollup/rollup-win32-x64-msvc": "4.60.1", + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" } }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "dev": true, "license": "ISC", "bin": { @@ -4788,19 +4480,6 @@ "dev": true, "license": "ISC" }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -4816,6 +4495,16 @@ "node": ">=18" } }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4834,94 +4523,13 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", @@ -4934,16 +4542,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4957,6 +4555,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4970,20 +4580,12 @@ "node": ">=8" } }, - "node_modules/test-exclude": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", - "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^9.0.4" - }, - "engines": { - "node": ">=18" - } + "license": "MIT" }, "node_modules/tinybench": { "version": "2.9.0", @@ -4993,21 +4595,24 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz", + "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -5016,34 +4621,58 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" } }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, "engines": { - "node": ">=14.0.0" + "node": ">=8.0" } }, "node_modules/totalist": { @@ -5063,16 +4692,16 @@ "license": "MIT" }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18.12" + "node": ">=16" }, "peerDependencies": { - "typescript": ">=4.8.4" + "typescript": ">=4.2.0" } }, "node_modules/tslib": { @@ -5095,9 +4724,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5109,9 +4738,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "dev": true, "license": "MIT" }, @@ -5126,9 +4755,9 @@ } }, "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -5139,21 +4768,24 @@ } }, "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -5162,19 +4794,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", - "terser": "^5.4.0" + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -5195,85 +4833,120 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, - "node_modules/vite-node": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" + "engines": { + "node": ">=12.0.0" }, - "bin": { - "vite-node": "vite-node.mjs" + "peerDependencies": { + "picomatch": "^3 || ^4" }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": ">=12" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", + "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.6", + "@vitest/mocker": "4.1.6", + "@vitest/pretty-format": "4.1.6", + "@vitest/runner": "4.1.6", + "@vitest/snapshot": "4.1.6", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", + "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.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.6", + "@vitest/browser-preview": "4.1.6", + "@vitest/browser-webdriverio": "4.1.6", + "@vitest/coverage-istanbul": "4.1.6", + "@vitest/coverage-v8": "4.1.6", + "@vitest/ui": "4.1.6", "happy-dom": "*", - "jsdom": "*" + "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": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -5284,9 +4957,25 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5330,121 +5019,25 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=16.0.0" } }, "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -5470,9 +5063,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "3.25.0", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.0.tgz", + "integrity": "sha512-ficnZKUW0mlNivqeJkosTEkGbJ6NKCtSaOHGx5aXbtfeWMdRyzXLbAIn19my4C/KB7WPY/p9vlGPt+qpOp6c4Q==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/tests/integrations/typescript/package.json b/tests/integrations/typescript/package.json index 7b6705e0e46..9b63e528895 100644 --- a/tests/integrations/typescript/package.json +++ b/tests/integrations/typescript/package.json @@ -11,21 +11,10 @@ "typecheck": "tsc --noEmit", "lint": "eslint src tests --ext .ts" }, - "devDependencies": { - "@types/node": "22.10.0", - "@typescript-eslint/eslint-plugin": "8.0.0", - "@typescript-eslint/parser": "8.0.0", - "@vitest/coverage-v8": "2.1.0", - "@vitest/ui": "2.1.0", - "dotenv": "16.4.0", - "eslint": "9.0.0", - "typescript": "5.7.0", - "vitest": "2.1.0" - }, "dependencies": { "@anthropic-ai/sdk": "0.71.2", - "@aws-sdk/client-bedrock": "3.966.0", - "@aws-sdk/client-bedrock-runtime": "3.965.0", + "@aws-sdk/client-bedrock": "3.1015.0", + "@aws-sdk/client-bedrock-runtime": "3.1015.0", "@google/generative-ai": "0.24.1", "@langchain/anthropic": "1.3.26", "@langchain/core": "1.1.39", @@ -33,10 +22,25 @@ "@langchain/openai": "1.4.4", "langsmith": "0.6.0", "openai": "6.15.0", - "yaml": "2.6.0", - "zod": "3.24.0" + "yaml": "2.9.0", + "zod": "3.25.0" + }, + "devDependencies": { + "@types/node": "22.10.0", + "@typescript-eslint/eslint-plugin": "8.0.0", + "@typescript-eslint/parser": "8.0.0", + "@vitest/coverage-v8": "4.1.6", + "@vitest/ui": "4.1.6", + "dotenv": "16.4.0", + "eslint": "9.0.0", + "typescript": "5.7.3", + "vitest": "4.1.6" + }, + "overrides": { + "ast-v8-to-istanbul": "1.0.0", + "rollup": "4.60.4" }, "engines": { "node": ">=25.0.0" } -} +} \ No newline at end of file diff --git a/transports/bifrost-http/handlers/governance.go b/transports/bifrost-http/handlers/governance.go index 70a97a66c3b..49815e23d69 100644 --- a/transports/bifrost-http/handlers/governance.go +++ b/transports/bifrost-http/handlers/governance.go @@ -11,6 +11,7 @@ import ( "sort" "strconv" "strings" + "sync" "time" "github.com/bytedance/sonic" @@ -56,12 +57,52 @@ type GovernanceManager interface { } // GovernanceHandler manages HTTP requests for governance operations +// ScopeNameResolver returns the human-readable name for a non-global model +// config scope target (e.g. a virtual key's Name given its ID). The second +// return value is false when no name could be resolved; the UI then falls +// back to rendering the raw scope_id. Implementations must be safe to call +// concurrently. +type ScopeNameResolver func(ctx context.Context, scopeID string) (string, bool) + +// scopeNameResolvers is the package-level registry consulted by +// resolveModelConfigScopeName. OSS seeds it (virtual_key) the first time a +// GovernanceHandler is constructed; downstream builds extend it via +// RegisterScopeNameResolver at startup. Guarded by scopeNameResolversMu. +var ( + scopeNameResolversMu sync.RWMutex + scopeNameResolvers = map[string]ScopeNameResolver{} +) + +// RegisterScopeNameResolver wires a resolver for a model_config scope value. +// Intended to be called once at process startup, before serving requests +// (e.g. an enterprise build registering a "user" resolver). Overwrites any +// previously registered resolver for the same scope. Safe to call +// concurrently. +func RegisterScopeNameResolver(scope string, fn ScopeNameResolver) { + if scope == "" || fn == nil { + return + } + scopeNameResolversMu.Lock() + scopeNameResolvers[scope] = fn + scopeNameResolversMu.Unlock() +} + +func lookupScopeNameResolver(scope string) (ScopeNameResolver, bool) { + scopeNameResolversMu.RLock() + defer scopeNameResolversMu.RUnlock() + fn, ok := scopeNameResolvers[scope] + return fn, ok +} + type GovernanceHandler struct { configStore configstore.ConfigStore governanceManager GovernanceManager } -// NewGovernanceHandler creates a new governance handler instance +// NewGovernanceHandler creates a new governance handler instance. +// Side effect: ensures the default virtual_key scope-name resolver is +// registered against the supplied configStore, so resolveModelConfigScopeName +// can render VK names for OSS-only builds without further wiring. func NewGovernanceHandler(manager GovernanceManager, configStore configstore.ConfigStore) (*GovernanceHandler, error) { if manager == nil { return nil, fmt.Errorf("governance manager is required") @@ -69,6 +110,13 @@ func NewGovernanceHandler(manager GovernanceManager, configStore configstore.Con if configStore == nil { return nil, fmt.Errorf("config store is required") } + RegisterScopeNameResolver(configstoreTables.ModelConfigScopeVirtualKey, func(ctx context.Context, scopeID string) (string, bool) { + vk, err := configStore.GetVirtualKey(ctx, scopeID) + if err != nil || vk == nil { + return "", false + } + return vk.Name, true + }) return &GovernanceHandler{ governanceManager: manager, configStore: configStore, @@ -337,11 +385,486 @@ func findExistingBudget(request CreateBudgetRequest, byID map[string]configstore return existing, found, nil } +// coerceLegacyBudget converts a single UpdateBudgetRequest into a *[]CreateBudgetRequest +// so it can be handled uniformly via reconcileModelConfigBudgets. Returns nil when the +// request carries no actionable change (e.g. only one field set but no existing budget to +// merge with, leaving the budget list unchanged). +func coerceLegacyBudget(req *UpdateBudgetRequest, existing *configstoreTables.TableBudget) *[]CreateBudgetRequest { + if isBudgetRemovalRequest(req) { + empty := []CreateBudgetRequest{} + return &empty + } + b := CreateBudgetRequest{} + if existing != nil { + b.ID = existing.ID + b.MaxLimit = existing.MaxLimit + b.ResetDuration = existing.ResetDuration + } + if req.MaxLimit != nil { + b.MaxLimit = *req.MaxLimit + } + if req.ResetDuration != nil { + b.ResetDuration = *req.ResetDuration + } + if b.MaxLimit == 0 || b.ResetDuration == "" { + return nil + } + result := []CreateBudgetRequest{b} + return &result +} + func isRateLimitRemovalRequest(req *UpdateRateLimitRequest) bool { return req != nil && req.TokenMaxLimit == nil && req.RequestMaxLimit == nil && req.TokenResetDuration == nil && req.RequestResetDuration == nil } +// reconcileModelConfigBudgets upserts the desired set of budgets owned by a model config +// (via TableBudget.ModelConfigID), preserving usage on matched rows and deleting removed +// ones. It mutates mc.Budgets to the reconciled set. The model config row must already +// exist (callers create it first). Mirrors the VK/team multi-budget reconciliation. +func (h *GovernanceHandler) reconcileModelConfigBudgets(ctx context.Context, tx *gorm.DB, mc *configstoreTables.TableModelConfig, requests []CreateBudgetRequest) error { + seenDurations := make(map[string]bool, len(requests)) + for _, b := range requests { + if b.MaxLimit < 0 { + return &badRequestError{err: fmt.Errorf("budget max_limit cannot be negative: %.2f", b.MaxLimit)} + } + if _, err := configstoreTables.ParseDuration(b.ResetDuration); err != nil { + return &badRequestError{err: fmt.Errorf("invalid reset duration format: %s", b.ResetDuration)} + } + if seenDurations[b.ResetDuration] { + return &badRequestError{err: fmt.Errorf("duplicate reset_duration in budgets: %s", b.ResetDuration)} + } + seenDurations[b.ResetDuration] = true + } + + existingByID, existingByDuration := buildBudgetLookup(mc.Budgets, requests) + var reconciled []configstoreTables.TableBudget + matchedIDs := make(map[string]bool) + for _, b := range requests { + existing, found, err := findExistingBudget(b, existingByID, existingByDuration) + if err != nil { + return err + } + if found { + existing.MaxLimit = b.MaxLimit + existing.ResetDuration = b.ResetDuration + if err := validateBudget(&existing); err != nil { + return err + } + if err := h.configStore.UpdateBudget(ctx, &existing, tx); err != nil { + return err + } + reconciled = append(reconciled, existing) + matchedIDs[existing.ID] = true + } else { + budget := configstoreTables.TableBudget{ + ID: uuid.NewString(), + MaxLimit: b.MaxLimit, + ResetDuration: b.ResetDuration, + LastReset: budgetLastReset(mc.CalendarAligned, b.ResetDuration), + CurrentUsage: 0, + ModelConfigID: &mc.ID, + } + inheritUsageFromClosestShorterBudget(&budget, mc.Budgets, false) + if err := validateBudget(&budget); err != nil { + return err + } + if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { + return err + } + reconciled = append(reconciled, budget) + } + } + // Delete budgets no longer present. + for _, existing := range mc.Budgets { + if !matchedIDs[existing.ID] { + if err := h.configStore.DeleteBudget(ctx, existing.ID, tx); err != nil { + return fmt.Errorf("failed to delete removed model config budget: %w", err) + } + } + } + mc.Budgets = reconciled + return nil +} + +// reconcileCustomerBudgets upserts the desired set of budgets owned by a customer +// (via TableBudget.CustomerID), preserving usage on matched rows and deleting removed ones. +// It mutates customer.Budgets to the reconciled set. Mirrors reconcileModelConfigBudgets. +func (h *GovernanceHandler) reconcileCustomerBudgets(ctx context.Context, tx *gorm.DB, customer *configstoreTables.TableCustomer, requests []CreateBudgetRequest) error { + seenDurations := make(map[string]bool, len(requests)) + for _, b := range requests { + if b.MaxLimit < 0 { + return &badRequestError{err: fmt.Errorf("budget max_limit cannot be negative: %.2f", b.MaxLimit)} + } + if _, err := configstoreTables.ParseDuration(b.ResetDuration); err != nil { + return &badRequestError{err: fmt.Errorf("invalid reset duration format: %s", b.ResetDuration)} + } + if seenDurations[b.ResetDuration] { + return &badRequestError{err: fmt.Errorf("duplicate reset_duration in budgets: %s", b.ResetDuration)} + } + seenDurations[b.ResetDuration] = true + } + + existingByID, existingByDuration := buildBudgetLookup(customer.Budgets, requests) + var reconciled []configstoreTables.TableBudget + matchedIDs := make(map[string]bool) + for _, b := range requests { + existing, found, err := findExistingBudget(b, existingByID, existingByDuration) + if err != nil { + return err + } + if found { + existing.MaxLimit = b.MaxLimit + existing.ResetDuration = b.ResetDuration + if err := validateBudget(&existing); err != nil { + return err + } + if err := h.configStore.UpdateBudget(ctx, &existing, tx); err != nil { + return err + } + reconciled = append(reconciled, existing) + matchedIDs[existing.ID] = true + } else { + cid := customer.ID + budget := configstoreTables.TableBudget{ + ID: uuid.NewString(), + MaxLimit: b.MaxLimit, + ResetDuration: b.ResetDuration, + LastReset: budgetLastReset(customer.CalendarAligned, b.ResetDuration), + CurrentUsage: 0, + CustomerID: &cid, + } + inheritUsageFromClosestShorterBudget(&budget, customer.Budgets, false) + if err := validateBudget(&budget); err != nil { + return err + } + if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { + return err + } + reconciled = append(reconciled, budget) + } + } + for _, existing := range customer.Budgets { + if !matchedIDs[existing.ID] { + if err := h.configStore.DeleteBudget(ctx, existing.ID, tx); err != nil { + return fmt.Errorf("failed to delete removed customer budget: %w", err) + } + } + } + customer.Budgets = reconciled + return nil +} + +// vkModelConfigDesired is the desired governance state for one VK-scoped model config tier +// (provider=nil for the VK top-level, or a specific provider). The *Provided flags distinguish +// "leave unchanged" (false, used by partial VK updates) from "set to the given value" (true). +// The rateLimit carries only the limit/duration fields (no ID/usage). +type vkModelConfigDesired struct { + provider *string + budgetsProvided bool + budgets []CreateBudgetRequest + rateLimitProvided bool + rateLimitRemove bool + rateLimit *configstoreTables.TableRateLimit +} + +// syncVKGovernanceToModelConfigs folds a virtual key's governance (top-level + per-provider +// budgets/rate-limits) into VK-scoped model configs, the single source of truth. It reconciles +// the top-level and per-provider configs and removes configs for providers no longer configured. +// Must run inside the VK create/update transaction. reconcileProviders controls per-provider +// handling: true treats perProvider as the full desired set (reconciling and removing absent +// providers); false leaves all per-provider configs untouched (for a partial VK update that +// omits provider_configs). +func (h *GovernanceHandler) syncVKGovernanceToModelConfigs(ctx context.Context, tx *gorm.DB, vk *configstoreTables.TableVirtualKey, top vkModelConfigDesired, perProvider []vkModelConfigDesired, reconcileProviders bool) error { + if err := h.reconcileVKModelConfig(ctx, tx, vk, top); err != nil { + return err + } + if !reconcileProviders { + return nil + } + keep := make(map[string]bool, len(perProvider)) + for _, pg := range perProvider { + if pg.provider == nil { + continue + } + keep[*pg.provider] = true + if err := h.reconcileVKModelConfig(ctx, tx, vk, pg); err != nil { + return err + } + } + // Delete VK-scoped provider model configs whose provider is no longer configured. + var existing []configstoreTables.TableModelConfig + if err := tx.Preload("Budgets"). + Where("scope = ? AND scope_id = ? AND model_name = ? AND provider IS NOT NULL", + configstoreTables.ModelConfigScopeVirtualKey, vk.ID, configstoreTables.ModelConfigAllModels). + Find(&existing).Error; err != nil { + return err + } + for i := range existing { + mc := &existing[i] + if mc.Provider != nil && !keep[*mc.Provider] { + if err := h.deleteVKModelConfig(ctx, tx, mc); err != nil { + return err + } + } + } + return nil +} + +// reconcileVKModelConfig reconciles a single VK-scoped model config to the desired state. +func (h *GovernanceHandler) reconcileVKModelConfig(ctx context.Context, tx *gorm.DB, vk *configstoreTables.TableVirtualKey, d vkModelConfigDesired) error { + q := tx.Preload("Budgets").Where("scope = ? AND scope_id = ? AND model_name = ?", + configstoreTables.ModelConfigScopeVirtualKey, vk.ID, configstoreTables.ModelConfigAllModels) + if d.provider == nil { + q = q.Where("provider IS NULL") + } else { + q = q.Where("provider = ?", *d.provider) + } + var existingList []configstoreTables.TableModelConfig + if err := q.Limit(1).Find(&existingList).Error; err != nil { + return err + } + isNew := len(existingList) == 0 + + var mc configstoreTables.TableModelConfig + if isNew { + mc = configstoreTables.TableModelConfig{ + ID: uuid.NewString(), + ModelName: configstoreTables.ModelConfigAllModels, + Provider: d.provider, + Scope: configstoreTables.ModelConfigScopeVirtualKey, + ScopeID: &vk.ID, + CalendarAligned: vk.CalendarAligned, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + } else { + mc = existingList[0] + mc.CalendarAligned = vk.CalendarAligned // keep in sync with the owning VK + } + + // Rate limit (mc references it via RateLimitID, so resolve before persisting the mc). + var rateLimitIDToDelete string + if d.rateLimitProvided { + switch { + case d.rateLimitRemove: + if mc.RateLimitID != nil { + rateLimitIDToDelete = *mc.RateLimitID + mc.RateLimitID = nil + mc.RateLimit = nil + } + case mc.RateLimitID != nil: + rl := configstoreTables.TableRateLimit{} + if err := tx.First(&rl, "id = ?", *mc.RateLimitID).Error; err != nil { + return err + } + rl.TokenMaxLimit = d.rateLimit.TokenMaxLimit + rl.TokenResetDuration = d.rateLimit.TokenResetDuration + rl.RequestMaxLimit = d.rateLimit.RequestMaxLimit + rl.RequestResetDuration = d.rateLimit.RequestResetDuration + if err := validateRateLimit(&rl); err != nil { + return err + } + if err := h.configStore.UpdateRateLimit(ctx, &rl, tx); err != nil { + return err + } + mc.RateLimit = &rl + default: + rl := configstoreTables.TableRateLimit{ + ID: uuid.NewString(), + TokenMaxLimit: d.rateLimit.TokenMaxLimit, + TokenResetDuration: d.rateLimit.TokenResetDuration, + RequestMaxLimit: d.rateLimit.RequestMaxLimit, + RequestResetDuration: d.rateLimit.RequestResetDuration, + TokenLastReset: time.Now(), + RequestLastReset: time.Now(), + } + if err := validateRateLimit(&rl); err != nil { + return err + } + if err := h.configStore.CreateRateLimit(ctx, &rl, tx); err != nil { + return err + } + mc.RateLimitID = &rl.ID + mc.RateLimit = &rl + } + } + + // Resulting budget count: the desired set if provided, else the existing set. + finalBudgetCount := len(mc.Budgets) + if d.budgetsProvided { + finalBudgetCount = len(d.budgets) + } + hasGovernance := mc.RateLimitID != nil || finalBudgetCount > 0 + + if !hasGovernance { + // No governance left → drop the model config (and its budgets) if it existed. + if !isNew { + for i := range mc.Budgets { + if err := h.configStore.DeleteBudget(ctx, mc.Budgets[i].ID, tx); err != nil { + return err + } + } + if err := tx.Delete(&configstoreTables.TableModelConfig{}, "id = ?", mc.ID).Error; err != nil { + return err + } + } + if rateLimitIDToDelete != "" { + if err := tx.Delete(&configstoreTables.TableRateLimit{}, "id = ?", rateLimitIDToDelete).Error; err != nil { + return err + } + } + return nil + } + + // Persist the mc (create or update) before touching budgets, which FK to it. + if isNew { + if err := h.configStore.CreateModelConfig(ctx, &mc, tx); err != nil { + return err + } + } else { + mc.UpdatedAt = time.Now() + if err := h.configStore.UpdateModelConfig(ctx, &mc, tx); err != nil { + return err + } + } + + if d.budgetsProvided { + if err := h.reconcileModelConfigBudgets(ctx, tx, &mc, d.budgets); err != nil { + return err + } + } + + if rateLimitIDToDelete != "" { + if err := tx.Delete(&configstoreTables.TableRateLimit{}, "id = ?", rateLimitIDToDelete).Error; err != nil { + return err + } + } + return nil +} + +// deleteVKModelConfig removes a VK-scoped model config and its owned +// budgets/rate-limit (used when a provider config is removed from the VK). +func (h *GovernanceHandler) deleteVKModelConfig(ctx context.Context, tx *gorm.DB, mc *configstoreTables.TableModelConfig) error { + for i := range mc.Budgets { + if err := h.configStore.DeleteBudget(ctx, mc.Budgets[i].ID, tx); err != nil { + return err + } + } + rlID := mc.RateLimitID + if err := tx.Delete(&configstoreTables.TableModelConfig{}, "id = ?", mc.ID).Error; err != nil { + return err + } + if rlID != nil { + if err := tx.Delete(&configstoreTables.TableRateLimit{}, "id = ?", *rlID).Error; err != nil { + return err + } + } + return nil +} + +// rateLimitFromRequestFields builds a transient TableRateLimit (limit/duration fields only) +// for the VK governance sync, from the shared rate-limit request field shape. +func rateLimitFromRequestFields(tokenMax *int64, tokenDur *string, reqMax *int64, reqDur *string) *configstoreTables.TableRateLimit { + return &configstoreTables.TableRateLimit{ + TokenMaxLimit: tokenMax, + TokenResetDuration: tokenDur, + RequestMaxLimit: reqMax, + RequestResetDuration: reqDur, + } +} + +// vkModelConfigIndexKey builds a lookup key for a VK-scoped model config by scope target + provider. +func vkModelConfigIndexKey(scopeID string, provider *string) string { + if provider == nil { + return scopeID + "|" + } + return scopeID + "|" + *provider +} + +// applyVKGovernanceFromModelConfigs repopulates a VK's (and each provider config's) budgets and +// rate-limit from the VK-scoped model configs that own them — for serialization only (so the VK +// sheet still renders the governance it edits). byKey is keyed by vkModelConfigIndexKey. +// The reverse of syncVKGovernanceToModelConfigs. +func applyVKGovernanceFromModelConfigs(vk *configstoreTables.TableVirtualKey, byKey map[string]*configstoreTables.TableModelConfig) { + if mc := byKey[vkModelConfigIndexKey(vk.ID, nil)]; mc != nil { + vk.Budgets = mc.Budgets + vk.RateLimit = mc.RateLimit + vk.RateLimitID = mc.RateLimitID + } else { + vk.Budgets = nil + vk.RateLimit = nil + vk.RateLimitID = nil + } + for i := range vk.ProviderConfigs { + pc := &vk.ProviderConfigs[i] + if mc := byKey[vkModelConfigIndexKey(vk.ID, &pc.Provider)]; mc != nil { + pc.Budgets = mc.Budgets + pc.RateLimit = mc.RateLimit + pc.RateLimitID = mc.RateLimitID + } else { + pc.Budgets = nil + pc.RateLimit = nil + pc.RateLimitID = nil + } + } +} + +// hydrateVKGovernance reverse-maps a single VK's governance from its VK-scoped model configs. +func (h *GovernanceHandler) hydrateVKGovernance(ctx context.Context, vk *configstoreTables.TableVirtualKey) { + if vk == nil { + return + } + byKey := make(map[string]*configstoreTables.TableModelConfig) + add := func(provider *string) { + mc, err := h.configStore.GetModelConfig(ctx, configstoreTables.ModelConfigScopeVirtualKey, &vk.ID, configstoreTables.ModelConfigAllModels, provider) + if err == nil && mc != nil { + byKey[vkModelConfigIndexKey(vk.ID, provider)] = mc + } + } + add(nil) + for i := range vk.ProviderConfigs { + prov := vk.ProviderConfigs[i].Provider + add(&prov) + } + applyVKGovernanceFromModelConfigs(vk, byKey) +} + +// buildVKModelConfigIndex builds a lookup map of VK-scoped model configs keyed by +// vkModelConfigIndexKey, from a slice of model-config pointers. +func buildVKModelConfigIndex(mcs []*configstoreTables.TableModelConfig) map[string]*configstoreTables.TableModelConfig { + byKey := make(map[string]*configstoreTables.TableModelConfig) + for _, mc := range mcs { + if mc != nil && mc.Scope == configstoreTables.ModelConfigScopeVirtualKey && mc.ModelName == configstoreTables.ModelConfigAllModels && mc.ScopeID != nil { + byKey[vkModelConfigIndexKey(*mc.ScopeID, mc.Provider)] = mc + } + } + return byKey +} + +// hydrateVKListGovernance reverse-maps governance for a list of VKs using a single bulk load +// of all VK-scoped model configs (avoids per-VK/per-provider queries). +func (h *GovernanceHandler) hydrateVKListGovernance(ctx context.Context, vks []configstoreTables.TableVirtualKey) { + if len(vks) == 0 { + return + } + allMCs, err := h.configStore.GetModelConfigs(ctx) + if err != nil { + logger.Error("failed to load model configs for VK governance hydration: %v", err) + return + } + byKey := make(map[string]*configstoreTables.TableModelConfig) + for i := range allMCs { + mc := &allMCs[i] + if mc.Scope == configstoreTables.ModelConfigScopeVirtualKey && mc.ModelName == configstoreTables.ModelConfigAllModels && mc.ScopeID != nil { + byKey[vkModelConfigIndexKey(*mc.ScopeID, mc.Provider)] = mc + } + } + for i := range vks { + applyVKGovernanceFromModelConfigs(&vks[i], byKey) + } +} + func collectProviderConfigDeleteIDs( config configstoreTables.TableVirtualKeyProviderConfig, budgetIDs []string, @@ -376,38 +899,48 @@ type UpdateTeamRequest struct { // CreateCustomerRequest represents the request body for creating a customer type CreateCustomerRequest struct { - Name string `json:"name" validate:"required"` - Budget *CreateBudgetRequest `json:"budget,omitempty"` - RateLimit *CreateRateLimitRequest `json:"rate_limit,omitempty"` // Customer can have its own rate limit + Name string `json:"name" validate:"required"` + Budgets []CreateBudgetRequest `json:"budgets,omitempty"` // Multi-budget: each must have a unique reset_duration + Budget *CreateBudgetRequest `json:"budget,omitempty"` // Deprecated: use budgets + RateLimit *CreateRateLimitRequest `json:"rate_limit,omitempty"` + CalendarAligned bool `json:"calendar_aligned,omitempty"` } // UpdateCustomerRequest represents the request body for updating a customer type UpdateCustomerRequest struct { - Name *string `json:"name,omitempty"` - Budget *UpdateBudgetRequest `json:"budget,omitempty"` - RateLimit *UpdateRateLimitRequest `json:"rate_limit,omitempty"` + Name *string `json:"name,omitempty"` + Budgets *[]CreateBudgetRequest `json:"budgets,omitempty"` // nil=no change, []=remove all + Budget *UpdateBudgetRequest `json:"budget,omitempty"` // Deprecated: use budgets + RateLimit *UpdateRateLimitRequest `json:"rate_limit,omitempty"` + CalendarAligned *bool `json:"calendar_aligned,omitempty"` } // CreateModelConfigRequest represents the request body for creating a model config type CreateModelConfigRequest struct { ModelName string `json:"model_name" validate:"required"` Provider *string `json:"provider,omitempty"` // Optional provider, nil means all providers - Budget *CreateBudgetRequest `json:"budget,omitempty"` + Scope string `json:"scope,omitempty"` // Defaults to "global" if not provided + ScopeID *string `json:"scope_id,omitempty"` // Required for non-global scopes (e.g. the virtual key ID) + Budgets []CreateBudgetRequest `json:"budgets,omitempty"` // A model config may carry multiple budgets (distinct reset windows) RateLimit *CreateRateLimitRequest `json:"rate_limit,omitempty"` } -// UpdateModelConfigRequest represents the request body for updating a model config +// UpdateModelConfigRequest represents the request body for updating a model config. +// Scope and scope_id are part of a config's identity and are intentionally not +// editable here (mirroring model_name/provider) — change them by recreating the config. type UpdateModelConfigRequest struct { ModelName *string `json:"model_name,omitempty"` Provider *string `json:"provider,omitempty"` // Optional provider, nil means no change - Budget *UpdateBudgetRequest `json:"budget,omitempty"` + Budgets []CreateBudgetRequest `json:"budgets,omitempty"` // Full desired set of budgets (reconciled against existing) RateLimit *UpdateRateLimitRequest `json:"rate_limit,omitempty"` } // UpdateProviderGovernanceRequest represents the request body for updating provider governance type UpdateProviderGovernanceRequest struct { - Budget *UpdateBudgetRequest `json:"budget,omitempty"` - RateLimit *UpdateRateLimitRequest `json:"rate_limit,omitempty"` + Budget *UpdateBudgetRequest `json:"budget,omitempty"` // deprecated; use budgets + Budgets *[]CreateBudgetRequest `json:"budgets,omitempty"` // nil=no change, []=remove all + RateLimit *UpdateRateLimitRequest `json:"rate_limit,omitempty"` + CalendarAligned *bool `json:"calendar_aligned,omitempty"` } // RegisterRoutes registers all governance-related routes for the new hierarchical system @@ -473,6 +1006,41 @@ func (h *GovernanceHandler) RegisterRoutes(r *router.Router, middlewares ...sche // getVirtualKeys handles GET /api/governance/virtual-keys - Get all virtual keys with relationships func (h *GovernanceHandler) getVirtualKeys(ctx *fasthttp.RequestCtx) { + // Check if "from_memory" query parameter is set to true + fromMemory := string(ctx.QueryArgs().Peek("from_memory")) == "true" + if fromMemory { + data := h.governanceManager.GetGovernanceData(ctx) + if data == nil { + SendError(ctx, 500, "Governance data is not available") + return + } + // Convert map to slice to match the non-memory response format (array) + virtualKeys := make([]*configstoreTables.TableVirtualKey, 0, len(data.VirtualKeys)) + for _, vk := range data.VirtualKeys { + virtualKeys = append(virtualKeys, vk) + } + sort.Slice(virtualKeys, func(i, j int) bool { + return virtualKeys[i].CreatedAt.Before(virtualKeys[j].CreatedAt) + }) + byKey := buildVKModelConfigIndex(data.ModelConfigs) + hydratedVKs := make([]*configstoreTables.TableVirtualKey, len(virtualKeys)) + for i, vk := range virtualKeys { + clone := *vk + pcs := make([]configstoreTables.TableVirtualKeyProviderConfig, len(vk.ProviderConfigs)) + copy(pcs, vk.ProviderConfigs) + clone.ProviderConfigs = pcs + applyVKGovernanceFromModelConfigs(&clone, byKey) + hydratedVKs[i] = &clone + } + SendJSON(ctx, map[string]interface{}{ + "virtual_keys": hydratedVKs, + "count": len(hydratedVKs), + "total_count": len(hydratedVKs), + "limit": len(hydratedVKs), + "offset": 0, + }) + return + } // Check for pagination/filter parameters limitStr := string(ctx.QueryArgs().Peek("limit")) offsetStr := string(ctx.QueryArgs().Peek("offset")) @@ -531,6 +1099,8 @@ func (h *GovernanceHandler) getVirtualKeys(ctx *fasthttp.RequestCtx) { SendError(ctx, 500, "Failed to retrieve virtual keys") return } + // Reverse-map governance from VK-scoped model configs for display. + h.hydrateVKListGovernance(ctx, virtualKeys) SendJSON(ctx, map[string]interface{}{ "virtual_keys": virtualKeys, "count": len(virtualKeys), @@ -548,6 +1118,7 @@ func (h *GovernanceHandler) getVirtualKeys(ctx *fasthttp.RequestCtx) { SendError(ctx, 500, "Failed to retrieve virtual keys") return } + h.hydrateVKListGovernance(ctx, virtualKeys) SendJSON(ctx, map[string]interface{}{ "virtual_keys": virtualKeys, "count": len(virtualKeys), @@ -620,46 +1191,13 @@ func (h *GovernanceHandler) createVirtualKey(ctx *fasthttp.RequestCtx) { IsActive: isActive, CalendarAligned: req.CalendarAligned, } - if req.RateLimit != nil { - rateLimit := configstoreTables.TableRateLimit{ - ID: uuid.NewString(), - TokenMaxLimit: req.RateLimit.TokenMaxLimit, - TokenResetDuration: req.RateLimit.TokenResetDuration, - RequestMaxLimit: req.RateLimit.RequestMaxLimit, - RequestResetDuration: req.RateLimit.RequestResetDuration, - TokenLastReset: time.Now(), - RequestLastReset: time.Now(), - } - if err := validateRateLimit(&rateLimit); err != nil { - return err - } - if err := h.configStore.CreateRateLimit(ctx, &rateLimit, tx); err != nil { - return err - } - vk.RateLimitID = &rateLimit.ID - } if err := h.configStore.CreateVirtualKey(ctx, &vk, tx); err != nil { return err } - // Create multi-budgets for VK - if len(req.Budgets) > 0 { - for _, b := range req.Budgets { - budget := configstoreTables.TableBudget{ - ID: uuid.NewString(), - MaxLimit: b.MaxLimit, - ResetDuration: b.ResetDuration, - LastReset: budgetLastReset(vk.CalendarAligned, b.ResetDuration), - CurrentUsage: 0, - VirtualKeyID: &vk.ID, - } - if err := validateBudget(&budget); err != nil { - return err - } - if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { - return err - } - } - } + // VK top-level and per-provider budgets/rate-limits are stored in VK-scoped model configs, + // the single source of truth, written via syncVKGovernanceToModelConfigs below. + // The per-provider desired state is accumulated while creating the provider configs. + var vkGovProviders []vkModelConfigDesired if req.ProviderConfigs != nil { for _, pc := range req.ProviderConfigs { providerName := schemas.ModelProvider(strings.TrimSpace(pc.Provider)) @@ -705,55 +1243,38 @@ func (h *GovernanceHandler) createVirtualKey(ctx *fasthttp.RequestCtx) { Keys: keys, } - // Create rate limit for provider config if provided - if pc.RateLimit != nil { - rateLimit := configstoreTables.TableRateLimit{ - ID: uuid.NewString(), - TokenMaxLimit: pc.RateLimit.TokenMaxLimit, - TokenResetDuration: pc.RateLimit.TokenResetDuration, - RequestMaxLimit: pc.RateLimit.RequestMaxLimit, - RequestResetDuration: pc.RateLimit.RequestResetDuration, - TokenLastReset: time.Now(), - RequestLastReset: time.Now(), - } - if err := validateRateLimit(&rateLimit); err != nil { - return err - } - if err := h.configStore.CreateRateLimit(ctx, &rateLimit, tx); err != nil { - return err - } - providerConfig.RateLimitID = &rateLimit.ID - } - if err := h.configStore.CreateVirtualKeyProviderConfig(ctx, providerConfig, tx); err != nil { return err } - // Create multi-budgets for provider config - if len(pc.Budgets) > 0 { - seenDurations := make(map[string]bool) - for _, b := range pc.Budgets { - if seenDurations[b.ResetDuration] { - return &badRequestError{err: fmt.Errorf("duplicate reset_duration in provider config budgets: %s", b.ResetDuration)} - } - seenDurations[b.ResetDuration] = true - budget := configstoreTables.TableBudget{ - ID: uuid.NewString(), - MaxLimit: b.MaxLimit, - ResetDuration: b.ResetDuration, - LastReset: budgetLastReset(vk.CalendarAligned, b.ResetDuration), - CurrentUsage: 0, - ProviderConfigID: &providerConfig.ID, - } - if err := validateBudget(&budget); err != nil { - return err - } - if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { - return err - } - } + // Provider-config budgets/rate-limit are stored in the VK-scoped model config + // for this provider (written by syncVKGovernanceToModelConfigs). + providerNameStr := string(providerName) + var pcRateLimit *configstoreTables.TableRateLimit + if pc.RateLimit != nil { + pcRateLimit = rateLimitFromRequestFields(pc.RateLimit.TokenMaxLimit, pc.RateLimit.TokenResetDuration, pc.RateLimit.RequestMaxLimit, pc.RateLimit.RequestResetDuration) } + vkGovProviders = append(vkGovProviders, vkModelConfigDesired{ + provider: &providerNameStr, + budgetsProvided: true, + budgets: pc.Budgets, + rateLimitProvided: pc.RateLimit != nil, + rateLimit: pcRateLimit, + }) } } + // Fold VK top-level + per-provider governance into VK-scoped model configs. + var topRateLimit *configstoreTables.TableRateLimit + if req.RateLimit != nil { + topRateLimit = rateLimitFromRequestFields(req.RateLimit.TokenMaxLimit, req.RateLimit.TokenResetDuration, req.RateLimit.RequestMaxLimit, req.RateLimit.RequestResetDuration) + } + if err := h.syncVKGovernanceToModelConfigs(ctx, tx, &vk, vkModelConfigDesired{ + budgetsProvided: true, + budgets: req.Budgets, + rateLimitProvided: req.RateLimit != nil, + rateLimit: topRateLimit, + }, vkGovProviders, true); err != nil { + return err + } if req.MCPConfigs != nil { // Check for duplicate MCPClientName values before processing seenMCPClientNames := make(map[string]bool) @@ -796,6 +1317,8 @@ func (h *GovernanceHandler) createVirtualKey(ctx *fasthttp.RequestCtx) { logger.Error("failed to reload virtual key: %v", err) preloadedVk = &vk } + // Reverse-map governance from the model configs just written, for display. + h.hydrateVKGovernance(ctx, preloadedVk) SendJSON(ctx, map[string]any{ "message": "Virtual key created successfully", @@ -806,6 +1329,31 @@ func (h *GovernanceHandler) createVirtualKey(ctx *fasthttp.RequestCtx) { // getVirtualKey handles GET /api/governance/virtual-keys/{vk_id} - Get a specific virtual key func (h *GovernanceHandler) getVirtualKey(ctx *fasthttp.RequestCtx) { vkID := ctx.UserValue("vk_id").(string) + // Check if "from_memory" query parameter is set to true + fromMemory := string(ctx.QueryArgs().Peek("from_memory")) == "true" + if fromMemory { + data := h.governanceManager.GetGovernanceData(ctx) + if data == nil { + SendError(ctx, 500, "Governance data is not available") + return + } + byKey := buildVKModelConfigIndex(data.ModelConfigs) + for _, vk := range data.VirtualKeys { + if vk.ID == vkID { + clone := *vk + pcs := make([]configstoreTables.TableVirtualKeyProviderConfig, len(vk.ProviderConfigs)) + copy(pcs, vk.ProviderConfigs) + clone.ProviderConfigs = pcs + applyVKGovernanceFromModelConfigs(&clone, byKey) + SendJSON(ctx, map[string]interface{}{ + "virtual_key": &clone, + }) + return + } + } + SendError(ctx, 404, "Virtual key not found") + return + } vk, err := h.configStore.GetVirtualKey(ctx, vkID) if err != nil { if errors.Is(err, configstore.ErrNotFound) { @@ -815,6 +1363,8 @@ func (h *GovernanceHandler) getVirtualKey(ctx *fasthttp.RequestCtx) { SendError(ctx, 500, "Failed to retrieve virtual key") return } + // Reverse-map governance from VK-scoped model configs for display. + h.hydrateVKGovernance(ctx, vk) SendJSON(ctx, map[string]interface{}{ "virtual_key": vk, @@ -893,130 +1443,10 @@ func (h *GovernanceHandler) updateVirtualKey(ctx *fasthttp.RequestCtx) { if req.CalendarAligned != nil { vk.CalendarAligned = *req.CalendarAligned } - // Handle multi-budget updates - if req.Budgets != nil { - // Validate multi-budgets - seenDurations := make(map[string]bool) - requestBudgets := append([]CreateBudgetRequest(nil), req.Budgets...) - sort.Slice(requestBudgets, func(i, j int) bool { - return compareBudgetRequestDurations(requestBudgets[i], requestBudgets[j]) - }) - for _, b := range requestBudgets { - if b.MaxLimit < 0 { - return &badRequestError{err: fmt.Errorf("budget max_limit cannot be negative: %.2f", b.MaxLimit)} - } - if _, err := configstoreTables.ParseDuration(b.ResetDuration); err != nil { - return &badRequestError{err: fmt.Errorf("invalid reset duration format: %s", b.ResetDuration)} - } - if seenDurations[b.ResetDuration] { - return &badRequestError{err: fmt.Errorf("duplicate reset_duration in budgets: %s", b.ResetDuration)} - } - seenDurations[b.ResetDuration] = true - } - - existingByID, existingByDuration := buildBudgetLookup(vk.Budgets, requestBudgets) - resetBudgetUsage := req.ResetBudgetUsage != nil && *req.ResetBudgetUsage - var reconciledBudgets []configstoreTables.TableBudget - matchedIDs := make(map[string]bool) - for _, b := range requestBudgets { - existing, found, err := findExistingBudget(b, existingByID, existingByDuration) - if err != nil { - return err - } - if found { - existing.MaxLimit = b.MaxLimit - existing.ResetDuration = b.ResetDuration - resetBudgetUsageIfRequested(&existing, resetBudgetUsage, vk.CalendarAligned) - if err := validateBudget(&existing); err != nil { - return err - } - if err := h.configStore.UpdateBudget(ctx, &existing, tx); err != nil { - return err - } - reconciledBudgets = append(reconciledBudgets, existing) - matchedIDs[existing.ID] = true - } else { - // New budget duration — create fresh - budget := configstoreTables.TableBudget{ - ID: uuid.NewString(), - MaxLimit: b.MaxLimit, - ResetDuration: b.ResetDuration, - LastReset: budgetLastReset(vk.CalendarAligned, b.ResetDuration), - CurrentUsage: 0, - VirtualKeyID: &vk.ID, - } - inheritUsageFromClosestShorterBudget(&budget, vk.Budgets, resetBudgetUsage) - if err := validateBudget(&budget); err != nil { - return err - } - if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { - return err - } - reconciledBudgets = append(reconciledBudgets, budget) - } - } - // Delete budgets that are no longer present - for _, existing := range vk.Budgets { - if !matchedIDs[existing.ID] { - if err := h.configStore.DeleteBudget(ctx, existing.ID, tx); err != nil { - return fmt.Errorf("failed to delete removed VK budget: %w", err) - } - } - } - vk.Budgets = reconciledBudgets - } - - // Handle rate limit updates - if req.RateLimit != nil { - if isRateLimitRemovalRequest(req.RateLimit) { - if vk.RateLimitID != nil { - rateLimitIDToDelete = *vk.RateLimitID - vk.RateLimitID = nil - vk.RateLimit = nil - } - } else if vk.RateLimitID != nil { - // Update existing rate limit - rateLimit := configstoreTables.TableRateLimit{} - if err := tx.First(&rateLimit, "id = ?", *vk.RateLimitID).Error; err != nil { - return err - } - - if req.RateLimit.TokenMaxLimit != nil { - rateLimit.TokenMaxLimit = req.RateLimit.TokenMaxLimit - } - if req.RateLimit.TokenResetDuration != nil { - rateLimit.TokenResetDuration = req.RateLimit.TokenResetDuration - } - if req.RateLimit.RequestMaxLimit != nil { - rateLimit.RequestMaxLimit = req.RateLimit.RequestMaxLimit - } - if req.RateLimit.RequestResetDuration != nil { - rateLimit.RequestResetDuration = req.RateLimit.RequestResetDuration - } - - if err := h.configStore.UpdateRateLimit(ctx, &rateLimit, tx); err != nil { - return err - } - } else { - // Create new rate limit - rateLimit := configstoreTables.TableRateLimit{ - ID: uuid.NewString(), - TokenMaxLimit: req.RateLimit.TokenMaxLimit, - TokenResetDuration: req.RateLimit.TokenResetDuration, - RequestMaxLimit: req.RateLimit.RequestMaxLimit, - RequestResetDuration: req.RateLimit.RequestResetDuration, - TokenLastReset: time.Now(), - RequestLastReset: time.Now(), - } - if err := validateRateLimit(&rateLimit); err != nil { - return err - } - if err := h.configStore.CreateRateLimit(ctx, &rateLimit, tx); err != nil { - return err - } - vk.RateLimitID = &rateLimit.ID - } - } + // VK top-level and per-provider budgets/rate-limits are stored in VK-scoped model + // configs (the single source of truth), written by syncVKGovernanceToModelConfigs + // below. Per-provider desired state is accumulated while reconciling provider config rows. + var vkGovProviders []vkModelConfigDesired if err := h.configStore.UpdateVirtualKey(ctx, vk, tx); err != nil { return err @@ -1094,56 +1524,22 @@ func (h *GovernanceHandler) updateVirtualKey(ctx *fasthttp.RequestCtx) { AllowAllKeys: allowAllKeys, Keys: keys, } - // Create rate limit for provider config if provided - if pc.RateLimit != nil { - rateLimit := configstoreTables.TableRateLimit{ - ID: uuid.NewString(), - TokenMaxLimit: pc.RateLimit.TokenMaxLimit, - TokenResetDuration: pc.RateLimit.TokenResetDuration, - RequestMaxLimit: pc.RateLimit.RequestMaxLimit, - RequestResetDuration: pc.RateLimit.RequestResetDuration, - TokenLastReset: time.Now(), - RequestLastReset: time.Now(), - } - if err := validateRateLimit(&rateLimit); err != nil { - return err - } - if err := h.configStore.CreateRateLimit(ctx, &rateLimit, tx); err != nil { - return err - } - providerConfig.RateLimitID = &rateLimit.ID - } if err := h.configStore.CreateVirtualKeyProviderConfig(ctx, providerConfig, tx); err != nil { return err } - // Create multi-budgets for new provider config in update - if len(pc.Budgets) > 0 { - seenDurations := make(map[string]bool) - pcBudgets := append([]CreateBudgetRequest(nil), pc.Budgets...) - sort.Slice(pcBudgets, func(i, j int) bool { - return compareBudgetRequestDurations(pcBudgets[i], pcBudgets[j]) - }) - for _, b := range pcBudgets { - if seenDurations[b.ResetDuration] { - return &badRequestError{err: fmt.Errorf("duplicate reset_duration in provider config budgets: %s", b.ResetDuration)} - } - seenDurations[b.ResetDuration] = true - budget := configstoreTables.TableBudget{ - ID: uuid.NewString(), - MaxLimit: b.MaxLimit, - ResetDuration: b.ResetDuration, - LastReset: budgetLastReset(vk.CalendarAligned, b.ResetDuration), - CurrentUsage: 0, - ProviderConfigID: &providerConfig.ID, - } - if err := validateBudget(&budget); err != nil { - return err - } - if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { - return err - } - } + // Provider-config governance is stored in the VK-scoped model config for this provider. + pName := string(providerName) + var pcRL *configstoreTables.TableRateLimit + if pc.RateLimit != nil { + pcRL = rateLimitFromRequestFields(pc.RateLimit.TokenMaxLimit, pc.RateLimit.TokenResetDuration, pc.RateLimit.RequestMaxLimit, pc.RateLimit.RequestResetDuration) } + vkGovProviders = append(vkGovProviders, vkModelConfigDesired{ + provider: &pName, + budgetsProvided: true, + budgets: pc.Budgets, + rateLimitProvided: pc.RateLimit != nil, + rateLimit: pcRL, + }) } else { // Update existing provider config existing, ok := existingConfigsMap[*pc.ID] @@ -1183,134 +1579,27 @@ func (h *GovernanceHandler) updateVirtualKey(ctx *fasthttp.RequestCtx) { existing.AllowAllKeys = allowAllKeys existing.Keys = keys - // Handle multi-budget updates for existing provider config - if pc.Budgets != nil { - // Validate - seenDurations := make(map[string]bool) - pcBudgets := append([]CreateBudgetRequest(nil), pc.Budgets...) - sort.Slice(pcBudgets, func(i, j int) bool { - return compareBudgetRequestDurations(pcBudgets[i], pcBudgets[j]) - }) - for _, b := range pcBudgets { - if b.MaxLimit < 0 { - return &badRequestError{err: fmt.Errorf("provider config budget max_limit cannot be negative: %.2f", b.MaxLimit)} - } - if _, err := configstoreTables.ParseDuration(b.ResetDuration); err != nil { - return &badRequestError{err: fmt.Errorf("invalid provider config budget reset duration format: %s", b.ResetDuration)} - } - if seenDurations[b.ResetDuration] { - return &badRequestError{err: fmt.Errorf("duplicate reset_duration in provider config budgets: %s", b.ResetDuration)} - } - seenDurations[b.ResetDuration] = true - } - - sort.Slice(existing.Budgets, func(i, j int) bool { - if existing.Budgets[i].ResetDuration == existing.Budgets[j].ResetDuration { - return existing.Budgets[i].ID < existing.Budgets[j].ID - } - return existing.Budgets[i].ResetDuration < existing.Budgets[j].ResetDuration - }) - - pcExistingByID, pcExistingByDuration := buildBudgetLookup(existing.Budgets, pcBudgets) - resetBudgetUsage := req.ResetBudgetUsage != nil && *req.ResetBudgetUsage - var pcReconciledBudgets []configstoreTables.TableBudget - pcMatchedIDs := make(map[string]bool) - for _, b := range pcBudgets { - eb, found, err := findExistingBudget(b, pcExistingByID, pcExistingByDuration) - if err != nil { - return err - } - if found { - eb.MaxLimit = b.MaxLimit - eb.ResetDuration = b.ResetDuration - resetBudgetUsageIfRequested(&eb, resetBudgetUsage, vk.CalendarAligned) - if err := validateBudget(&eb); err != nil { - return err - } - if err := h.configStore.UpdateBudget(ctx, &eb, tx); err != nil { - return err - } - pcReconciledBudgets = append(pcReconciledBudgets, eb) - pcMatchedIDs[eb.ID] = true - } else { - // New budget duration — create fresh - budget := configstoreTables.TableBudget{ - ID: uuid.NewString(), - MaxLimit: b.MaxLimit, - ResetDuration: b.ResetDuration, - LastReset: budgetLastReset(vk.CalendarAligned, b.ResetDuration), - CurrentUsage: 0, - ProviderConfigID: &existing.ID, - } - inheritUsageFromClosestShorterBudget(&budget, existing.Budgets, resetBudgetUsage) - if err := validateBudget(&budget); err != nil { - return err - } - if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { - return err - } - pcReconciledBudgets = append(pcReconciledBudgets, budget) - } - } - // Delete budgets that are no longer present - for _, eb := range existing.Budgets { - if !pcMatchedIDs[eb.ID] { - if err := h.configStore.DeleteBudget(ctx, eb.ID, tx); err != nil { - return fmt.Errorf("failed to delete removed provider config budget: %w", err) - } - } - } - existing.Budgets = pcReconciledBudgets - } - // Handle rate limit updates for provider config + // Provider-config governance is stored in the VK-scoped model config for this + // provider (written by syncVKGovernanceToModelConfigs). pc.Budgets == nil + // leaves existing budgets unchanged; an explicit set reconciles them. + pName := string(providerName) + rlRemove := false + var pcRL *configstoreTables.TableRateLimit if pc.RateLimit != nil { if isRateLimitRemovalRequest(pc.RateLimit) { - if existing.RateLimitID != nil { - providerRateLimitIDsToDelete = append(providerRateLimitIDsToDelete, *existing.RateLimitID) - existing.RateLimitID = nil - existing.RateLimit = nil - } - } else if existing.RateLimitID != nil { - // Update existing rate limit - rateLimit := configstoreTables.TableRateLimit{} - if err := tx.First(&rateLimit, "id = ?", *existing.RateLimitID).Error; err != nil { - return err - } - if pc.RateLimit.TokenMaxLimit != nil { - rateLimit.TokenMaxLimit = pc.RateLimit.TokenMaxLimit - } - if pc.RateLimit.TokenResetDuration != nil { - rateLimit.TokenResetDuration = pc.RateLimit.TokenResetDuration - } - if pc.RateLimit.RequestMaxLimit != nil { - rateLimit.RequestMaxLimit = pc.RateLimit.RequestMaxLimit - } - if pc.RateLimit.RequestResetDuration != nil { - rateLimit.RequestResetDuration = pc.RateLimit.RequestResetDuration - } - if err := h.configStore.UpdateRateLimit(ctx, &rateLimit, tx); err != nil { - return err - } + rlRemove = true } else { - // Create new rate limit for existing provider config - rateLimit := configstoreTables.TableRateLimit{ - ID: uuid.NewString(), - TokenMaxLimit: pc.RateLimit.TokenMaxLimit, - TokenResetDuration: pc.RateLimit.TokenResetDuration, - RequestMaxLimit: pc.RateLimit.RequestMaxLimit, - RequestResetDuration: pc.RateLimit.RequestResetDuration, - TokenLastReset: time.Now(), - RequestLastReset: time.Now(), - } - if err := validateRateLimit(&rateLimit); err != nil { - return err - } - if err := h.configStore.CreateRateLimit(ctx, &rateLimit, tx); err != nil { - return err - } - existing.RateLimitID = &rateLimit.ID + pcRL = rateLimitFromRequestFields(pc.RateLimit.TokenMaxLimit, pc.RateLimit.TokenResetDuration, pc.RateLimit.RequestMaxLimit, pc.RateLimit.RequestResetDuration) } } + vkGovProviders = append(vkGovProviders, vkModelConfigDesired{ + provider: &pName, + budgetsProvided: pc.Budgets != nil, + budgets: pc.Budgets, + rateLimitProvided: pc.RateLimit != nil, + rateLimitRemove: rlRemove, + rateLimit: pcRL, + }) if err := h.configStore.UpdateVirtualKeyProviderConfig(ctx, &existing, tx); err != nil { return err } @@ -1335,6 +1624,24 @@ func (h *GovernanceHandler) updateVirtualKey(ctx *fasthttp.RequestCtx) { } } } + // Fold VK governance into VK-scoped model configs. The top-level is always reconciled + // (provided-aware); per-provider configs are reconciled only when the request supplied + // provider_configs (else they're left untouched). + top := vkModelConfigDesired{ + budgetsProvided: req.Budgets != nil, + budgets: req.Budgets, + rateLimitProvided: req.RateLimit != nil, + } + if req.RateLimit != nil { + if isRateLimitRemovalRequest(req.RateLimit) { + top.rateLimitRemove = true + } else { + top.rateLimit = rateLimitFromRequestFields(req.RateLimit.TokenMaxLimit, req.RateLimit.TokenResetDuration, req.RateLimit.RequestMaxLimit, req.RateLimit.RequestResetDuration) + } + } + if err := h.syncVKGovernanceToModelConfigs(ctx, tx, vk, top, vkGovProviders, req.ProviderConfigs != nil); err != nil { + return err + } if req.MCPConfigs != nil { // Check for duplicate MCPClientName values among all configs before processing seenMCPClientNames := make(map[string]bool) @@ -1450,6 +1757,8 @@ func (h *GovernanceHandler) updateVirtualKey(ctx *fasthttp.RequestCtx) { logger.Error("failed to load relationships for updated VK: %v", err) preloadedVk = vk } + // Reverse-map governance from VK-scoped model configs for display. + h.hydrateVKGovernance(ctx, preloadedVk) if _, err := h.governanceManager.ReloadVirtualKey(ctx, vk.ID); err != nil { // Should never happen but just in case logger.Error("failed to reload virtual key after update: %v", err) @@ -2105,6 +2414,10 @@ func (h *GovernanceHandler) createCustomer(ctx *fasthttp.RequestCtx) { SendError(ctx, 400, "Invalid JSON") return } + if len(req.Budgets) > 0 && req.Budget != nil { + SendError(ctx, 400, "only one of 'budget' or 'budgets' may be set") + return + } // Validate required fields if req.Name == "" { SendError(ctx, 400, "Customer name is required") @@ -2123,28 +2436,28 @@ func (h *GovernanceHandler) createCustomer(ctx *fasthttp.RequestCtx) { return } } + // Coerce legacy singular budget into the multi-budget slice. + budgetRequests := req.Budgets + if len(budgetRequests) == 0 && req.Budget != nil { + budgetRequests = []CreateBudgetRequest{{ + MaxLimit: req.Budget.MaxLimit, + ResetDuration: req.Budget.ResetDuration, + }} + } var customer configstoreTables.TableCustomer if err := h.configStore.ExecuteTransaction(ctx, func(tx *gorm.DB) error { customer = configstoreTables.TableCustomer{ - ID: uuid.NewString(), - Name: req.Name, + ID: uuid.NewString(), + Name: req.Name, + CalendarAligned: req.CalendarAligned, } - - if req.Budget != nil { - budget := configstoreTables.TableBudget{ - ID: uuid.NewString(), - MaxLimit: req.Budget.MaxLimit, - ResetDuration: req.Budget.ResetDuration, - LastReset: budgetLastReset(false, req.Budget.ResetDuration), - CurrentUsage: 0, - } - if err := validateBudget(&budget); err != nil { - return err - } - if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { + if err := h.configStore.CreateCustomer(ctx, &customer, tx); err != nil { + return err + } + if len(budgetRequests) > 0 { + if err := h.reconcileCustomerBudgets(ctx, tx, &customer, budgetRequests); err != nil { return err } - customer.BudgetID = &budget.ID } if req.RateLimit != nil { rateLimit := configstoreTables.TableRateLimit{ @@ -2160,12 +2473,17 @@ func (h *GovernanceHandler) createCustomer(ctx *fasthttp.RequestCtx) { return err } customer.RateLimitID = &rateLimit.ID - } - if err := h.configStore.CreateCustomer(ctx, &customer, tx); err != nil { - return err + if err := h.configStore.UpdateCustomer(ctx, &customer, tx); err != nil { + return err + } } return nil }); err != nil { + var badReqErr *badRequestError + if errors.As(err, &badReqErr) { + SendError(ctx, 400, err.Error()) + return + } SendError(ctx, 500, "failed to create customer") return } @@ -2205,6 +2523,10 @@ func (h *GovernanceHandler) updateCustomer(ctx *fasthttp.RequestCtx) { SendError(ctx, 400, "Invalid JSON") return } + if req.Budgets != nil && req.Budget != nil { + SendError(ctx, 400, "only one of 'budget' or 'budgets' may be set") + return + } // Fetching customer from database customer, err := h.configStore.GetCustomer(ctx, customerID) if err != nil { @@ -2217,69 +2539,35 @@ func (h *GovernanceHandler) updateCustomer(ctx *fasthttp.RequestCtx) { } // Updating customer in database if err := h.configStore.ExecuteTransaction(ctx, func(tx *gorm.DB) error { - // Track IDs to delete after updating the customer (to avoid FK constraint) - var budgetIDToDelete, rateLimitIDToDelete string + var rateLimitIDToDelete string // Update fields if provided if req.Name != nil { customer.Name = *req.Name } - // Handle budget updates - if req.Budget != nil { - // Check if budget removal is requested (all fields nil) - budgetIsEmpty := isBudgetRemovalRequest(req.Budget) - if budgetIsEmpty { - // Mark budget for deletion after FK is removed - if customer.BudgetID != nil { - budgetIDToDelete = *customer.BudgetID - customer.BudgetID = nil - customer.Budget = nil - } - } else if customer.BudgetID != nil { - // Update existing budget — all fields are optional (partial update) - budget := configstoreTables.TableBudget{} - if err := tx.First(&budget, "id = ?", *customer.BudgetID).Error; err != nil { - return err - } - if req.Budget.MaxLimit != nil { - budget.MaxLimit = *req.Budget.MaxLimit - } - if req.Budget.ResetDuration != nil { - budget.ResetDuration = *req.Budget.ResetDuration - } - if err := validateBudget(&budget); err != nil { - return err - } - if err := h.configStore.UpdateBudget(ctx, &budget, tx); err != nil { - return err - } - customer.Budget = &budget - } else { - // Create new budget - if req.Budget.MaxLimit == nil || req.Budget.ResetDuration == nil { - return fmt.Errorf("both max_limit and reset_duration are required when creating a new budget") - } - if *req.Budget.MaxLimit < 0 { - return fmt.Errorf("budget max_limit cannot be negative: %.2f", *req.Budget.MaxLimit) - } - if _, err := configstoreTables.ParseDuration(*req.Budget.ResetDuration); err != nil { - return fmt.Errorf("invalid reset duration format: %s", *req.Budget.ResetDuration) - } - budget := configstoreTables.TableBudget{ - ID: uuid.NewString(), - MaxLimit: *req.Budget.MaxLimit, - ResetDuration: *req.Budget.ResetDuration, - LastReset: budgetLastReset(false, *req.Budget.ResetDuration), - CurrentUsage: 0, - } - if err := validateBudget(&budget); err != nil { - return err - } - if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { - return err - } - customer.BudgetID = &budget.ID - customer.Budget = &budget + wasCalendarAligned := customer.CalendarAligned + if req.CalendarAligned != nil { + customer.CalendarAligned = *req.CalendarAligned + } + calendarAlignmentJustEnabled := !wasCalendarAligned && customer.CalendarAligned + // Handle budget updates: prefer Budgets slice; coerce legacy Budget if needed. + effectiveBudgets := req.Budgets + if effectiveBudgets == nil && req.Budget != nil { + if len(customer.Budgets) > 1 { + return &badRequestError{err: fmt.Errorf("deprecated 'budget' field cannot be used when multiple budgets already exist; use 'budgets'")} + } + var existingBudget *configstoreTables.TableBudget + if len(customer.Budgets) == 1 { + existingBudget = &customer.Budgets[0] + } + effectiveBudgets = coerceLegacyBudget(req.Budget, existingBudget) + if effectiveBudgets == nil && !isBudgetRemovalRequest(req.Budget) { + return &badRequestError{err: fmt.Errorf("both max_limit and reset_duration are required when creating a new budget")} + } + } + if effectiveBudgets != nil { + if err := h.reconcileCustomerBudgets(ctx, tx, customer, *effectiveBudgets); err != nil { + return err } } // Handle rate limit updates @@ -2331,16 +2619,46 @@ func (h *GovernanceHandler) updateCustomer(ctx *fasthttp.RequestCtx) { customer.RateLimit = &rateLimit } } + // Snap budgets and rate limit to the current calendar period when calendar + // alignment transitions false → true. Runs after reconciliation so combined + // "toggle + budgets" requests see the final reconciled state. + if calendarAlignmentJustEnabled { + now := time.Now() + for i := range customer.Budgets { + b := &customer.Budgets[i] + if !configstoreTables.IsCalendarAlignableDuration(b.ResetDuration) { + continue + } + b.LastReset = configstoreTables.GetCalendarPeriodStart(b.ResetDuration, now) + b.CurrentUsage = 0 + if err := h.configStore.UpdateBudget(ctx, b, tx); err != nil { + return fmt.Errorf("failed to snap customer budget %s on calendar-align enable: %w", b.ID, err) + } + } + if customer.RateLimit != nil { + rl := customer.RateLimit + snapped := false + if rl.TokenResetDuration != nil && configstoreTables.IsCalendarAlignableDuration(*rl.TokenResetDuration) { + rl.TokenLastReset = configstoreTables.GetCalendarPeriodStart(*rl.TokenResetDuration, now) + rl.TokenCurrentUsage = 0 + snapped = true + } + if rl.RequestResetDuration != nil && configstoreTables.IsCalendarAlignableDuration(*rl.RequestResetDuration) { + rl.RequestLastReset = configstoreTables.GetCalendarPeriodStart(*rl.RequestResetDuration, now) + rl.RequestCurrentUsage = 0 + snapped = true + } + if snapped { + if err := h.configStore.UpdateRateLimit(ctx, rl, tx); err != nil { + return fmt.Errorf("failed to snap customer rate limit on calendar-align enable: %w", err) + } + } + } + } if err := h.configStore.UpdateCustomer(ctx, customer, tx); err != nil { return err } - // Now that FK references are removed, delete the orphaned budget/rate limit - if budgetIDToDelete != "" { - if err := tx.Delete(&configstoreTables.TableBudget{}, "id = ?", budgetIDToDelete).Error; err != nil { - return err - } - } if rateLimitIDToDelete != "" { if err := tx.Delete(&configstoreTables.TableRateLimit{}, "id = ?", rateLimitIDToDelete).Error; err != nil { return err @@ -2349,6 +2667,11 @@ func (h *GovernanceHandler) updateCustomer(ctx *fasthttp.RequestCtx) { return nil }); err != nil { + var badReqErr *badRequestError + if errors.As(err, &badReqErr) { + SendError(ctx, 400, err.Error()) + return + } SendError(ctx, 500, "Failed to update customer") return } @@ -2489,15 +2812,98 @@ func validateBudget(budget *configstoreTables.TableBudget) error { // getModelConfigs handles GET /api/governance/model-configs - Get all model configs func (h *GovernanceHandler) getModelConfigs(ctx *fasthttp.RequestCtx) { + fromMemory := string(ctx.QueryArgs().Peek("from_memory")) == "true" + if fromMemory { + data := h.governanceManager.GetGovernanceData(ctx) + if data == nil { + SendError(ctx, 500, "Governance data is not available") + return + } + search := string(ctx.QueryArgs().Peek("search")) + scopeFilter := string(ctx.QueryArgs().Peek("scope")) + providerFilter := string(ctx.QueryArgs().Peek("provider")) + // Deep-copy into a value slice: top-level struct copy + nested pointer/slice fields + // so we never alias or mutate live governance state during serialization. + all := make([]configstoreTables.TableModelConfig, 0, len(data.ModelConfigs)) + for _, mc := range data.ModelConfigs { + if mc == nil { + continue + } + if search != "" && !strings.Contains(strings.ToLower(mc.ModelName), strings.ToLower(search)) { + continue + } + if scopeFilter != "" && mc.Scope != scopeFilter { + continue + } + if providerFilter != "" { + if mc.Provider == nil || *mc.Provider != providerFilter { + continue + } + } + clone := *mc + if len(mc.Budgets) > 0 { + bs := make([]configstoreTables.TableBudget, len(mc.Budgets)) + copy(bs, mc.Budgets) + clone.Budgets = bs + } + if mc.Budget != nil { + b := *mc.Budget + clone.Budget = &b + } + if mc.RateLimit != nil { + rl := *mc.RateLimit + clone.RateLimit = &rl + } + all = append(all, clone) + } + totalCount := len(all) + // Apply pagination if requested, otherwise return all (consistent with DB path). + limitStr := string(ctx.QueryArgs().Peek("limit")) + offsetStr := string(ctx.QueryArgs().Peek("offset")) + offset := 0 + limit := totalCount + if offsetStr != "" { + if n, err := strconv.Atoi(offsetStr); err == nil && n >= 0 { + offset = n + } + } + if limitStr != "" { + if n, err := strconv.Atoi(limitStr); err == nil && n > 0 { + limit = n + } + } + if offset > totalCount { + offset = totalCount + } + end := offset + limit + if end > totalCount { + end = totalCount + } + page := all[offset:end] + h.enrichModelConfigScopeNames(ctx, page) + SendJSON(ctx, map[string]any{ + "model_configs": page, + "count": len(page), + "total_count": totalCount, + "limit": limit, + "offset": offset, + }) + return + } + // Check for pagination parameters limitStr := string(ctx.QueryArgs().Peek("limit")) offsetStr := string(ctx.QueryArgs().Peek("offset")) search := string(ctx.QueryArgs().Peek("search")) + scope := string(ctx.QueryArgs().Peek("scope")) + provider := string(ctx.QueryArgs().Peek("provider")) - if limitStr != "" || offsetStr != "" || search != "" { + if limitStr != "" || offsetStr != "" || search != "" || scope != "" || provider != "" { // Paginated path params := configstore.ModelConfigsQueryParams{ - Search: search, + Search: search, + Scope: scope, + Provider: provider, } if limitStr != "" { n, err := strconv.Atoi(limitStr) @@ -2531,6 +2937,7 @@ func (h *GovernanceHandler) getModelConfigs(ctx *fasthttp.RequestCtx) { SendError(ctx, 500, "Failed to retrieve model configs") return } + h.enrichModelConfigScopeNames(ctx, modelConfigs) SendJSON(ctx, map[string]any{ "model_configs": modelConfigs, "count": len(modelConfigs), @@ -2548,6 +2955,7 @@ func (h *GovernanceHandler) getModelConfigs(ctx *fasthttp.RequestCtx) { SendError(ctx, 500, "Failed to retrieve model configs") return } + h.enrichModelConfigScopeNames(ctx, modelConfigs) SendJSON(ctx, map[string]any{ "model_configs": modelConfigs, "count": len(modelConfigs), @@ -2569,11 +2977,45 @@ func (h *GovernanceHandler) getModelConfig(ctx *fasthttp.RequestCtx) { SendError(ctx, 500, "Failed to retrieve model config") return } + h.resolveModelConfigScopeName(ctx, mc, map[string]string{}) SendJSON(ctx, map[string]interface{}{ "model_config": mc, }) } +// resolveModelConfigScopeName populates the transient ScopeName for a single non-global +// model config by dispatching to the resolver registered for mc.Scope. Unknown scopes +// (no resolver registered) and resolution failures are non-fatal — ScopeName stays empty +// and the UI falls back to rendering the scope_id. The cache lets callers dedupe lookups +// across many configs; it is keyed by (scope, scope_id) so distinct scopes never collide. +func (h *GovernanceHandler) resolveModelConfigScopeName(ctx context.Context, mc *configstoreTables.TableModelConfig, cache map[string]string) { + if mc == nil || mc.Scope == "" || mc.ScopeID == nil { + return + } + resolver, ok := lookupScopeNameResolver(mc.Scope) + if !ok { + return + } + id := *mc.ScopeID + key := mc.Scope + "|" + id + name, cached := cache[key] + if !cached { + if resolved, found := resolver(ctx, id); found { + name = resolved + } + cache[key] = name + } + mc.ScopeName = name +} + +// enrichModelConfigScopeNames populates ScopeName for each non-global config in the slice. +func (h *GovernanceHandler) enrichModelConfigScopeNames(ctx context.Context, configs []configstoreTables.TableModelConfig) { + cache := map[string]string{} + for i := range configs { + h.resolveModelConfigScopeName(ctx, &configs[i], cache) + } +} + // createModelConfig handles POST /api/governance/model-configs - Create a new model config func (h *GovernanceHandler) createModelConfig(ctx *fasthttp.RequestCtx) { var req CreateModelConfigRequest @@ -2586,60 +3028,89 @@ func (h *GovernanceHandler) createModelConfig(ctx *fasthttp.RequestCtx) { SendError(ctx, 400, "Model name is required") return } - // Check if model config with same (model_name, provider) already exists - existing, err := h.configStore.GetModelConfig(ctx, req.ModelName, req.Provider) + // Default and validate scope. Global is the implicit default (preserves + // pre-scope behavior). Non-global scopes require a scope_id naming the target. + // scopeCalendarAligned is inherited from the owning VK for virtual_key scope. + scopeCalendarAligned := false + if req.Scope == "" { + req.Scope = configstoreTables.ModelConfigScopeGlobal + } + if !configstoreTables.IsValidModelConfigScope(req.Scope) { + SendError(ctx, 400, fmt.Sprintf("Invalid scope %q", req.Scope)) + return + } + if req.Scope == configstoreTables.ModelConfigScopeGlobal { + req.ScopeID = nil // normalize: global configs must not carry a scope_id + } else { + if req.ScopeID == nil || *req.ScopeID == "" { + SendError(ctx, 400, "scope_id is required when scope is not global") + return + } + // For the virtual_key scope, the scope_id must reference an existing VK. + if req.Scope == configstoreTables.ModelConfigScopeVirtualKey { + vk, vkErr := h.configStore.GetVirtualKey(ctx, *req.ScopeID) + if vkErr != nil { + if errors.Is(vkErr, configstore.ErrNotFound) { + SendError(ctx, 400, fmt.Sprintf("Virtual key '%s' not found", *req.ScopeID)) + } else { + logger.Error("failed to verify virtual key for model config scope: %v", vkErr) + SendError(ctx, 500, "Failed to verify virtual key") + } + return + } + // Inherit calendar alignment from the owning VK so budgets reset consistently. + scopeCalendarAligned = vk.CalendarAligned + } + } + // Check if a model config with the same identity (scope, scope_id, model_name, provider) already exists + existing, err := h.configStore.GetModelConfig(ctx, req.Scope, req.ScopeID, req.ModelName, req.Provider) if err != nil && err != configstore.ErrNotFound { logger.Error("failed to check existing model config: %v", err) SendError(ctx, 500, fmt.Sprintf("Failed to check existing model config: %v", err)) return } if existing != nil { + scopeDesc := "global" + if req.Scope != configstoreTables.ModelConfigScopeGlobal { + scopeDesc = fmt.Sprintf("%s '%s'", req.Scope, *req.ScopeID) + } if req.Provider != nil { - SendError(ctx, 409, fmt.Sprintf("Model config for model '%s' with provider '%s' already exists", req.ModelName, *req.Provider)) + SendError(ctx, 409, fmt.Sprintf("Model config for model '%s' with provider '%s' (%s) already exists", req.ModelName, *req.Provider, scopeDesc)) } else { - SendError(ctx, 409, fmt.Sprintf("Model config for model '%s' (global) already exists", req.ModelName)) + SendError(ctx, 409, fmt.Sprintf("Model config for model '%s' (%s) already exists", req.ModelName, scopeDesc)) } return } - // Validate budget if provided - if req.Budget != nil { - if req.Budget.MaxLimit < 0 { - SendError(ctx, 400, fmt.Sprintf("Budget max_limit cannot be negative: %.2f", req.Budget.MaxLimit)) + // Validate budgets if provided + seenDurations := make(map[string]bool, len(req.Budgets)) + for i := range req.Budgets { + if req.Budgets[i].MaxLimit < 0 { + SendError(ctx, 400, fmt.Sprintf("Budget max_limit cannot be negative: %.2f", req.Budgets[i].MaxLimit)) + return + } + if _, err := configstoreTables.ParseDuration(req.Budgets[i].ResetDuration); err != nil { + SendError(ctx, 400, fmt.Sprintf("Invalid reset duration format: %s", req.Budgets[i].ResetDuration)) return } - if _, err := configstoreTables.ParseDuration(req.Budget.ResetDuration); err != nil { - SendError(ctx, 400, fmt.Sprintf("Invalid reset duration format: %s", req.Budget.ResetDuration)) + if seenDurations[req.Budgets[i].ResetDuration] { + SendError(ctx, 400, fmt.Sprintf("Duplicate reset_duration in budgets: %s", req.Budgets[i].ResetDuration)) return } + seenDurations[req.Budgets[i].ResetDuration] = true } var mc configstoreTables.TableModelConfig if err := h.configStore.ExecuteTransaction(ctx, func(tx *gorm.DB) error { mc = configstoreTables.TableModelConfig{ - ID: uuid.NewString(), - ModelName: req.ModelName, - Provider: req.Provider, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - // Create budget if provided - if req.Budget != nil { - budget := configstoreTables.TableBudget{ - ID: uuid.NewString(), - MaxLimit: req.Budget.MaxLimit, - ResetDuration: req.Budget.ResetDuration, - LastReset: budgetLastReset(false, req.Budget.ResetDuration), - CurrentUsage: 0, - } - if err := validateBudget(&budget); err != nil { - return err - } - if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { - return err - } - mc.BudgetID = &budget.ID - mc.Budget = &budget - } - // Create rate limit if provided + ID: uuid.NewString(), + ModelName: req.ModelName, + Provider: req.Provider, + Scope: req.Scope, + ScopeID: req.ScopeID, + CalendarAligned: scopeCalendarAligned, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + // Create rate limit if provided (mc references it via RateLimitID, so create first). if req.RateLimit != nil { rateLimit := configstoreTables.TableRateLimit{ ID: uuid.NewString(), @@ -2659,9 +3130,28 @@ func (h *GovernanceHandler) createModelConfig(ctx *fasthttp.RequestCtx) { mc.RateLimitID = &rateLimit.ID mc.RateLimit = &rateLimit } + // Create the model config row first so its budgets can reference it via ModelConfigID. if err := h.configStore.CreateModelConfig(ctx, &mc, tx); err != nil { return err } + // Create owned budgets (a model config may carry multiple). + for _, b := range req.Budgets { + budget := configstoreTables.TableBudget{ + ID: uuid.NewString(), + MaxLimit: b.MaxLimit, + ResetDuration: b.ResetDuration, + LastReset: budgetLastReset(mc.CalendarAligned, b.ResetDuration), + CurrentUsage: 0, + ModelConfigID: &mc.ID, + } + if err := validateBudget(&budget); err != nil { + return err + } + if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { + return err + } + mc.Budgets = append(mc.Budgets, budget) + } return nil }); err != nil { logger.Error("failed to create model config: %v", err) @@ -2674,6 +3164,7 @@ func (h *GovernanceHandler) createModelConfig(ctx *fasthttp.RequestCtx) { logger.Error("failed to reload model config in memory: %v", err) preloadedMC = &mc } + h.resolveModelConfigScopeName(ctx, preloadedMC, map[string]string{}) SendJSON(ctx, map[string]interface{}{ "message": "Model config created successfully", "model_config": preloadedMC, @@ -2698,8 +3189,8 @@ func (h *GovernanceHandler) updateModelConfig(ctx *fasthttp.RequestCtx) { return } if err := h.configStore.ExecuteTransaction(ctx, func(tx *gorm.DB) error { - // Track IDs to delete after updating the model config (to avoid FK constraint) - var budgetIDToDelete, rateLimitIDToDelete string + // Track rate-limit ID to delete after updating the model config (to avoid FK constraint). + var rateLimitIDToDelete string // Update fields if provided if req.ModelName != nil { @@ -2709,62 +3200,12 @@ func (h *GovernanceHandler) updateModelConfig(ctx *fasthttp.RequestCtx) { if req.Provider != nil { mc.Provider = req.Provider } - // Handle budget updates - if req.Budget != nil { - // Check if budget removal is requested (all fields nil) - budgetIsEmpty := isBudgetRemovalRequest(req.Budget) - if budgetIsEmpty { - // Mark budget for deletion after FK is removed - if mc.BudgetID != nil { - budgetIDToDelete = *mc.BudgetID - mc.BudgetID = nil - mc.Budget = nil - } - } else if mc.BudgetID != nil { - // Update existing budget — all fields are optional (partial update) - budget := configstoreTables.TableBudget{} - if err := tx.First(&budget, "id = ?", *mc.BudgetID).Error; err != nil { - return err - } - if req.Budget.MaxLimit != nil { - budget.MaxLimit = *req.Budget.MaxLimit - } - if req.Budget.ResetDuration != nil { - budget.ResetDuration = *req.Budget.ResetDuration - } - if err := validateBudget(&budget); err != nil { - return err - } - if err := h.configStore.UpdateBudget(ctx, &budget, tx); err != nil { - return err - } - mc.Budget = &budget - } else { - // Create new budget - if req.Budget.MaxLimit == nil || req.Budget.ResetDuration == nil { - return fmt.Errorf("both max_limit and reset_duration are required when creating a new budget") - } - if *req.Budget.MaxLimit < 0 { - return fmt.Errorf("budget max_limit cannot be negative: %.2f", *req.Budget.MaxLimit) - } - if _, err := configstoreTables.ParseDuration(*req.Budget.ResetDuration); err != nil { - return fmt.Errorf("invalid reset duration format: %s", *req.Budget.ResetDuration) - } - budget := configstoreTables.TableBudget{ - ID: uuid.NewString(), - MaxLimit: *req.Budget.MaxLimit, - ResetDuration: *req.Budget.ResetDuration, - LastReset: budgetLastReset(false, *req.Budget.ResetDuration), - CurrentUsage: 0, - } - if err := validateBudget(&budget); err != nil { - return err - } - if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { - return err - } - mc.BudgetID = &budget.ID - mc.Budget = &budget + // Handle budget updates: req.Budgets is the full desired set. A non-nil empty + // slice removes all budgets; omitting the field leaves them unchanged. Budgets + // are owned via ModelConfigID, so no model-config FK juggling is needed. + if req.Budgets != nil { + if err := h.reconcileModelConfigBudgets(ctx, tx, mc, req.Budgets); err != nil { + return err } } // Handle rate limit updates @@ -2822,12 +3263,7 @@ func (h *GovernanceHandler) updateModelConfig(ctx *fasthttp.RequestCtx) { return err } - // Now that FK references are removed, delete the orphaned budget/rate limit - if budgetIDToDelete != "" { - if err := tx.Delete(&configstoreTables.TableBudget{}, "id = ?", budgetIDToDelete).Error; err != nil { - return err - } - } + // Now that the FK reference is removed, delete the orphaned rate limit. if rateLimitIDToDelete != "" { if err := tx.Delete(&configstoreTables.TableRateLimit{}, "id = ?", rateLimitIDToDelete).Error; err != nil { return err @@ -2846,6 +3282,7 @@ func (h *GovernanceHandler) updateModelConfig(ctx *fasthttp.RequestCtx) { logger.Error("failed to reload model config in memory: %v", err) updatedMC = mc } + h.resolveModelConfigScopeName(ctx, updatedMC, map[string]string{}) SendJSON(ctx, map[string]interface{}{ "message": "Model config updated successfully", "model_config": updatedMC, @@ -2889,28 +3326,63 @@ func (h *GovernanceHandler) deleteModelConfig(ctx *fasthttp.RequestCtx) { // ProviderGovernanceResponse represents a provider with its governance settings type ProviderGovernanceResponse struct { - Provider string `json:"provider"` - Budget *configstoreTables.TableBudget `json:"budget,omitempty"` - RateLimit *configstoreTables.TableRateLimit `json:"rate_limit,omitempty"` + Provider string `json:"provider"` + Budget *configstoreTables.TableBudget `json:"budget,omitempty"` // deprecated: use budgets + Budgets []configstoreTables.TableBudget `json:"budgets,omitempty"` + RateLimit *configstoreTables.TableRateLimit `json:"rate_limit,omitempty"` + CalendarAligned bool `json:"calendar_aligned"` +} + +// modelConfigToProviderGovernance converts a model config to a ProviderGovernanceResponse. +// Returns false if the config does not represent provider-level governance +// (i.e. not scope=global, model_name="*", with a provider set). +func modelConfigToProviderGovernance(mc *configstoreTables.TableModelConfig) (ProviderGovernanceResponse, bool) { + if mc == nil || mc.Scope != configstoreTables.ModelConfigScopeGlobal || + mc.ModelName != configstoreTables.ModelConfigAllModels || mc.Provider == nil { + return ProviderGovernanceResponse{}, false + } + var budget *configstoreTables.TableBudget + if len(mc.Budgets) > 0 { + budget = &mc.Budgets[0] + } + budgets := make([]configstoreTables.TableBudget, len(mc.Budgets)) + copy(budgets, mc.Budgets) + return ProviderGovernanceResponse{ + Provider: *mc.Provider, + Budget: budget, + Budgets: budgets, + RateLimit: mc.RateLimit, + CalendarAligned: mc.CalendarAligned, + }, true } -// getProviderGovernance handles GET /api/governance/providers - Get all providers with governance settings +// getProviderGovernance handles GET /api/governance/providers - returns provider-level governance, +// now backed by all-models model configs scoped per provider. func (h *GovernanceHandler) getProviderGovernance(ctx *fasthttp.RequestCtx) { - providers, err := h.configStore.GetProviders(ctx) - if err != nil { - logger.Error("failed to retrieve providers: %v", err) - SendError(ctx, 500, "Failed to retrieve providers") - return - } - // Transform to governance response format + fromMemory := string(ctx.QueryArgs().Peek("from_memory")) == "true" var result []ProviderGovernanceResponse - for _, p := range providers { - if p.Budget != nil || p.RateLimit != nil { - result = append(result, ProviderGovernanceResponse{ - Provider: p.Name, - Budget: p.Budget, - RateLimit: p.RateLimit, - }) + if fromMemory { + data := h.governanceManager.GetGovernanceData(ctx) + if data == nil { + SendError(ctx, 500, "Governance data is not available") + return + } + for _, mc := range data.ModelConfigs { + if r, ok := modelConfigToProviderGovernance(mc); ok { + result = append(result, r) + } + } + } else { + configs, err := h.configStore.GetProviderGovernanceModelConfigs(ctx) + if err != nil { + logger.Error("failed to retrieve model configs: %v", err) + SendError(ctx, 500, "Failed to retrieve providers") + return + } + for i := range configs { + if r, ok := modelConfigToProviderGovernance(&configs[i]); ok { + result = append(result, r) + } } } SendJSON(ctx, map[string]interface{}{ @@ -2927,97 +3399,78 @@ func (h *GovernanceHandler) updateProviderGovernance(ctx *fasthttp.RequestCtx) { SendError(ctx, 400, "Invalid JSON") return } - // Get all providers and find the one we need + if req.Budget != nil && req.Budgets != nil { + SendError(ctx, 400, "only one of 'budget' or 'budgets' may be set") + return + } + // Validate the provider exists. providers, err := h.configStore.GetProviders(ctx) if err != nil { SendError(ctx, 500, "Failed to retrieve providers") return } - var provider *configstoreTables.TableProvider + providerExists := false for i := range providers { if providers[i].Name == providerName { - provider = &providers[i] + providerExists = true break } } - if provider == nil { + if !providerExists { SendError(ctx, 404, "Provider not found") return } + + existing, err := h.configStore.GetModelConfig(ctx, configstoreTables.ModelConfigScopeGlobal, nil, configstoreTables.ModelConfigAllModels, &providerName) + if err != nil && err != configstore.ErrNotFound { + logger.Error("failed to load provider governance: %v", err) + SendError(ctx, 500, fmt.Sprintf("Failed to load provider governance: %v", err)) + return + } + isNew := existing == nil + mc := configstoreTables.TableModelConfig{ + ID: uuid.NewString(), + ModelName: configstoreTables.ModelConfigAllModels, + Provider: &providerName, + Scope: configstoreTables.ModelConfigScopeGlobal, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if existing != nil { + mc = *existing + } + + // Existing single owned budget, if any (provider governance is single-budget by API). + var existingBudget *configstoreTables.TableBudget + if len(mc.Budgets) > 0 { + existingBudget = &mc.Budgets[0] + } + + deleted := false if err := h.configStore.ExecuteTransaction(ctx, func(tx *gorm.DB) error { - // Track IDs to delete after updating the provider (to avoid FK constraint) - var budgetIDToDelete, rateLimitIDToDelete string - - // Handle budget updates - if req.Budget != nil { - // Check if budget removal is requested (all fields nil) - budgetIsEmpty := isBudgetRemovalRequest(req.Budget) - if budgetIsEmpty { - // Mark budget for deletion after FK is removed - if provider.BudgetID != nil { - budgetIDToDelete = *provider.BudgetID - provider.BudgetID = nil - provider.Budget = nil - } - } else if provider.BudgetID != nil { - // Update existing budget — all fields are optional (partial update) - budget := configstoreTables.TableBudget{} - if err := tx.First(&budget, "id = ?", *provider.BudgetID).Error; err != nil { - return err - } - if req.Budget.MaxLimit != nil { - budget.MaxLimit = *req.Budget.MaxLimit - } - if req.Budget.ResetDuration != nil { - budget.ResetDuration = *req.Budget.ResetDuration - } - if err := validateBudget(&budget); err != nil { - return err - } - if err := h.configStore.UpdateBudget(ctx, &budget, tx); err != nil { - return err - } - provider.Budget = &budget - } else { - // Create new budget - if req.Budget.MaxLimit == nil || req.Budget.ResetDuration == nil { - return fmt.Errorf("both max_limit and reset_duration are required when creating a new budget") - } - budget := configstoreTables.TableBudget{ - ID: uuid.NewString(), - MaxLimit: *req.Budget.MaxLimit, - ResetDuration: *req.Budget.ResetDuration, - LastReset: budgetLastReset(false, *req.Budget.ResetDuration), - CurrentUsage: 0, - } - if err := validateBudget(&budget); err != nil { - return err - } - if err := h.configStore.CreateBudget(ctx, &budget, tx); err != nil { - return err - } - provider.BudgetID = &budget.ID - provider.Budget = &budget - } + var rateLimitIDToDelete string + + // Apply CalendarAligned if provided. + wasCalendarAligned := mc.CalendarAligned + if req.CalendarAligned != nil { + mc.CalendarAligned = *req.CalendarAligned } - // Handle rate limit updates + calendarAlignmentJustEnabled := !wasCalendarAligned && mc.CalendarAligned + + // Rate limit lifecycle (mc references it via RateLimitID, so resolve it before + // persisting the model config below). if req.RateLimit != nil { - // Check if rate limit values are empty - means remove rate limit (reset durations don't matter) - rateLimitIsEmpty := req.RateLimit.TokenMaxLimit == nil && req.RateLimit.RequestMaxLimit == nil - if rateLimitIsEmpty { - // Mark rate limit for deletion after FK is removed - if provider.RateLimitID != nil { - rateLimitIDToDelete = *provider.RateLimitID - provider.RateLimitID = nil - provider.RateLimit = nil + if isRateLimitRemovalRequest(req.RateLimit) { + if mc.RateLimitID != nil { + rateLimitIDToDelete = *mc.RateLimitID + mc.RateLimitID = nil + mc.RateLimit = nil } - } else if provider.RateLimitID != nil { - // Update existing rate limit - set ALL fields from request (nil means clear) + } else if mc.RateLimitID != nil { rateLimit := configstoreTables.TableRateLimit{} - if err := tx.First(&rateLimit, "id = ?", *provider.RateLimitID).Error; err != nil { + if err := tx.First(&rateLimit, "id = ?", *mc.RateLimitID).Error; err != nil { return err } - // Set all fields from request - nil values will clear the field rateLimit.TokenMaxLimit = req.RateLimit.TokenMaxLimit rateLimit.TokenResetDuration = req.RateLimit.TokenResetDuration rateLimit.RequestMaxLimit = req.RateLimit.RequestMaxLimit @@ -3028,9 +3481,8 @@ func (h *GovernanceHandler) updateProviderGovernance(ctx *fasthttp.RequestCtx) { if err := h.configStore.UpdateRateLimit(ctx, &rateLimit, tx); err != nil { return err } - provider.RateLimit = &rateLimit + mc.RateLimit = &rateLimit } else { - // Create new rate limit rateLimit := configstoreTables.TableRateLimit{ ID: uuid.NewString(), TokenMaxLimit: req.RateLimit.TokenMaxLimit, @@ -3046,113 +3498,163 @@ func (h *GovernanceHandler) updateProviderGovernance(ctx *fasthttp.RequestCtx) { if err := h.configStore.CreateRateLimit(ctx, &rateLimit, tx); err != nil { return err } - provider.RateLimitID = &rateLimit.ID - provider.RateLimit = &rateLimit + mc.RateLimitID = &rateLimit.ID + mc.RateLimit = &rateLimit } } - // Update only budget/rate limit FK references (avoid overwriting encrypted fields) - if err := tx.Model(provider).Select("budget_id", "rate_limit_id").Updates(provider).Error; err != nil { - return err + + // Determine effective budgets: budgets field takes priority; budget field is coerced + // into a single-element slice for backward compatibility. + effectiveBudgets := req.Budgets + if effectiveBudgets == nil && req.Budget != nil { + if len(mc.Budgets) > 1 { + return &badRequestError{err: fmt.Errorf("deprecated 'budget' field cannot be used when multiple budgets already exist; use 'budgets'")} + } + effectiveBudgets = coerceLegacyBudget(req.Budget, existingBudget) + if effectiveBudgets == nil && !isBudgetRemovalRequest(req.Budget) { + return &badRequestError{err: fmt.Errorf("both max_limit and reset_duration are required when creating a new budget")} + } + } + + willHaveBudget := len(mc.Budgets) > 0 + if effectiveBudgets != nil { + willHaveBudget = len(*effectiveBudgets) > 0 + } + + hasGovernance := mc.RateLimitID != nil || willHaveBudget + switch { + case !hasGovernance && isNew: + // Nothing to persist (removal request on a provider with no governance). + return nil + case !hasGovernance && !isNew: + // All governance removed → delete the model config and its owned budgets. + for _, b := range mc.Budgets { + if err := tx.Delete(&configstoreTables.TableBudget{}, "id = ?", b.ID).Error; err != nil { + return err + } + } + if err := tx.Delete(&configstoreTables.TableModelConfig{}, "id = ?", mc.ID).Error; err != nil { + return err + } + deleted = true + case isNew: + // Create the model config first so its budgets can reference it. + if err := h.configStore.CreateModelConfig(ctx, &mc, tx); err != nil { + return err + } + default: + if err := h.configStore.UpdateModelConfig(ctx, &mc, tx); err != nil { + return err + } } - // Now that FK references are removed, delete the orphaned budget/rate limit - if budgetIDToDelete != "" { - if err := tx.Delete(&configstoreTables.TableBudget{}, "id = ?", budgetIDToDelete).Error; err != nil { + // Budget reconciliation (mc row exists at this point for create cases). + if !deleted && effectiveBudgets != nil { + if err := h.reconcileModelConfigBudgets(ctx, tx, &mc, *effectiveBudgets); err != nil { return err } } + + // Snap budgets and rate limit to the current calendar period when calendar + // alignment transitions false → true. Runs after reconciliation so combined + // "toggle + budgets" requests see the final reconciled state. + if !deleted && calendarAlignmentJustEnabled { + now := time.Now() + for i := range mc.Budgets { + b := &mc.Budgets[i] + if !configstoreTables.IsCalendarAlignableDuration(b.ResetDuration) { + continue + } + b.LastReset = configstoreTables.GetCalendarPeriodStart(b.ResetDuration, now) + b.CurrentUsage = 0 + if err := h.configStore.UpdateBudget(ctx, b, tx); err != nil { + return fmt.Errorf("failed to snap provider budget %s on calendar-align enable: %w", b.ID, err) + } + } + if mc.RateLimit != nil { + rl := mc.RateLimit + snapped := false + if rl.TokenResetDuration != nil && configstoreTables.IsCalendarAlignableDuration(*rl.TokenResetDuration) { + rl.TokenLastReset = configstoreTables.GetCalendarPeriodStart(*rl.TokenResetDuration, now) + rl.TokenCurrentUsage = 0 + snapped = true + } + if rl.RequestResetDuration != nil && configstoreTables.IsCalendarAlignableDuration(*rl.RequestResetDuration) { + rl.RequestLastReset = configstoreTables.GetCalendarPeriodStart(*rl.RequestResetDuration, now) + rl.RequestCurrentUsage = 0 + snapped = true + } + if snapped { + if err := h.configStore.UpdateRateLimit(ctx, rl, tx); err != nil { + return fmt.Errorf("failed to snap provider rate limit on calendar-align enable: %w", err) + } + } + } + } + + // Delete orphaned rate-limit row if it was unlinked. if rateLimitIDToDelete != "" { if err := tx.Delete(&configstoreTables.TableRateLimit{}, "id = ?", rateLimitIDToDelete).Error; err != nil { return err } } - return nil }); err != nil { + var badReqErr *badRequestError + if errors.As(err, &badReqErr) { + SendError(ctx, 400, err.Error()) + return + } logger.Error("failed to update provider governance: %v", err) SendError(ctx, 500, fmt.Sprintf("Failed to update provider governance: %v", err)) return } - // Reload provider in memory - updatedProvider, err := h.governanceManager.ReloadProvider(ctx, schemas.ModelProvider(providerName)) - if err != nil { - logger.Error("failed to reload provider in memory: %v", err) - // Use the local provider object if reload fails - } else { - provider = updatedProvider + + // Sync the in-memory governance store with the change. + resp := ProviderGovernanceResponse{Provider: providerName} + if deleted { + if err := h.governanceManager.RemoveModelConfig(ctx, mc.ID); err != nil { + logger.Error("failed to remove provider governance from memory: %v", err) + } + } else if len(mc.Budgets) > 0 || mc.RateLimitID != nil { + if reloaded, err := h.governanceManager.ReloadModelConfig(ctx, mc.ID); err != nil { + logger.Error("failed to reload provider governance in memory: %v", err) + if r, ok := modelConfigToProviderGovernance(&mc); ok { + resp = r + } + } else if r, ok := modelConfigToProviderGovernance(reloaded); ok { + resp = r + } } SendJSON(ctx, map[string]interface{}{ - "message": "Provider governance updated successfully", - "provider": ProviderGovernanceResponse{ - Provider: provider.Name, - Budget: provider.Budget, - RateLimit: provider.RateLimit, - }, + "message": "Provider governance updated successfully", + "provider": resp, }) } -// deleteProviderGovernance handles DELETE /api/governance/providers/{provider_name} - Remove governance from provider +// deleteProviderGovernance handles DELETE /api/governance/providers/{provider_name} - removes +// provider-level governance by deleting the all-models model config for that provider. func (h *GovernanceHandler) deleteProviderGovernance(ctx *fasthttp.RequestCtx) { providerName := ctx.UserValue("provider_name").(string) - // Get all providers and find the one we need - providers, err := h.configStore.GetProviders(ctx) + mc, err := h.configStore.GetModelConfig(ctx, configstoreTables.ModelConfigScopeGlobal, nil, configstoreTables.ModelConfigAllModels, &providerName) if err != nil { - SendError(ctx, 500, "Failed to retrieve providers") - return - } - var provider *configstoreTables.TableProvider - for i := range providers { - if providers[i].Name == providerName { - provider = &providers[i] - break + if err == configstore.ErrNotFound { + // No provider-level governance to remove — treat as success (idempotent). + SendJSON(ctx, map[string]interface{}{"message": "Provider governance deleted successfully"}) + return } - } - if provider == nil { - SendError(ctx, 404, "Provider not found") + logger.Error("failed to load provider governance: %v", err) + SendError(ctx, 500, "Failed to delete provider governance") return } - if err := h.configStore.ExecuteTransaction(ctx, func(tx *gorm.DB) error { - // Store IDs to delete after removing FK references - var budgetIDToDelete, rateLimitIDToDelete string - - if provider.BudgetID != nil { - budgetIDToDelete = *provider.BudgetID - provider.BudgetID = nil - provider.Budget = nil - } - if provider.RateLimitID != nil { - rateLimitIDToDelete = *provider.RateLimitID - provider.RateLimitID = nil - provider.RateLimit = nil - } - - // Update only budget/rate limit FK references (avoid overwriting encrypted fields) - if err := tx.Model(provider).Select("budget_id", "rate_limit_id").Updates(provider).Error; err != nil { - return err - } - - // Now delete the orphaned budget/rate limit - if budgetIDToDelete != "" { - if err := tx.Delete(&configstoreTables.TableBudget{}, "id = ?", budgetIDToDelete).Error; err != nil { - return err - } - } - if rateLimitIDToDelete != "" { - if err := tx.Delete(&configstoreTables.TableRateLimit{}, "id = ?", rateLimitIDToDelete).Error; err != nil { - return err - } - } - - return nil - }); err != nil { + // DeleteModelConfig cascades to the owned budget/rate-limit rows. + if err := h.configStore.DeleteModelConfig(ctx, mc.ID); err != nil { logger.Error("failed to delete provider governance: %v", err) SendError(ctx, 500, "Failed to delete provider governance") return } - // Reload provider in memory (to clear the budget/rate limit) - if _, err := h.governanceManager.ReloadProvider(ctx, schemas.ModelProvider(providerName)); err != nil { - logger.Error("failed to reload provider in memory: %v", err) - // Continue anyway, the governance is deleted from DB + if err := h.governanceManager.RemoveModelConfig(ctx, mc.ID); err != nil { + logger.Error("failed to remove provider governance from memory: %v", err) } SendJSON(ctx, map[string]interface{}{ "message": "Provider governance deleted successfully", diff --git a/transports/bifrost-http/handlers/governance_test.go b/transports/bifrost-http/handlers/governance_test.go index a99b18c214c..f52b3103707 100644 --- a/transports/bifrost-http/handlers/governance_test.go +++ b/transports/bifrost-http/handlers/governance_test.go @@ -1510,6 +1510,225 @@ func TestCollectProviderConfigDeleteIDs(t *testing.T) { } } +func TestCoerceLegacyBudget(t *testing.T) { + existing := &configstoreTables.TableBudget{ID: "bud-1", MaxLimit: 50, ResetDuration: "1d"} + + tests := []struct { + name string + req *UpdateBudgetRequest + existing *configstoreTables.TableBudget + // nil wantResult means coerce returns nil (no actionable change) + wantNil bool + wantEmpty bool // non-nil but empty slice (removal) + wantID string + wantLimit float64 + wantPeriod string + }{ + { + name: "empty object → removal, returns empty slice", + req: &UpdateBudgetRequest{}, + existing: nil, + wantEmpty: true, + }, + { + name: "both fields set, no existing → new budget entry, no ID", + req: &UpdateBudgetRequest{MaxLimit: schemas.Ptr(100.0), ResetDuration: schemas.Ptr("1w")}, + existing: nil, + wantLimit: 100, + wantPeriod: "1w", + }, + { + name: "update max_limit only, existing budget → merges ID and reset_duration", + req: &UpdateBudgetRequest{MaxLimit: schemas.Ptr(200.0)}, + existing: existing, + wantID: "bud-1", + wantLimit: 200, + wantPeriod: "1d", + }, + { + name: "update reset_duration only, existing budget → merges ID and max_limit", + req: &UpdateBudgetRequest{ResetDuration: schemas.Ptr("1w")}, + existing: existing, + wantID: "bud-1", + wantLimit: 50, + wantPeriod: "1w", + }, + { + name: "max_limit only, no existing → cannot build valid budget, returns nil", + req: &UpdateBudgetRequest{MaxLimit: schemas.Ptr(100.0)}, + existing: nil, + wantNil: true, + }, + { + name: "reset_duration only, no existing → cannot build valid budget, returns nil", + req: &UpdateBudgetRequest{ResetDuration: schemas.Ptr("1d")}, + existing: nil, + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := coerceLegacyBudget(tt.req, tt.existing) + if tt.wantNil { + if got != nil { + t.Fatalf("expected nil, got %+v", got) + } + return + } + if got == nil { + t.Fatal("expected non-nil result") + } + if tt.wantEmpty { + if len(*got) != 0 { + t.Fatalf("expected empty slice, got %+v", *got) + } + return + } + if len(*got) != 1 { + t.Fatalf("expected 1-element slice, got %d elements", len(*got)) + } + b := (*got)[0] + if b.ID != tt.wantID { + t.Errorf("ID = %q, want %q", b.ID, tt.wantID) + } + if b.MaxLimit != tt.wantLimit { + t.Errorf("MaxLimit = %v, want %v", b.MaxLimit, tt.wantLimit) + } + if b.ResetDuration != tt.wantPeriod { + t.Errorf("ResetDuration = %q, want %q", b.ResetDuration, tt.wantPeriod) + } + }) + } +} + +func TestModelConfigToProviderGovernanceNewFields(t *testing.T) { + provider := "openai" + base := configstoreTables.TableModelConfig{ + Scope: configstoreTables.ModelConfigScopeGlobal, + ModelName: configstoreTables.ModelConfigAllModels, + Provider: &provider, + } + + t.Run("nil mc returns false", func(t *testing.T) { + if _, ok := modelConfigToProviderGovernance(nil); ok { + t.Fatal("expected false for nil mc") + } + }) + + t.Run("wrong scope returns false", func(t *testing.T) { + mc := base + mc.Scope = "virtual_key" + if _, ok := modelConfigToProviderGovernance(&mc); ok { + t.Fatal("expected false for non-global scope") + } + }) + + t.Run("no budgets: Budget nil, Budgets empty, CalendarAligned false", func(t *testing.T) { + mc := base + r, ok := modelConfigToProviderGovernance(&mc) + if !ok { + t.Fatal("expected ok") + } + if r.Budget != nil { + t.Errorf("Budget should be nil, got %+v", r.Budget) + } + if len(r.Budgets) != 0 { + t.Errorf("Budgets should be empty, got %+v", r.Budgets) + } + if r.CalendarAligned { + t.Error("CalendarAligned should be false") + } + }) + + t.Run("single budget: Budget points to first, Budgets has one entry", func(t *testing.T) { + mc := base + mc.Budgets = []configstoreTables.TableBudget{{ID: "b1", MaxLimit: 100, ResetDuration: "1d"}} + r, ok := modelConfigToProviderGovernance(&mc) + if !ok { + t.Fatal("expected ok") + } + if r.Budget == nil || r.Budget.ID != "b1" { + t.Errorf("Budget = %+v, want ID=b1", r.Budget) + } + if len(r.Budgets) != 1 || r.Budgets[0].ID != "b1" { + t.Errorf("Budgets = %+v, want 1 entry with ID=b1", r.Budgets) + } + }) + + t.Run("multiple budgets: Budget is first, Budgets contains all", func(t *testing.T) { + mc := base + mc.Budgets = []configstoreTables.TableBudget{ + {ID: "b1", MaxLimit: 100, ResetDuration: "1d"}, + {ID: "b2", MaxLimit: 500, ResetDuration: "1w"}, + } + r, ok := modelConfigToProviderGovernance(&mc) + if !ok { + t.Fatal("expected ok") + } + if r.Budget == nil || r.Budget.ID != "b1" { + t.Errorf("Budget should point to first budget, got %+v", r.Budget) + } + if len(r.Budgets) != 2 { + t.Fatalf("Budgets len = %d, want 2", len(r.Budgets)) + } + if r.Budgets[0].ID != "b1" || r.Budgets[1].ID != "b2" { + t.Errorf("Budgets = %+v", r.Budgets) + } + }) + + t.Run("calendar_aligned is propagated", func(t *testing.T) { + mc := base + mc.CalendarAligned = true + r, ok := modelConfigToProviderGovernance(&mc) + if !ok { + t.Fatal("expected ok") + } + if !r.CalendarAligned { + t.Error("CalendarAligned should be true") + } + }) + + t.Run("Budgets slice is a copy, not a reference to mc.Budgets", func(t *testing.T) { + mc := base + mc.Budgets = []configstoreTables.TableBudget{{ID: "b1", MaxLimit: 100, ResetDuration: "1d"}} + r, _ := modelConfigToProviderGovernance(&mc) + r.Budgets[0].MaxLimit = 999 + if mc.Budgets[0].MaxLimit == 999 { + t.Error("mutating response Budgets should not affect the original mc") + } + }) +} + +func TestUpdateProviderGovernance_BudgetMutualExclusion(t *testing.T) { + SetLogger(&mockLogger{}) + + h := &GovernanceHandler{} + ctx := &fasthttp.RequestCtx{} + ctx.SetUserValue("provider_name", "openai") + ctx.Request.SetBodyString(`{ + "budget": {"max_limit": 100, "reset_duration": "1d"}, + "budgets": [{"max_limit": 100, "reset_duration": "1d"}] + }`) + + h.updateProviderGovernance(ctx) + + if ctx.Response.StatusCode() != 400 { + t.Fatalf("expected 400, got %d: %s", ctx.Response.StatusCode(), ctx.Response.Body()) + } + var resp struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(ctx.Response.Body(), &resp); err != nil { + t.Fatalf("failed to parse error response: %v", err) + } + if !strings.Contains(resp.Error.Message, "budget") { + t.Errorf("error message should mention 'budget', got: %q", resp.Error.Message) + } +} + func TestValidateRoutingFallbacks(t *testing.T) { tests := []struct { @@ -1538,3 +1757,258 @@ func TestValidateRoutingFallbacks(t *testing.T) { }) } } + +// --- customer calendar_aligned handler tests --- + +type mockCustomerStore struct { + configstore.ConfigStore + customers map[string]*configstoreTables.TableCustomer + createdBudgets []*configstoreTables.TableBudget + updatedBudgets []*configstoreTables.TableBudget + updatedRLs []*configstoreTables.TableRateLimit +} + +func newMockCustomerStore() *mockCustomerStore { + return &mockCustomerStore{customers: make(map[string]*configstoreTables.TableCustomer)} +} + +func (m *mockCustomerStore) ExecuteTransaction(_ context.Context, fn func(*gorm.DB) error) error { + return fn(nil) +} +func (m *mockCustomerStore) GetCustomer(_ context.Context, id string) (*configstoreTables.TableCustomer, error) { + c, ok := m.customers[id] + if !ok { + return nil, configstore.ErrNotFound + } + clone := *c + if len(c.Budgets) > 0 { + clonedBudgets := make([]configstoreTables.TableBudget, len(c.Budgets)) + copy(clonedBudgets, c.Budgets) + clone.Budgets = clonedBudgets + } + if c.RateLimit != nil { + rl := *c.RateLimit + clone.RateLimit = &rl + } + return &clone, nil +} +func (m *mockCustomerStore) CreateCustomer(_ context.Context, customer *configstoreTables.TableCustomer, _ ...*gorm.DB) error { + m.customers[customer.ID] = customer + return nil +} +func (m *mockCustomerStore) UpdateCustomer(_ context.Context, customer *configstoreTables.TableCustomer, _ ...*gorm.DB) error { + m.customers[customer.ID] = customer + return nil +} +func (m *mockCustomerStore) CreateBudget(_ context.Context, budget *configstoreTables.TableBudget, _ ...*gorm.DB) error { + m.createdBudgets = append(m.createdBudgets, budget) + return nil +} +func (m *mockCustomerStore) UpdateBudget(_ context.Context, budget *configstoreTables.TableBudget, _ ...*gorm.DB) error { + m.updatedBudgets = append(m.updatedBudgets, budget) + return nil +} +func (m *mockCustomerStore) CreateRateLimit(_ context.Context, rl *configstoreTables.TableRateLimit, _ ...*gorm.DB) error { + return nil +} +func (m *mockCustomerStore) UpdateRateLimit(_ context.Context, rl *configstoreTables.TableRateLimit, _ ...*gorm.DB) error { + m.updatedRLs = append(m.updatedRLs, rl) + return nil +} +func (m *mockCustomerStore) DeleteBudget(_ context.Context, _ string, _ ...*gorm.DB) error { return nil } + +type mockCustomerGovernanceManager struct { + GovernanceManager +} + +func (m *mockCustomerGovernanceManager) ReloadCustomer(_ context.Context, _ string) (*configstoreTables.TableCustomer, error) { + return nil, nil +} + +// TestCreateCustomer_CalendarAligned_SnapsBudgetLastReset verifies that when +// calendar_aligned=true is set on create, the budget's LastReset is snapped to +// the calendar period start rather than time.Now(). +func TestCreateCustomer_CalendarAligned_SnapsBudgetLastReset(t *testing.T) { + SetLogger(&mockLogger{}) + store := newMockCustomerStore() + h := &GovernanceHandler{configStore: store, governanceManager: &mockCustomerGovernanceManager{}} + + body, _ := json.Marshal(map[string]any{ + "name": "ACME", + "calendar_aligned": true, + "budget": map[string]any{ + "max_limit": 100.0, + "reset_duration": "1M", + }, + }) + ctx := &fasthttp.RequestCtx{} + ctx.Request.SetBody(body) + + before := time.Now() + h.createCustomer(ctx) + + if ctx.Response.StatusCode() != 200 { + t.Fatalf("expected 200, got %d: %s", ctx.Response.StatusCode(), ctx.Response.Body()) + } + if len(store.createdBudgets) != 1 { + t.Fatalf("expected 1 created budget, got %d", len(store.createdBudgets)) + } + b := store.createdBudgets[0] + // Calendar-aligned LastReset must be at the start of the calendar period, + // which is always <= the beginning of the test, never a rolling time.Now(). + if b.LastReset.After(before) { + t.Errorf("calendar-aligned budget LastReset %v should not be after test start %v (expected period start)", b.LastReset, before) + } + // Confirm the stored customer has CalendarAligned=true. + var created *configstoreTables.TableCustomer + for _, c := range store.customers { + created = c + } + if created == nil || !created.CalendarAligned { + t.Errorf("stored customer should have CalendarAligned=true") + } +} + +// TestCreateCustomer_CalendarAligned_False verifies that when calendar_aligned is +// not set, budget LastReset is a rolling time.Now() (not at a period boundary). +func TestCreateCustomer_CalendarAligned_False(t *testing.T) { + SetLogger(&mockLogger{}) + store := newMockCustomerStore() + h := &GovernanceHandler{configStore: store, governanceManager: &mockCustomerGovernanceManager{}} + + body, _ := json.Marshal(map[string]any{ + "name": "Globex", + "budget": map[string]any{ + "max_limit": 50.0, + "reset_duration": "1M", + }, + }) + ctx := &fasthttp.RequestCtx{} + ctx.Request.SetBody(body) + + before := time.Now() + h.createCustomer(ctx) + after := time.Now() + + if ctx.Response.StatusCode() != 200 { + t.Fatalf("expected 200, got %d: %s", ctx.Response.StatusCode(), ctx.Response.Body()) + } + if len(store.createdBudgets) != 1 { + t.Fatalf("expected 1 created budget, got %d", len(store.createdBudgets)) + } + b := store.createdBudgets[0] + // Rolling LastReset should be within the test window. + if b.LastReset.Before(before) || b.LastReset.After(after) { + t.Errorf("non-calendar-aligned budget LastReset %v should be between %v and %v", b.LastReset, before, after) + } +} + +// TestUpdateCustomer_CalendarAligned_SnapsExistingBudget verifies that toggling +// calendar_aligned from false to true snaps the existing budget's LastReset to the +// start of the current calendar period and resets CurrentUsage. +func TestUpdateCustomer_CalendarAligned_SnapsExistingBudget(t *testing.T) { + SetLogger(&mockLogger{}) + store := newMockCustomerStore() + + budgetID := "bud-snap" + budgetID2 := "bud-snap-2" + oldLastReset := time.Now().AddDate(0, -1, 0) // 1 month ago + store.customers["cust-snap"] = &configstoreTables.TableCustomer{ + ID: "cust-snap", + Name: "Initech", + CalendarAligned: false, + Budgets: []configstoreTables.TableBudget{ + { + ID: budgetID, + MaxLimit: 200.0, + ResetDuration: "1M", + LastReset: oldLastReset, + CurrentUsage: 99.0, + }, + { + ID: budgetID2, + MaxLimit: 500.0, + ResetDuration: "1Y", + LastReset: oldLastReset, + CurrentUsage: 150.0, + }, + }, + } + h := &GovernanceHandler{configStore: store, governanceManager: &mockCustomerGovernanceManager{}} + + body, _ := json.Marshal(map[string]any{"calendar_aligned": true}) + ctx := &fasthttp.RequestCtx{} + ctx.Request.SetBody(body) + ctx.SetUserValue("customer_id", "cust-snap") + + snapBefore := time.Now() + h.updateCustomer(ctx) + + if ctx.Response.StatusCode() != 200 { + t.Fatalf("expected 200, got %d: %s", ctx.Response.StatusCode(), ctx.Response.Body()) + } + // UpdateBudget must have been called once per budget (both snap). + if len(store.updatedBudgets) != 2 { + t.Fatalf("expected 2 UpdateBudget calls for snap, got %d", len(store.updatedBudgets)) + } + snappedIDs := make(map[string]bool, 2) + for _, snapped := range store.updatedBudgets { + snappedIDs[snapped.ID] = true + if snapped.LastReset.Equal(oldLastReset) { + t.Errorf("budget %s LastReset was not snapped: still equals old value", snapped.ID) + } + if snapped.LastReset.After(snapBefore) { + t.Errorf("budget %s snapped LastReset %v should be at the period start, not time.Now()", snapped.ID, snapped.LastReset) + } + if snapped.CurrentUsage != 0 { + t.Errorf("budget %s expected CurrentUsage reset to 0, got %v", snapped.ID, snapped.CurrentUsage) + } + } + if !snappedIDs[budgetID] || !snappedIDs[budgetID2] { + t.Errorf("expected both %q and %q to be snapped, got IDs: %v", budgetID, budgetID2, snappedIDs) + } +} + +// TestUpdateCustomer_CalendarAligned_NoSnapWhenAlreadyEnabled verifies that if +// calendar_aligned is already true, no snap/UpdateBudget call occurs on update. +func TestUpdateCustomer_CalendarAligned_NoSnapWhenAlreadyEnabled(t *testing.T) { + SetLogger(&mockLogger{}) + store := newMockCustomerStore() + + store.customers["cust-already"] = &configstoreTables.TableCustomer{ + ID: "cust-already", + Name: "Umbrella", + CalendarAligned: true, // already enabled + Budgets: []configstoreTables.TableBudget{ + { + ID: "bud-already-1", + MaxLimit: 300.0, + ResetDuration: "1M", + LastReset: time.Now().AddDate(0, -1, 0), + CurrentUsage: 42.0, + }, + { + ID: "bud-already-2", + MaxLimit: 800.0, + ResetDuration: "1Y", + LastReset: time.Now().AddDate(-1, 0, 0), + CurrentUsage: 10.0, + }, + }, + } + h := &GovernanceHandler{configStore: store, governanceManager: &mockCustomerGovernanceManager{}} + + body, _ := json.Marshal(map[string]any{"calendar_aligned": true}) + ctx := &fasthttp.RequestCtx{} + ctx.Request.SetBody(body) + ctx.SetUserValue("customer_id", "cust-already") + + h.updateCustomer(ctx) + + if ctx.Response.StatusCode() != 200 { + t.Fatalf("expected 200, got %d: %s", ctx.Response.StatusCode(), ctx.Response.Body()) + } + if len(store.updatedBudgets) != 0 { + t.Errorf("expected no UpdateBudget call when calendar_aligned was already true, got %d", len(store.updatedBudgets)) + } +} diff --git a/transports/bifrost-http/handlers/inference.go b/transports/bifrost-http/handlers/inference.go index fc1468964fd..eebaaf7249f 100644 --- a/transports/bifrost-http/handlers/inference.go +++ b/transports/bifrost-http/handlers/inference.go @@ -1411,6 +1411,13 @@ func prepareTranscriptionRequest(ctx *fasthttp.RequestCtx, config *lib.Config) ( if responseFormatValues := form.Value["response_format"]; len(responseFormatValues) > 0 && responseFormatValues[0] != "" { transcriptionParams.ResponseFormat = &responseFormatValues[0] } + var fallbacks []schemas.Fallback + if fallbackValues := form.Value["fallbacks"]; len(fallbackValues) > 0 { + fallbacks, err = parseFallbacks(fallbackValues) + if err != nil { + return nil, false, err + } + } if transcriptionParams.ExtraParams == nil { transcriptionParams.ExtraParams = make(map[string]interface{}) } @@ -1424,10 +1431,11 @@ func prepareTranscriptionRequest(ctx *fasthttp.RequestCtx, config *lib.Config) ( stream = true } bifrostTranscriptionReq := &schemas.BifrostTranscriptionRequest{ - Model: modelName, - Provider: schemas.ModelProvider(provider), - Input: transcriptionInput, - Params: transcriptionParams, + Model: modelName, + Provider: provider, + Input: transcriptionInput, + Params: transcriptionParams, + Fallbacks: fallbacks, } return bifrostTranscriptionReq, stream, nil } @@ -2631,6 +2639,25 @@ func (h *CompletionHandler) videoRemix(ctx *fasthttp.RequestCtx) { SendJSON(ctx, resp) } +// resolveBatchProvider resolves the provider (and optional model) for a batch +// create request. Per the OpenAI spec, model is optional on POST /v1/batches — +// it lives inside each JSONL request body. When model is present it is parsed +// via resolveModelAndProvider; when absent the provider is taken from the +// ?provider= query param or x-model-provider header (same as fileUpload). +func resolveBatchProvider(ctx *fasthttp.RequestCtx, config *lib.Config, model string) (schemas.ModelProvider, string, error) { + if model != "" { + return resolveModelAndProvider(ctx, config, model) + } + p := string(ctx.QueryArgs().Peek("provider")) + if p == "" { + p = string(ctx.Request.Header.Peek("x-model-provider")) + } + if p == "" { + return "", "", fmt.Errorf("provider query parameter or x-model-provider header is required when model is not specified") + } + return schemas.ModelProvider(p), "", nil +} + // batchCreate handles POST /v1/batches - Create a new batch job func (h *CompletionHandler) batchCreate(ctx *fasthttp.RequestCtx) { var req BatchCreateRequest @@ -2639,7 +2666,10 @@ func (h *CompletionHandler) batchCreate(ctx *fasthttp.RequestCtx) { return } - provider, modelName, err := resolveModelAndProvider(ctx, h.config, req.Model) + // model is optional on POST /v1/batches per the OpenAI spec — the model lives + // inside each JSONL request body. When omitted, resolve the provider from the + // x-model-provider header or ?provider= query param (same as fileUpload). + provider, modelName, err := resolveBatchProvider(ctx, h.config, req.Model) if err != nil { SendError(ctx, fasthttp.StatusBadRequest, err.Error()) return @@ -2990,15 +3020,18 @@ func (h *CompletionHandler) fileUpload(ctx *fasthttp.RequestCtx) { // fileList handles GET /v1/files - List files func (h *CompletionHandler) fileList(ctx *fasthttp.RequestCtx) { - // Get provider from query parameters - provider := string(ctx.QueryArgs().Peek("x-model-provider")) + // Get provider from query parameters or header; accept both ?provider= and + // ?x-model-provider= for consistency with other file endpoints (#3963). + provider := string(ctx.QueryArgs().Peek("provider")) + if provider == "" { + provider = string(ctx.QueryArgs().Peek("x-model-provider")) + } if provider == "" { - // Try to get from header provider = string(ctx.Request.Header.Peek("x-model-provider")) - if provider == "" { - SendError(ctx, fasthttp.StatusBadRequest, "x-model-provider query parameter or x-model-provider header is required") - return - } + } + if provider == "" { + SendError(ctx, fasthttp.StatusBadRequest, "provider query parameter or x-model-provider header is required") + return } // Parse optional parameters diff --git a/transports/bifrost-http/handlers/inference_batch_test.go b/transports/bifrost-http/handlers/inference_batch_test.go new file mode 100644 index 00000000000..1dbd504d98c --- /dev/null +++ b/transports/bifrost-http/handlers/inference_batch_test.go @@ -0,0 +1,81 @@ +package handlers + +import ( + "strings" + "testing" + + "github.com/maximhq/bifrost/transports/bifrost-http/lib" + "github.com/valyala/fasthttp" +) + +// TestResolveBatchProvider covers the three resolution paths introduced to make +// model optional on POST /v1/batches (OpenAI spec: model lives in the JSONL body). +func TestResolveBatchProvider(t *testing.T) { + config := &lib.Config{} + + cases := []struct { + name string + model string + header string // x-model-provider; empty = unset + query string // ?provider=; empty = unset + wantProvider string + wantModel string + wantErrMsg string // non-empty = error expected, substring match + }{ + { + name: "model field: provider+model parsed", + model: "openai/gpt-4o-mini", + wantProvider: "openai", + wantModel: "gpt-4o-mini", + }, + { + name: "no model, x-model-provider header", + header: "openai", + wantProvider: "openai", + wantModel: "", + }, + { + name: "no model, ?provider= query param", + query: "anthropic", + wantProvider: "anthropic", + wantModel: "", + }, + { + name: "no model, no provider → error", + wantErrMsg: "provider query parameter or x-model-provider header is required", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := &fasthttp.RequestCtx{} + if tc.header != "" { + ctx.Request.Header.Set("x-model-provider", tc.header) + } + if tc.query != "" { + ctx.QueryArgs().Set("provider", tc.query) + } + + provider, modelName, err := resolveBatchProvider(ctx, config, tc.model) + + if tc.wantErrMsg != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantErrMsg) + } + if !strings.Contains(err.Error(), tc.wantErrMsg) { + t.Fatalf("error %q does not contain %q", err.Error(), tc.wantErrMsg) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(provider) != tc.wantProvider { + t.Fatalf("provider = %q, want %q", provider, tc.wantProvider) + } + if modelName != tc.wantModel { + t.Fatalf("modelName = %q, want %q", modelName, tc.wantModel) + } + }) + } +} diff --git a/transports/bifrost-http/handlers/middlewares.go b/transports/bifrost-http/handlers/middlewares.go index da9dfb07e14..42bf86b86ec 100644 --- a/transports/bifrost-http/handlers/middlewares.go +++ b/transports/bifrost-http/handlers/middlewares.go @@ -46,6 +46,27 @@ func SecurityHeadersMiddleware() schemas.BifrostHTTPMiddleware { } } +// clientForwardedIP returns the client-supplied originating IP from reverse-proxy +// headers, or "" if none are present. X-Forwarded-For may be a comma-separated list +// (client, proxy1, proxy2); the leftmost entry is the original client. +// +// These headers are caller-controlled and unauthenticated unless Bifrost sits behind +// a trusted proxy that overwrites them. The value is logged as http.forwarded_for — +// separate from the authoritative http.remote_addr (the real TCP peer) — so a forged +// header cannot mask the true peer in the access log. +func clientForwardedIP(ctx *fasthttp.RequestCtx) string { + if xff := strings.TrimSpace(string(ctx.Request.Header.Peek("X-Forwarded-For"))); xff != "" { + if first, _, found := strings.Cut(xff, ","); found { + return strings.TrimSpace(first) + } + return xff + } + if xrip := strings.TrimSpace(string(ctx.Request.Header.Peek("X-Real-IP"))); xrip != "" { + return xrip + } + return "" +} + // CorsMiddleware handles CORS headers for localhost and configured allowed origins func CorsMiddleware(config *lib.Config) schemas.BifrostHTTPMiddleware { return func(next fasthttp.RequestHandler) fasthttp.RequestHandler { @@ -70,9 +91,17 @@ func CorsMiddleware(config *lib.Config) schemas.BifrostHTTPMiddleware { Int64("http.request_duration_ms", time.Since(startTime).Milliseconds()). Str("http.remote_addr", ctx.RemoteAddr().String()). Str("http.user_agent", string(ctx.Request.Header.UserAgent())) + if forwarded := clientForwardedIP(ctx); forwarded != "" { + logBuilder = logBuilder.Str("http.forwarded_for", forwarded) + } if traceID, ok := ctx.UserValue(schemas.BifrostContextKeyTraceID).(string); ok && traceID != "" { logBuilder = logBuilder.Str("trace_id", traceID) } + if statusCode >= 400 && !ctx.Response.IsBodyStream() { + if body := ctx.Response.Body(); len(body) > 0 { + logBuilder = logBuilder.Str("http.error", string(body)) + } + } logBuilder.Send() }() } @@ -1145,6 +1174,16 @@ func (m *TracingMiddleware) Middleware() schemas.BifrostHTTPMiddleware { ctx.SetUserValue(schemas.BifrostContextKeySpanID, spanID) } } + // Capture request headers onto the trace when a connector has opted in. + // Gated so there is no overhead when no observability plugin wants headers. + if tracer.ShouldCaptureRequestHeaders() { + headers := make(map[string]string) + ctx.Request.Header.All()(func(key, value []byte) bool { + headers[strings.ToLower(string(key))] = string(value) + return true + }) + tracer.SetTraceRequestHeaders(traceID, headers) + } defer func() { deferred, _ := ctx.UserValue(schemas.BifrostContextKeyDeferTraceCompletion).(bool) // Record response status on the root span diff --git a/transports/bifrost-http/handlers/providers.go b/transports/bifrost-http/handlers/providers.go index aafe6106c00..059f5e138f0 100644 --- a/transports/bifrost-http/handlers/providers.go +++ b/transports/bifrost-http/handlers/providers.go @@ -281,6 +281,12 @@ func (h *ProviderHandler) addProvider(ctx *fasthttp.RequestCtx) { SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid retry backoff: %v", err)) return } + if payload.NetworkConfig.BaseURL != "" { + if err := bifrost.ValidateExternalURL(payload.NetworkConfig.BaseURL, payload.NetworkConfig.AllowPrivateNetwork); err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid base URL: %v", err)) + return + } + } } // Check if provider already exists if _, err := h.inMemoryStore.GetProviderConfigRedacted(payload.Provider); err != nil { @@ -449,6 +455,12 @@ func (h *ProviderHandler) updateProvider(ctx *fasthttp.RequestCtx) { SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid retry backoff: %v", err)) return } + if nc.BaseURL != "" { + if err := bifrost.ValidateExternalURL(nc.BaseURL, nc.AllowPrivateNetwork); err != nil { + SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid base URL: %v", err)) + return + } + } config.ConcurrencyAndBufferSize = &payload.ConcurrencyAndBufferSize // Merge network config - restore ca_cert_pem if the redacted placeholder was sent back diff --git a/transports/bifrost-http/integrations/router.go b/transports/bifrost-http/integrations/router.go index 96db94f5c3c..4413cf971a2 100644 --- a/transports/bifrost-http/integrations/router.go +++ b/transports/bifrost-http/integrations/router.go @@ -792,6 +792,18 @@ func (g *GenericRouter) createHandler(config RouteConfig) fasthttp.RequestHandle skipModelCatalogProviderSelection, _ := bifrostCtx.Value(schemas.BifrostContextKeySkipModelCatalogProviderSelection).(bool) if extractedProvider == "" && !skipModelCatalogProviderSelection { availableProviders := g.handlerStore.GetProvidersForModel(extractedModel) + existingProviders, hasExistingProviders := bifrostCtx.Value(schemas.BifrostContextKeyAvailableProviders).([]schemas.ModelProvider) + if hasExistingProviders { + if len(existingProviders) == 0 { + availableProviders = []schemas.ModelProvider{} + } else if len(availableProviders) == 0 { + availableProviders = existingProviders + } else { + availableProviders = slices.DeleteFunc(availableProviders, func(provider schemas.ModelProvider) bool { + return !slices.Contains(existingProviders, provider) + }) + } + } availableProvidersStrs := make([]string, len(availableProviders)) for i, p := range availableProviders { availableProvidersStrs[i] = string(p) @@ -814,6 +826,8 @@ func (g *GenericRouter) createHandler(config RouteConfig) fasthttp.RequestHandle )) } bifrostCtx.SetValue(schemas.BifrostContextKeyAvailableProviders, availableProviders) + } else if hasExistingProviders { + bifrostCtx.SetValue(schemas.BifrostContextKeyAvailableProviders, []schemas.ModelProvider{}) } schemas.AppendToContextList(bifrostCtx, schemas.BifrostContextKeyRoutingEnginesUsed, schemas.RoutingEngineModelCatalog) } @@ -912,7 +926,7 @@ func (g *GenericRouter) createHandler(config RouteConfig) fasthttp.RequestHandle } // Extract and parse fallbacks from the request if present - if err := g.extractAndParseFallbacks(req, bifrostReq); err != nil { + if err := g.extractAndParseFallbacks(bifrostCtx, req, bifrostReq); err != nil { g.sendError(ctx, bifrostCtx, config.ErrorConverter, newBifrostError(err, "failed to parse fallbacks: "+err.Error())) return } @@ -2840,7 +2854,10 @@ func extractModelFromPath(path string) string { path = strings.TrimPrefix(path, "/") parts := strings.Split(path, "/") for i, p := range parts { - if p == "models" || p == "tunedModels" { + // GenAI uses models/{model} and tunedModels/{model}; Azure OpenAI uses + // deployments/{deployment}, where the deployment name is the model identifier + // (deployment-based Azure routes usually omit "model" from the request body). + if p == "models" || p == "tunedModels" || p == "deployments" { if i+1 < len(parts) { model := parts[i+1] // Strip :suffix for GenAI (e.g. :generateContent, :streamGenerateContent) @@ -3054,7 +3071,22 @@ func (g *GenericRouter) handlePassthroughStream( ctx.SetUserValue(schemas.BifrostContextKeyDeferTraceCompletion, true) ctx.SetStatusCode(passthroughResp.StatusCode) - ctx.SetContentType("text/event-stream") + // Preserve the upstream Content-Type. Passthrough streams aren't always SSE — e.g. + // Vertex/Gemini :streamGenerateContent without ?alt=sse returns an incrementally-delivered + // JSON array with Content-Type: application/json. Forcing text/event-stream mislabels that + // stream, so clients that dispatch on content-type run an SSE parser over a non-SSE body and + // hang. Fall back to text/event-stream only when the upstream didn't provide a Content-Type. + contentType := "" + for k, v := range passthroughResp.Headers { + if strings.EqualFold(k, "content-type") { + contentType = v + break + } + } + if contentType == "" { + contentType = "text/event-stream" + } + ctx.SetContentType(contentType) ctx.Response.Header.Set("Cache-Control", "no-cache") ctx.Response.Header.Set("Connection", "keep-alive") ctx.Response.Header.Set("X-Accel-Buffering", "no") @@ -3063,7 +3095,8 @@ func (g *GenericRouter) handlePassthroughStream( case "connection", "transfer-encoding", "content-length", "content-type", "cache-control", "x-accel-buffering", "set-cookie", "proxy-authenticate", "www-authenticate": - // drop — streaming invariants are set explicitly above; upstream must not override them + // drop — streaming invariants are set explicitly above (Content-Type is set from the + // upstream value before this loop); upstream must not override them here default: ctx.Response.Header.Set(k, v) } diff --git a/transports/bifrost-http/integrations/router_test.go b/transports/bifrost-http/integrations/router_test.go index c96e3808d1f..90bfee725d6 100644 --- a/transports/bifrost-http/integrations/router_test.go +++ b/transports/bifrost-http/integrations/router_test.go @@ -13,6 +13,7 @@ import ( "time" "github.com/bytedance/sonic" + "github.com/maximhq/bifrost/core/providers/anthropic" "github.com/maximhq/bifrost/core/providers/openai" "github.com/maximhq/bifrost/core/schemas" "github.com/stretchr/testify/assert" @@ -376,6 +377,95 @@ func TestOpenAIChatStructuredOutputRequestParserAndConverter(t *testing.T) { assert.Contains(t, responseFormat, "json_schema") } +func TestCreateHandler_AnthropicRouteConstrainsCatalogProvidersWhenAvailableProvidersSet(t *testing.T) { + handlerStore := &mockHandlerStore{ + availableProviders: []schemas.ModelProvider{ + schemas.Anthropic, + schemas.Azure, + schemas.Bedrock, + schemas.Vertex, + }, + } + + var capturedProviders []schemas.ModelProvider + route := RouteConfig{ + Type: RouteConfigTypeAnthropic, + Path: "/v1/messages", + Method: fasthttp.MethodPost, + GetHTTPRequestType: func(ctx *fasthttp.RequestCtx) schemas.RequestType { + return schemas.ResponsesRequest + }, + GetRequestTypeInstance: func(ctx context.Context) interface{} { + return &anthropic.AnthropicMessageRequest{} + }, + GetRequestModel: anthropicModelGetter, + PreCallback: checkAnthropicPassthrough, + RequestConverter: func(ctx *schemas.BifrostContext, req interface{}) (*schemas.BifrostRequest, error) { + capturedProviders, _ = ctx.Value(schemas.BifrostContextKeyAvailableProviders).([]schemas.ModelProvider) + return nil, fmt.Errorf("stop before bifrost execution") + }, + ErrorConverter: func(ctx *schemas.BifrostContext, err *schemas.BifrostError) interface{} { + return err + }, + } + + router := NewGenericRouter(nil, handlerStore, nil, nil, nil) + ctx := &fasthttp.RequestCtx{} + ctx.Request.Header.SetMethod(fasthttp.MethodPost) + ctx.SetUserValue(schemas.BifrostContextKeyAvailableProviders, []schemas.ModelProvider{ + schemas.Azure, + schemas.OpenAI, + schemas.Ollama, + }) + ctx.Request.SetBodyString(`{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`) + + router.createHandler(route)(ctx) + + require.Equal(t, fasthttp.StatusInternalServerError, ctx.Response.StatusCode()) + require.Equal(t, []schemas.ModelProvider{schemas.Azure}, capturedProviders) +} + +func TestCreateHandler_AnthropicRouteKeepsCatalogProvidersWhenAvailableProvidersUnset(t *testing.T) { + handlerStore := &mockHandlerStore{ + availableProviders: []schemas.ModelProvider{ + schemas.Bedrock, + schemas.Vertex, + }, + } + + var capturedProviders []schemas.ModelProvider + route := RouteConfig{ + Type: RouteConfigTypeAnthropic, + Path: "/v1/messages", + Method: fasthttp.MethodPost, + GetHTTPRequestType: func(ctx *fasthttp.RequestCtx) schemas.RequestType { + return schemas.ResponsesRequest + }, + GetRequestTypeInstance: func(ctx context.Context) interface{} { + return &anthropic.AnthropicMessageRequest{} + }, + GetRequestModel: anthropicModelGetter, + PreCallback: checkAnthropicPassthrough, + RequestConverter: func(ctx *schemas.BifrostContext, req interface{}) (*schemas.BifrostRequest, error) { + capturedProviders, _ = ctx.Value(schemas.BifrostContextKeyAvailableProviders).([]schemas.ModelProvider) + return nil, fmt.Errorf("stop before bifrost execution") + }, + ErrorConverter: func(ctx *schemas.BifrostContext, err *schemas.BifrostError) interface{} { + return err + }, + } + + router := NewGenericRouter(nil, handlerStore, nil, nil, nil) + ctx := &fasthttp.RequestCtx{} + ctx.Request.Header.SetMethod(fasthttp.MethodPost) + ctx.Request.SetBodyString(`{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`) + + router.createHandler(route)(ctx) + + require.Equal(t, fasthttp.StatusInternalServerError, ctx.Response.StatusCode()) + require.Equal(t, []schemas.ModelProvider{schemas.Bedrock, schemas.Vertex}, capturedProviders) +} + func TestCreateHandler_CustomParserFailureClosesConnection(t *testing.T) { handlerStore := &mockHandlerStore{} converterCalled := false @@ -598,3 +688,53 @@ func TestExtraParamsSetViaInterfaceMutatesOriginalReq(t *testing.T) { assert.Contains(t, bifrostReq.ChatRequest.Params.ExtraParams, "guardrailConfig", "extra params should propagate through RequestConverter to BifrostChatRequest") } + +// TestExtractModelFromPath covers model extraction across provider path styles: GenAI +// models/tunedModels (with :action suffixes), Vertex fully-qualified publisher paths, and +// Azure OpenAI deployments/{deployment} (where the deployment name is the model identifier). +func TestExtractModelFromPath(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + {"azure deployment chat", "/openai/deployments/my-gpt4o/chat/completions", "my-gpt4o"}, + {"azure deployment leading-stripped", "openai/deployments/prod-embed-3/embeddings", "prod-embed-3"}, + {"genai models with action", "/v1beta/models/gemini-2.5-pro:generateContent", "gemini-2.5-pro"}, + {"genai models stream action", "/models/gemini-2.5-flash:streamGenerateContent", "gemini-2.5-flash"}, + {"genai tunedModels", "/v1beta/tunedModels/my-tuned-1:generateContent", "my-tuned-1"}, + {"vertex fully-qualified", "/projects/p/locations/us-central1/publishers/google/models/gemini-3-pro:streamGenerateContent", "gemini-3-pro"}, + {"no model segment", "/v1/chat/completions", ""}, + {"deployments with no trailing segment", "/openai/deployments", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := extractModelFromPath(tt.path); got != tt.want { + t.Fatalf("extractModelFromPath(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +// TestExtractPassthroughModel verifies the path value wins when present, and the body model is +// used as a fallback — notably for Azure deployment routes where the body usually omits "model". +func TestExtractPassthroughModel(t *testing.T) { + tests := []struct { + name string + path string + bodyModel string + want string + }{ + {"azure deployment path overrides empty body", "/openai/deployments/my-gpt4o/chat/completions", "", "my-gpt4o"}, + {"body fallback when path has no model", "/openai/v1/chat/completions", "gpt-4o", "gpt-4o"}, + {"path wins over body", "/openai/deployments/dep-a/chat/completions", "ignored-body-model", "dep-a"}, + {"both empty", "/v1/chat/completions", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := extractPassthroughModel(tt.path, tt.bodyModel); got != tt.want { + t.Fatalf("extractPassthroughModel(%q, %q) = %q, want %q", tt.path, tt.bodyModel, got, tt.want) + } + }) + } +} diff --git a/transports/bifrost-http/integrations/utils.go b/transports/bifrost-http/integrations/utils.go index f21360bb4b5..29be18477c6 100644 --- a/transports/bifrost-http/integrations/utils.go +++ b/transports/bifrost-http/integrations/utils.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" "reflect" + "slices" "strconv" "strings" @@ -236,11 +237,11 @@ func (g *GenericRouter) sendError(ctx *fasthttp.RequestCtx, bifrostCtx *schemas. // Naming follows the existing `x-bf-*` request-side convention (see // `x-bf-vk`, `x-bf-key-id`, etc.). const ( - HeaderBifrostProvider = "x-bifrost-provider" - HeaderBifrostOriginalModel = "x-bifrost-original-model" - HeaderBifrostResolvedModel = "x-bifrost-resolved-model" - HeaderBifrostFallbackIndex = "x-bifrost-fallback-index" - HeaderBifrostRequestType = "x-bifrost-request-type" + HeaderBifrostProvider = "x-bifrost-provider" + HeaderBifrostOriginalModel = "x-bifrost-original-model" + HeaderBifrostResolvedModel = "x-bifrost-resolved-model" + HeaderBifrostFallbackIndex = "x-bifrost-fallback-index" + HeaderBifrostRequestType = "x-bifrost-request-type" ) // applyBifrostResponseHeaders writes both the upstream provider response @@ -335,8 +336,8 @@ func (g *GenericRouter) streamLargeResponse(ctx *fasthttp.RequestCtx, bifrostCtx return true } -// extractAndParseFallbacks extracts fallbacks from the integration request and adds them to the BifrostRequest -func (g *GenericRouter) extractAndParseFallbacks(req interface{}, bifrostReq *schemas.BifrostRequest) error { +// extractAndParseFallbacks extracts fallbacks from the integration request and adds them to the BifrostRequest. +func (g *GenericRouter) extractAndParseFallbacks(ctx *schemas.BifrostContext, req interface{}, bifrostReq *schemas.BifrostRequest) error { // Check if the request has a fallbacks field ([]string) fallbacks, err := g.extractFallbacksFromRequest(req) if err != nil { @@ -348,6 +349,11 @@ func (g *GenericRouter) extractAndParseFallbacks(req interface{}, bifrostReq *sc } provider, _, _ := bifrostReq.GetRequestFields() + var availableProviders []schemas.ModelProvider + var hasAvailableProviders bool + if ctx != nil { + availableProviders, hasAvailableProviders = ctx.Value(schemas.BifrostContextKeyAvailableProviders).([]schemas.ModelProvider) + } // Parse fallbacks from strings to Fallback structs parsedFallbacks := make([]schemas.Fallback, 0, len(fallbacks)) @@ -358,6 +364,9 @@ func (g *GenericRouter) extractAndParseFallbacks(req interface{}, bifrostReq *sc // Use ParseModelString to extract provider and model provider, model := schemas.ParseModelString(fallbackStr, provider) + if hasAvailableProviders && !slices.Contains(availableProviders, provider) { + continue + } parsedFallback := schemas.Fallback{ Provider: provider, @@ -367,6 +376,7 @@ func (g *GenericRouter) extractAndParseFallbacks(req interface{}, bifrostReq *sc } if len(parsedFallbacks) == 0 { + bifrostReq.SetFallbacks(nil) return nil // No valid fallbacks found } diff --git a/transports/bifrost-http/integrations/utils_test.go b/transports/bifrost-http/integrations/utils_test.go index de0824b501a..e1e1dd09d30 100644 --- a/transports/bifrost-http/integrations/utils_test.go +++ b/transports/bifrost-http/integrations/utils_test.go @@ -60,7 +60,7 @@ func TestExtractAndParseFallbacks_GeminiGenerationRequest(t *testing.T) { }, } - err := router.extractAndParseFallbacks(geminiReq, bifrostReq) + err := router.extractAndParseFallbacks(newTestBifrostContext(), geminiReq, bifrostReq) require.NoError(t, err) require.NotNil(t, bifrostReq.ResponsesRequest) @@ -69,6 +69,59 @@ func TestExtractAndParseFallbacks_GeminiGenerationRequest(t *testing.T) { assert.Equal(t, "gemini-3-flash-preview", bifrostReq.ResponsesRequest.Fallbacks[0].Model) } +func TestExtractAndParseFallbacks_FiltersByAvailableProviders(t *testing.T) { + router := newTestGenericRouter() + geminiReq := &gemini.GeminiGenerationRequest{ + Model: "gemini/gemini-3-flash-preview", + Fallbacks: []string{ + "azure/claude-opus-4-8", + "bedrock/claude-opus-4-8", + "vertex/claude-opus-4-8", + }, + } + bifrostReq := &schemas.BifrostRequest{ + ResponsesRequest: &schemas.BifrostResponsesRequest{ + Provider: schemas.Gemini, + Model: "gemini-3-flash-preview", + }, + } + ctx := newTestBifrostContext() + ctx.SetValue(schemas.BifrostContextKeyAvailableProviders, []schemas.ModelProvider{schemas.Azure}) + + err := router.extractAndParseFallbacks(ctx, geminiReq, bifrostReq) + + require.NoError(t, err) + require.NotNil(t, bifrostReq.ResponsesRequest) + require.Len(t, bifrostReq.ResponsesRequest.Fallbacks, 1) + assert.Equal(t, schemas.Azure, bifrostReq.ResponsesRequest.Fallbacks[0].Provider) + assert.Equal(t, "claude-opus-4-8", bifrostReq.ResponsesRequest.Fallbacks[0].Model) +} + +func TestExtractAndParseFallbacks_ClearsDisallowedPreparsedFallbacks(t *testing.T) { + router := newTestGenericRouter() + geminiReq := &gemini.GeminiGenerationRequest{ + Model: "gemini/gemini-3-flash-preview", + Fallbacks: []string{"bedrock/claude-opus-4-8"}, + } + bifrostReq := &schemas.BifrostRequest{ + ResponsesRequest: &schemas.BifrostResponsesRequest{ + Provider: schemas.Gemini, + Model: "gemini-3-flash-preview", + Fallbacks: []schemas.Fallback{ + {Provider: schemas.Bedrock, Model: "claude-opus-4-8"}, + }, + }, + } + ctx := newTestBifrostContext() + ctx.SetValue(schemas.BifrostContextKeyAvailableProviders, []schemas.ModelProvider{schemas.Azure}) + + err := router.extractAndParseFallbacks(ctx, geminiReq, bifrostReq) + + require.NoError(t, err) + require.NotNil(t, bifrostReq.ResponsesRequest) + require.Empty(t, bifrostReq.ResponsesRequest.Fallbacks) +} + // TestSendStreamError_PropagatesProviderStatusCode verifies that sendStreamError // sets the HTTP status code from the provider's BifrostError.StatusCode field. // All three providers (OpenAI, Anthropic, Bedrock) return actual HTTP error codes diff --git a/transports/bifrost-http/lib/config.go b/transports/bifrost-http/lib/config.go index 65ab7d069e2..cb0c280c981 100644 --- a/transports/bifrost-http/lib/config.go +++ b/transports/bifrost-http/lib/config.go @@ -103,6 +103,13 @@ const ( DBLookupDelay = 1 * time.Second ) +const ( + // SourceOfTruthSplit preserves the current DB/config.json merge behavior. + SourceOfTruthSplit = "split" + // SourceOfTruthConfigJSON makes present config.json sections authoritative during startup sync. + SourceOfTruthConfigJSON = "config.json" +) + // getWeight safely dereferences a *float64 weight pointer, returning 1.0 as default if nil. // This allows distinguishing between "not set" (nil -> 1.0) and "explicitly set to 0" (0.0). func getWeight(w *float64) float64 { @@ -149,6 +156,7 @@ type ConfigData struct { // empty = deny all, ["*"] = allow all. Setting it to 1 restores v1.4.x semantics: // empty = allow all (equivalent to ["*"]). Version int `json:"version,omitempty"` + SourceOfTruth string `json:"source_of_truth,omitempty"` Client *configstore.ClientConfig `json:"client"` EncryptionKey *schemas.EnvVar `json:"encryption_key"` // Deprecated: Use GovernanceConfig.AuthConfig instead @@ -163,6 +171,9 @@ type ConfigData struct { Plugins []*schemas.PluginConfig `json:"plugins,omitempty"` WebSocket *schemas.WebSocketConfig `json:"websocket,omitempty"` FeatureFlags *FeatureFlagsFileConfig `json:"feature_flags,omitempty"` + + presentSections map[string]bool + presentGovernanceSections map[string]bool } // FeatureFlagsFileConfig is the config.json / Helm shape for feature flag @@ -221,13 +232,111 @@ func (v *FeatureFlagFileValue) UnmarshalJSON(data []byte) error { return nil } +// normalizeSourceOfTruth returns the configured source-of-truth mode, defaulting to split. +func normalizeSourceOfTruth(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", SourceOfTruthSplit: + return SourceOfTruthSplit + case SourceOfTruthConfigJSON: + return SourceOfTruthConfigJSON + default: + // Unknown values fall back to split rather than persisting an invalid + // mode. Schema validation rejects unknown values upstream; this guards + // any path that bypasses it so reconciliation never acts on garbage. + logger.Warn("unknown source_of_truth %q, defaulting to %q", value, SourceOfTruthSplit) + return SourceOfTruthSplit + } +} + +// isConfigJSONSourceOfTruth reports whether present config.json sections own reconciliation. +func (cd *ConfigData) isConfigJSONSourceOfTruth() bool { + if cd == nil { + return false + } + return normalizeSourceOfTruth(cd.SourceOfTruth) == SourceOfTruthConfigJSON +} + +// sectionPresent reports whether a top-level config.json section was explicitly provided. +func (cd *ConfigData) sectionPresent(name string) bool { + if cd == nil { + return false + } + if cd.presentSections != nil { + return cd.presentSections[name] + } + switch name { + case "client": + return cd.Client != nil + case "providers": + return cd.Providers != nil + case "mcp": + return cd.MCP != nil + case "governance": + return cd.Governance != nil + case "plugins": + return cd.Plugins != nil + case "framework": + return cd.FrameworkConfig != nil + case "vector_store": + return cd.VectorStoreConfig != nil + case "logs_store": + return cd.LogsStoreConfig != nil + case "config_store": + return cd.ConfigStoreConfig != nil + default: + return false + } +} + +// governanceSectionPresent reports whether a governance collection was explicitly provided. +func (cd *ConfigData) governanceSectionPresent(name string) bool { + if cd == nil || cd.Governance == nil { + return false + } + if cd.presentGovernanceSections != nil { + return cd.presentGovernanceSections[name] + } + switch name { + case "virtual_keys": + return cd.Governance.VirtualKeys != nil + case "teams": + return cd.Governance.Teams != nil + case "customers": + return cd.Governance.Customers != nil + case "budgets": + return cd.Governance.Budgets != nil + case "rate_limits": + return cd.Governance.RateLimits != nil + case "model_configs": + return cd.Governance.ModelConfigs != nil + case "providers": + return cd.Governance.Providers != nil + case "routing_rules": + return cd.Governance.RoutingRules != nil + case "pricing_overrides": + return cd.Governance.PricingOverrides != nil + default: + return false + } +} + // UnmarshalJSON unmarshals the ConfigData from JSON using internal unmarshallers // for VectorStoreConfig, ConfigStoreConfig, and LogsStoreConfig to ensure proper // type safety and configuration parsing. func (cd *ConfigData) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("failed to inspect config data: %w", err) + } + cd.presentSections = make(map[string]bool, len(raw)) + for key := range raw { + cd.presentSections[key] = true + } + // First, unmarshal into a temporary struct to get all fields except the complex configs type TempConfigData struct { Version int `json:"version,omitempty"` + SourceOfTruth string `json:"source_of_truth,omitempty"` FrameworkConfig json.RawMessage `json:"framework,omitempty"` Client *configstore.ClientConfig `json:"client"` EncryptionKey *schemas.EnvVar `json:"encryption_key"` @@ -250,6 +359,7 @@ func (cd *ConfigData) UnmarshalJSON(data []byte) error { // Set simple fields cd.Version = temp.Version + cd.SourceOfTruth = normalizeSourceOfTruth(temp.SourceOfTruth) cd.Client = temp.Client cd.EncryptionKey = temp.EncryptionKey cd.AuthConfig = temp.AuthConfig @@ -259,6 +369,16 @@ func (cd *ConfigData) UnmarshalJSON(data []byte) error { cd.Plugins = temp.Plugins cd.WebSocket = temp.WebSocket cd.FeatureFlags = temp.FeatureFlags + cd.presentGovernanceSections = nil + if rawGovernance, ok := raw["governance"]; ok && len(rawGovernance) > 0 { + var rawGovernanceFields map[string]json.RawMessage + if err := json.Unmarshal(rawGovernance, &rawGovernanceFields); err == nil { + cd.presentGovernanceSections = make(map[string]bool, len(rawGovernanceFields)) + for key := range rawGovernanceFields { + cd.presentGovernanceSections[key] = true + } + } + } // Initialize providers map if nil if cd.Providers == nil { cd.Providers = make(map[string]configstore.ProviderConfig) @@ -504,6 +624,19 @@ func promoteDeprecatedCalendarAligned(configData *ConfigData) { if configData == nil || configData.Governance == nil { return } + // Build ID-keyed lookup maps for the global budget/rate-limit sections so + // customer entries (which reference by ID, not inline) can promote legacy + // calendar_aligned flags from the referenced rows. + budgetsByID := make(map[string]*configstoreTables.TableBudget, len(configData.Governance.Budgets)) + for i := range configData.Governance.Budgets { + b := &configData.Governance.Budgets[i] + budgetsByID[b.ID] = b + } + rateLimitsByID := make(map[string]*configstoreTables.TableRateLimit, len(configData.Governance.RateLimits)) + for i := range configData.Governance.RateLimits { + rl := &configData.Governance.RateLimits[i] + rateLimitsByID[rl.ID] = rl + } for i := range configData.Governance.VirtualKeys { vk := &configData.Governance.VirtualKeys[i] promoteCalendarAligned(&vk.CalendarAligned, vk.Budgets, vk.RateLimit) @@ -516,6 +649,28 @@ func promoteDeprecatedCalendarAligned(configData *ConfigData) { team := &configData.Governance.Teams[i] promoteCalendarAligned(&team.CalendarAligned, team.Budgets, team.RateLimit) } + for i := range configData.Governance.Customers { + customer := &configData.Governance.Customers[i] + // Inline budgets (new multi-budget format): promote directly. + promoteCalendarAligned(&customer.CalendarAligned, customer.Budgets, nil) + // Legacy budget_id reference: look up the referenced row. + if customer.BudgetID != nil { + if b := budgetsByID[*customer.BudgetID]; b != nil { + if b.CalendarAlignedInput != nil && *b.CalendarAlignedInput { + customer.CalendarAligned = true + } + b.CalendarAlignedInput = nil + } + } + if customer.RateLimitID != nil { + if rl := rateLimitsByID[*customer.RateLimitID]; rl != nil { + if rl.CalendarAlignedInput != nil && *rl.CalendarAlignedInput { + customer.CalendarAligned = true + } + rl.CalendarAlignedInput = nil + } + } + } } // promoteCalendarAligned ORs each child's legacy calendar_aligned input into @@ -996,22 +1151,47 @@ func loadProviders(ctx context.Context, config *Config, configData *ConfigData) logger.Debug("no providers config found in store, processing from config file") providersInConfigStore = make(map[schemas.ModelProvider]configstore.ProviderConfig) } - // Process provider configurations from file - if len(configData.Providers) > 0 { + existingProvidersForPrune := providersInConfigStore + providersSectionPresent := configData.sectionPresent("providers") + if configData.isConfigJSONSourceOfTruth() && providersSectionPresent { + logger.Debug("source_of_truth=config.json: syncing providers exactly from config file") + authoritativeProviders := make(map[schemas.ModelProvider]configstore.ProviderConfig, len(configData.Providers)) for providerName, providerCfgInFile := range configData.Providers { - if err = processProvider(config, providerName, providerCfgInFile, providersInConfigStore); err != nil { + provider := schemas.ModelProvider(strings.ToLower(providerName)) + existingCfg, exists := providersInConfigStore[provider] + if err = processAuthoritativeProvider(providerName, providerCfgInFile, existingCfg, exists, authoritativeProviders); err != nil { logger.Warn("failed to process provider %s: %v", providerName, err) + // Preserve the existing persisted config so a single bad file entry + // does not prune the provider (and its DB-only keys) from the store. + if exists { + authoritativeProviders[provider] = existingCfg + } } } - } else if len(providersInConfigStore) == 0 { - // No providers in file and none in DB — auto-detect from environment - config.autoDetectProviders(ctx) - maps.Copy(providersInConfigStore, config.Providers) + providersInConfigStore = authoritativeProviders + } else { + // Process provider configurations from file + if len(configData.Providers) > 0 { + for providerName, providerCfgInFile := range configData.Providers { + if err = processProvider(config, providerName, providerCfgInFile, providersInConfigStore); err != nil { + logger.Warn("failed to process provider %s: %v", providerName, err) + } + } + } else if len(providersInConfigStore) == 0 && (!configData.isConfigJSONSourceOfTruth() || providersSectionPresent) { + // No providers in file and none in DB — auto-detect from environment + config.autoDetectProviders(ctx) + maps.Copy(providersInConfigStore, config.Providers) + } } // Update store and config if config.ConfigStore != nil { logger.Debug("updating providers config in store") - if err = config.ConfigStore.UpdateProvidersConfig(ctx, providersInConfigStore); err != nil { + if configData.isConfigJSONSourceOfTruth() && providersSectionPresent { + err = syncAuthoritativeProvidersInStore(ctx, config.ConfigStore, existingProvidersForPrune, providersInConfigStore) + } else { + err = config.ConfigStore.UpdateProvidersConfig(ctx, providersInConfigStore) + } + if err != nil { logger.Fatal("failed to update providers config: %v", err) } } @@ -1019,6 +1199,40 @@ func loadProviders(ctx context.Context, config *Config, configData *ConfigData) return nil } +// syncAuthoritativeProvidersInStore persists providers and deletes DB-only providers and keys. +func syncAuthoritativeProvidersInStore( + ctx context.Context, + store configstore.ConfigStore, + existingProviders map[schemas.ModelProvider]configstore.ProviderConfig, + authoritativeProviders map[schemas.ModelProvider]configstore.ProviderConfig, +) error { + return store.ExecuteTransaction(ctx, func(tx *gorm.DB) error { + for provider, existingConfig := range existingProviders { + newConfig, keepProvider := authoritativeProviders[provider] + if !keepProvider { + if err := store.DeleteProvider(ctx, provider, tx); err != nil { + return err + } + continue + } + keepKeys := make(map[string]bool, len(newConfig.Keys)) + for _, key := range newConfig.Keys { + if key.ID != "" { + keepKeys[key.ID] = true + } + } + for _, key := range existingConfig.Keys { + if key.ID != "" && !keepKeys[key.ID] { + if err := store.DeleteProviderKey(ctx, provider, key.ID, tx); err != nil { + return err + } + } + } + } + return store.UpdateProvidersConfig(ctx, authoritativeProviders, tx) + }) +} + // processProvider processes a single provider configuration from config file func processProvider( _ *Config, @@ -1048,6 +1262,37 @@ func processProvider( return nil } +// processAuthoritativeProvider adds one config.json provider to the authoritative provider set. +func processAuthoritativeProvider( + providerName string, + providerCfgInFile configstore.ProviderConfig, + existingCfg configstore.ProviderConfig, + exists bool, + providers map[schemas.ModelProvider]configstore.ProviderConfig, +) error { + provider := schemas.ModelProvider(strings.ToLower(providerName)) + for i, providerKeyInFile := range providerCfgInFile.Keys { + if providerKeyInFile.ID == "" { + providerCfgInFile.Keys[i].ID = uuid.NewString() + } + if err := providerKeyInFile.Aliases.Validate(); err != nil { + return fmt.Errorf("invalid aliases for key %q in provider %s: %w", providerKeyInFile.Name, provider, err) + } + } + fileProviderConfigHash, err := providerCfgInFile.GenerateConfigHash(string(provider)) + if err != nil { + logger.Warn("failed to generate config hash for %s: %v", provider, err) + } + providerCfgInFile.ConfigHash = fileProviderConfigHash + if exists { + providerCfgInFile.Keys = mergeProviderKeys(provider, providerCfgInFile.Keys, existingCfg.Keys) + providerCfgInFile.Status = existingCfg.Status + providerCfgInFile.Description = existingCfg.Description + } + providers[provider] = providerCfgInFile + return nil +} + // mergeProviderWithHash merges provider config using hash-based reconciliation func mergeProviderWithHash( provider schemas.ModelProvider, @@ -1284,7 +1529,11 @@ func loadMCPConfig(ctx context.Context, config *Config, configData *ConfigData) if config.MCPConfig != nil { // Merge with config file if present if configData.MCP != nil { - mergeMCPConfig(ctx, config, configData, config.MCPConfig) + if configData.isConfigJSONSourceOfTruth() && configData.sectionPresent("mcp") { + syncMCPConfigFromFile(ctx, config, configData, config.MCPConfig) + } else { + mergeMCPConfig(ctx, config, configData, config.MCPConfig) + } } } else if configData.MCP != nil { // MCP config not in store, use config file @@ -1474,6 +1723,102 @@ func mcpClientConfigToTable(clientConfig *schemas.MCPClientConfig) (configstoreT }, nil } +// syncMCPConfigFromFile replaces stored MCP clients with the clients declared in config.json. +// +// Unlike the provider/plugin/governance syncs, this reconciliation is intentionally +// best-effort rather than transactional: the MCP store methods (Create/Update/Delete +// MCPClientConfig) do not accept an enclosing tx, and each client is reconciled +// independently so a single malformed client only logs a warning instead of aborting +// startup. An all-or-nothing transaction is incompatible with that warn-and-continue +// behavior, and blocking boot on one bad MCP entry is the worse failure mode here. +func syncMCPConfigFromFile(ctx context.Context, config *Config, configData *ConfigData, mcpConfig *schemas.MCPConfig) { + logger.Debug("source_of_truth=config.json: syncing MCP config exactly from config file") + if configData.MCP == nil { + return + } + fileMCPConfig := configData.MCP + existingByName := make(map[string]*schemas.MCPClientConfig, len(mcpConfig.ClientConfigs)) + existingByID := make(map[string]*schemas.MCPClientConfig, len(mcpConfig.ClientConfigs)) + for _, existing := range mcpConfig.ClientConfigs { + if existing == nil { + continue + } + if existing.Name != "" { + existingByName[existing.Name] = existing + } + if existing.ID != "" { + existingByID[existing.ID] = existing + } + } + + keepIDs := make(map[string]bool, len(fileMCPConfig.ClientConfigs)) + updates := make([]configstoreTables.TableMCPClient, 0) + adds := make([]*schemas.MCPClientConfig, 0) + for _, fileClient := range fileMCPConfig.ClientConfigs { + if fileClient == nil { + continue + } + existing := existingByName[fileClient.Name] + if existing == nil && fileClient.ID != "" { + existing = existingByID[fileClient.ID] + } + // Mark the matched existing client as kept up-front so a later validation + // failure (which `continue`s) does not cause the prune loop to delete it. + if existing != nil && existing.ID != "" { + keepIDs[existing.ID] = true + } + if fileClient.ID == "" { + if existing != nil && existing.ID != "" { + fileClient.ID = existing.ID + } else { + fileClient.ID = uuid.NewString() + } + } + fileRow, err := mcpClientConfigToTable(fileClient) + if err != nil { + logger.Warn("invalid MCP client config for %q: %v", fileClient.Name, err) + continue + } + fileHash, err := configstore.GenerateMCPClientHash(fileRow) + if err != nil { + logger.Warn("failed to generate MCP client hash for %q: %v", fileClient.Name, err) + continue + } + fileClient.ConfigHash = fileHash + fileRow.ConfigHash = fileHash + keepIDs[fileClient.ID] = true + if existing == nil { + adds = append(adds, fileClient) + } else { + fileRow.ClientID = existing.ID + updates = append(updates, fileRow) + } + } + + if config.ConfigStore != nil { + for _, existing := range mcpConfig.ClientConfigs { + if existing == nil || existing.ID == "" || keepIDs[existing.ID] { + continue + } + if err := config.ConfigStore.DeleteMCPClientConfig(ctx, existing.ID); err != nil { + logger.Warn("failed to delete MCP client config %q: %v", existing.Name, err) + } + } + for _, add := range adds { + if err := config.ConfigStore.CreateMCPClientConfig(ctx, add); err != nil { + logger.Warn("failed to create MCP client config: %v", err) + } + } + for i := range updates { + update := updates[i] + if err := config.ConfigStore.UpdateMCPClientConfig(ctx, update.ClientID, &update); err != nil { + logger.Warn("failed to update MCP client config %q: %v", update.Name, err) + } + } + } + config.MCPConfig = fileMCPConfig +} + // loadGovernanceConfig loads and merges governance config from file func loadGovernanceConfig(ctx context.Context, config *Config, configData *ConfigData) { if configData.Governance != nil { @@ -2018,6 +2363,162 @@ func mergeGovernanceConfig(ctx context.Context, config *Config, configData *Conf } } } + if configData.isConfigJSONSourceOfTruth() { + pruneGovernanceConfigToFile(ctx, config, configData) + } +} + +// pruneGovernanceConfigToFile removes DB-only governance rows for file-present collections. +func pruneGovernanceConfigToFile(ctx context.Context, config *Config, configData *ConfigData) { + if config.ConfigStore == nil || config.GovernanceConfig == nil || configData.Governance == nil { + return + } + logger.Debug("source_of_truth=config.json: pruning governance rows not present in config file") + err := config.ConfigStore.ExecuteTransaction(ctx, func(tx *gorm.DB) error { + if configData.governanceSectionPresent("virtual_keys") { + keep := make(map[string]bool, len(configData.Governance.VirtualKeys)) + for i := range configData.Governance.VirtualKeys { + vk := &configData.Governance.VirtualKeys[i] + keep[vk.ID] = true + // Unchanged VKs never went through resolveMCPConfigClientIDs in + // mergeGovernanceConfig, so their MCPConfigs may still carry + // mcp_client_name with MCPClientID==0. Resolve before reconciling + // to avoid creating/deleting client-id 0 associations. + vk.MCPConfigs = resolveMCPConfigClientIDs(ctx, config.ConfigStore, vk.MCPConfigs, vk.ID) + if err := reconcileVirtualKeyAssociations(ctx, config.ConfigStore, tx, vk.ID, vk.ProviderConfigs, vk.MCPConfigs); err != nil { + return fmt.Errorf("failed to reconcile associations for virtual key %s: %w", vk.ID, err) + } + } + for _, existing := range config.GovernanceConfig.VirtualKeys { + if existing.ID != "" && !keep[existing.ID] { + if err := config.ConfigStore.DeleteVirtualKey(ctx, existing.ID, tx); err != nil { + return fmt.Errorf("failed to delete virtual key %s: %w", existing.ID, err) + } + } + } + config.GovernanceConfig.VirtualKeys = configData.Governance.VirtualKeys + } + if configData.governanceSectionPresent("routing_rules") { + keep := make(map[string]bool, len(configData.Governance.RoutingRules)) + for _, row := range configData.Governance.RoutingRules { + keep[row.ID] = true + } + for _, existing := range config.GovernanceConfig.RoutingRules { + if existing.ID != "" && !keep[existing.ID] { + if err := config.ConfigStore.DeleteRoutingRule(ctx, existing.ID, tx); err != nil { + return fmt.Errorf("failed to delete routing rule %s: %w", existing.ID, err) + } + } + } + config.GovernanceConfig.RoutingRules = configData.Governance.RoutingRules + } + if configData.governanceSectionPresent("pricing_overrides") { + keep := make(map[string]bool, len(configData.Governance.PricingOverrides)) + for _, row := range configData.Governance.PricingOverrides { + keep[row.ID] = true + } + for _, existing := range config.GovernanceConfig.PricingOverrides { + if existing.ID != "" && !keep[existing.ID] { + if err := config.ConfigStore.DeletePricingOverride(ctx, existing.ID, tx); err != nil { + return fmt.Errorf("failed to delete pricing override %s: %w", existing.ID, err) + } + } + } + config.GovernanceConfig.PricingOverrides = configData.Governance.PricingOverrides + } + if configData.governanceSectionPresent("model_configs") { + keep := make(map[string]bool, len(configData.Governance.ModelConfigs)) + for _, row := range configData.Governance.ModelConfigs { + keep[row.ID] = true + } + for _, existing := range config.GovernanceConfig.ModelConfigs { + if existing.ID != "" && !keep[existing.ID] { + if err := config.ConfigStore.DeleteModelConfig(ctx, existing.ID, tx); err != nil { + return fmt.Errorf("failed to delete model config %s: %w", existing.ID, err) + } + } + } + config.GovernanceConfig.ModelConfigs = configData.Governance.ModelConfigs + } + if configData.governanceSectionPresent("teams") { + keep := make(map[string]bool, len(configData.Governance.Teams)) + for _, row := range configData.Governance.Teams { + keep[row.ID] = true + } + for _, existing := range config.GovernanceConfig.Teams { + if existing.ID != "" && !keep[existing.ID] { + if err := config.ConfigStore.DeleteTeam(ctx, existing.ID, tx); err != nil { + return fmt.Errorf("failed to delete team %s: %w", existing.ID, err) + } + } + } + config.GovernanceConfig.Teams = configData.Governance.Teams + } + if configData.governanceSectionPresent("customers") { + keep := make(map[string]bool, len(configData.Governance.Customers)) + for _, row := range configData.Governance.Customers { + keep[row.ID] = true + } + for _, existing := range config.GovernanceConfig.Customers { + if existing.ID != "" && !keep[existing.ID] { + if err := config.ConfigStore.DeleteCustomer(ctx, existing.ID, tx); err != nil { + return fmt.Errorf("failed to delete customer %s: %w", existing.ID, err) + } + } + } + config.GovernanceConfig.Customers = configData.Governance.Customers + } + if configData.governanceSectionPresent("providers") { + keep := make(map[string]configstoreTables.TableProvider, len(configData.Governance.Providers)) + for _, row := range configData.Governance.Providers { + keep[row.Name] = row + } + for _, existing := range config.GovernanceConfig.Providers { + if existing.Name == "" || keep[existing.Name].Name != "" { + continue + } + if err := tx.Model(&configstoreTables.TableProvider{}). + Where("name = ?", existing.Name). + Select("budget_id", "rate_limit_id"). + Updates(map[string]interface{}{"budget_id": nil, "rate_limit_id": nil}).Error; err != nil { + return fmt.Errorf("failed to clear provider governance mapping for %s: %w", existing.Name, err) + } + } + config.GovernanceConfig.Providers = configData.Governance.Providers + } + if configData.governanceSectionPresent("budgets") { + keep := make(map[string]bool, len(configData.Governance.Budgets)) + for _, row := range configData.Governance.Budgets { + keep[row.ID] = true + } + for _, existing := range config.GovernanceConfig.Budgets { + if existing.ID != "" && !keep[existing.ID] { + if err := config.ConfigStore.DeleteBudget(ctx, existing.ID, tx); err != nil { + return fmt.Errorf("failed to delete budget %s: %w", existing.ID, err) + } + } + } + config.GovernanceConfig.Budgets = configData.Governance.Budgets + } + if configData.governanceSectionPresent("rate_limits") { + keep := make(map[string]bool, len(configData.Governance.RateLimits)) + for _, row := range configData.Governance.RateLimits { + keep[row.ID] = true + } + for _, existing := range config.GovernanceConfig.RateLimits { + if existing.ID != "" && !keep[existing.ID] { + if err := config.ConfigStore.DeleteRateLimit(ctx, existing.ID, tx); err != nil { + return fmt.Errorf("failed to delete rate limit %s: %w", existing.ID, err) + } + } + } + config.GovernanceConfig.RateLimits = configData.Governance.RateLimits + } + return nil + }) + if err != nil { + logger.Fatal("failed to prune governance config: %v", err) + } } // updateGovernanceConfigInStore updates governance config items in the store @@ -2049,12 +2550,15 @@ func updateGovernanceConfigInStore( // - team_id -> governance_teams // - virtual_key_id -> governance_virtual_keys // - provider_config_id -> governance_virtual_key_provider_configs + // - customer_id -> governance_customers pendingTeamBudgetsToAdd := make([]configstoreTables.TableBudget, 0) pendingVirtualKeyBudgetsToAdd := make([]configstoreTables.TableBudget, 0) pendingProviderConfigBudgetsToAdd := make([]configstoreTables.TableBudget, 0) + pendingCustomerBudgetsToAdd := make([]configstoreTables.TableBudget, 0) pendingTeamBudgetsToUpdate := make([]configstoreTables.TableBudget, 0) pendingVirtualKeyBudgetsToUpdate := make([]configstoreTables.TableBudget, 0) pendingProviderConfigBudgetsToUpdate := make([]configstoreTables.TableBudget, 0) + pendingCustomerBudgetsToUpdate := make([]configstoreTables.TableBudget, 0) // Create budgets for _, budget := range budgetsToAdd { @@ -2070,6 +2574,10 @@ func updateGovernanceConfigInStore( pendingProviderConfigBudgetsToAdd = append(pendingProviderConfigBudgetsToAdd, budget) continue } + if budget.CustomerID != nil { + pendingCustomerBudgetsToAdd = append(pendingCustomerBudgetsToAdd, budget) + continue + } if err := config.ConfigStore.CreateBudget(ctx, &budget, tx); err != nil { return fmt.Errorf("failed to create budget %s: %w", budget.ID, err) } @@ -2089,6 +2597,10 @@ func updateGovernanceConfigInStore( pendingProviderConfigBudgetsToUpdate = append(pendingProviderConfigBudgetsToUpdate, budget) continue } + if budget.CustomerID != nil { + pendingCustomerBudgetsToUpdate = append(pendingCustomerBudgetsToUpdate, budget) + continue + } if err := config.ConfigStore.UpdateBudget(ctx, &budget, tx); err != nil { return fmt.Errorf("failed to update budget %s: %w", budget.ID, err) } @@ -2108,20 +2620,88 @@ func updateGovernanceConfigInStore( } } - // Create customers - for _, customer := range customersToAdd { - if err := config.ConfigStore.CreateCustomer(ctx, &customer, tx); err != nil { + // Create customers — strip inline Budgets first; created explicitly after the row exists. + for i := range customersToAdd { + customer := &customersToAdd[i] + for j := range customer.Budgets { + cid := customer.ID + customer.Budgets[j].CustomerID = &cid + pendingCustomerBudgetsToAdd = append(pendingCustomerBudgetsToAdd, customer.Budgets[j]) + } + customer.Budgets = nil + if err := config.ConfigStore.CreateCustomer(ctx, customer, tx); err != nil { return fmt.Errorf("failed to create customer %s: %w", customer.ID, err) } } // Update customers (config.json changed) - for _, customer := range customersToUpdate { - if err := config.ConfigStore.UpdateCustomer(ctx, &customer, tx); err != nil { + for i := range customersToUpdate { + customer := &customersToUpdate[i] + if customer.Budgets != nil { + // Fetch existing budget IDs for this customer so we can route + // to add vs update — avoids INSERT conflicts on second sync. + var existingIDs []string + if err := tx.Model(&configstoreTables.TableBudget{}). + Where("customer_id = ?", customer.ID). + Pluck("id", &existingIDs).Error; err != nil { + return fmt.Errorf("failed to query existing budgets for customer %s: %w", customer.ID, err) + } + existingSet := make(map[string]bool, len(existingIDs)) + for _, id := range existingIDs { + existingSet[id] = true + } + desiredSet := make(map[string]bool, len(customer.Budgets)) + for j := range customer.Budgets { + cid := customer.ID + customer.Budgets[j].CustomerID = &cid + desiredSet[customer.Budgets[j].ID] = true + if existingSet[customer.Budgets[j].ID] { + pendingCustomerBudgetsToUpdate = append(pendingCustomerBudgetsToUpdate, customer.Budgets[j]) + } else { + pendingCustomerBudgetsToAdd = append(pendingCustomerBudgetsToAdd, customer.Budgets[j]) + } + } + // Delete stale budgets one by one — mirrors the team/VK reconcile pattern. + for _, existingID := range existingIDs { + if !desiredSet[existingID] { + if err := config.ConfigStore.DeleteBudget(ctx, existingID, tx); err != nil { + return fmt.Errorf("failed to delete stale budget %s for customer %s: %w", existingID, customer.ID, err) + } + } + } + } + customer.Budgets = nil + if err := config.ConfigStore.UpdateCustomer(ctx, customer, tx); err != nil { return fmt.Errorf("failed to update customer %s: %w", customer.ID, err) } } + // Link budget_id references: validate ownership and set customer_id. + // For adds: verify the budget is unowned before taking it. + // For updates: also clear any stale link from the old budget_id. + for _, customer := range customersToAdd { + if customer.BudgetID == nil { + continue + } + if err := linkCustomerBudgetID(tx, customer.ID, *customer.BudgetID, false); err != nil { + return fmt.Errorf("failed to link budget %s to customer %s: %w", *customer.BudgetID, customer.ID, err) + } + } + for _, customer := range customersToUpdate { + if customer.BudgetID == nil { + // budget_id removed — unlink any budget previously owned via this path. + if err := tx.Model(&configstoreTables.TableBudget{}). + Where("customer_id = ?", customer.ID). + Update("customer_id", nil).Error; err != nil { + return fmt.Errorf("failed to unlink stale budgets from customer %s: %w", customer.ID, err) + } + continue + } + if err := linkCustomerBudgetID(tx, customer.ID, *customer.BudgetID, true); err != nil { + return fmt.Errorf("failed to link budget %s to customer %s: %w", *customer.BudgetID, customer.ID, err) + } + } + // Create teams for _, team := range teamsToAdd { if err := config.ConfigStore.CreateTeam(ctx, &team, tx); err != nil { @@ -2150,6 +2730,20 @@ func updateGovernanceConfigInStore( } } + // Create customer-owned budgets after customers exist (inline budgets + top-level with customer_id). + for _, budget := range pendingCustomerBudgetsToAdd { + if err := config.ConfigStore.CreateBudget(ctx, &budget, tx); err != nil { + return fmt.Errorf("failed to create budget %s: %w", budget.ID, err) + } + } + + // Update customer-owned budgets declared in top-level governance.budgets. + for _, budget := range pendingCustomerBudgetsToUpdate { + if err := config.ConfigStore.UpdateBudget(ctx, &budget, tx); err != nil { + return fmt.Errorf("failed to update budget %s: %w", budget.ID, err) + } + } + // Create virtual keys with explicit association handling for i := range virtualKeysToAdd { virtualKey := &virtualKeysToAdd[i] @@ -2251,6 +2845,11 @@ func updateGovernanceConfigInStore( if err := config.ConfigStore.CreateModelConfig(ctx, &modelConfig, tx); err != nil { return fmt.Errorf("failed to create model config %s: %w", modelConfig.ID, err) } + if len(modelConfig.BudgetIDs) > 0 { + if err := linkModelConfigBudgets(tx, modelConfig.ID, modelConfig.BudgetIDs); err != nil { + return err + } + } } // Update model configs (config.json changed) @@ -2261,6 +2860,11 @@ func updateGovernanceConfigInStore( if err := config.ConfigStore.UpdateModelConfig(ctx, &modelConfig, tx); err != nil { return fmt.Errorf("failed to update model config %s: %w", modelConfig.ID, err) } + if len(modelConfig.BudgetIDs) > 0 { + if err := linkModelConfigBudgets(tx, modelConfig.ID, modelConfig.BudgetIDs); err != nil { + return err + } + } } // Upsert provider governance links (budget_id/rate_limit_id) for newly added mappings. @@ -2332,6 +2936,86 @@ func validateModelConfigGovernanceOwnership(tx *gorm.DB, modelConfig configstore if err := validateRateLimitLinkOwnership(tx, modelConfig.RateLimitID, "model config", modelConfig.ID); err != nil { return err } + for _, budgetID := range modelConfig.BudgetIDs { + id := budgetID + if err := validateBudgetLinkOwnership(tx, &id, "model config", modelConfig.ID); err != nil { + return err + } + } + return nil +} + +// linkCustomerBudgetID sets customer_id on the referenced budget row, verifying that the budget +// is either unowned or already owned by this customer. When clearStale is true it also +// unlinks any other budget previously owned by the customer (handles budget_id changes). +func linkCustomerBudgetID(tx *gorm.DB, customerID, budgetID string, clearStale bool) error { + var existing configstoreTables.TableBudget + if err := tx.Select("id", "customer_id", "team_id", "virtual_key_id", "provider_config_id", "model_config_id"). + First(&existing, "id = ?", budgetID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("budget %s not found", budgetID) + } + return fmt.Errorf("failed to check budget ownership: %w", err) + } + if (existing.CustomerID != nil && *existing.CustomerID != customerID) || + existing.TeamID != nil || existing.VirtualKeyID != nil || + existing.ProviderConfigID != nil || existing.ModelConfigID != nil { + return fmt.Errorf("budget %s is already owned by another entity", budgetID) + } + if clearStale { + if err := tx.Model(&configstoreTables.TableBudget{}). + Where("customer_id = ? AND id != ?", customerID, budgetID). + Update("customer_id", nil).Error; err != nil { + return fmt.Errorf("failed to unlink stale budget from customer %s: %w", customerID, err) + } + } + result := tx.Model(&configstoreTables.TableBudget{}). + Where("id = ?", budgetID). + Update("customer_id", customerID) + if result.Error != nil { + return fmt.Errorf("failed to link budget %s to customer %s: %w", budgetID, customerID, result.Error) + } + if result.RowsAffected == 0 { + return fmt.Errorf("failed to link budget %s to customer %s: no row updated", budgetID, customerID) + } + return nil +} + +// linkModelConfigBudgets sets model_config_id on each budget in budgetIDs, and clears it from +// any budgets previously owned by mcID that are no longer in the list. +func linkModelConfigBudgets(tx *gorm.DB, mcID string, budgetIDs []string) error { + // Normalize: trim whitespace and deduplicate. + seen := make(map[string]struct{}, len(budgetIDs)) + normalized := make([]string, 0, len(budgetIDs)) + for _, raw := range budgetIDs { + id := strings.TrimSpace(raw) + if id == "" { + continue + } + if _, dup := seen[id]; dup { + continue + } + seen[id] = struct{}{} + normalized = append(normalized, id) + } + + // Clear ownership from budgets that are no longer referenced. + unlinkQ := tx.Model(&configstoreTables.TableBudget{}). + Where("model_config_id = ?", mcID) + if len(normalized) > 0 { + unlinkQ = unlinkQ.Where("id NOT IN ?", normalized) + } + if err := unlinkQ.Update("model_config_id", nil).Error; err != nil { + return fmt.Errorf("failed to unlink stale budgets from model config %q: %w", mcID, err) + } + // Link the declared budgets. + for _, id := range normalized { + if err := tx.Model(&configstoreTables.TableBudget{}). + Where("id = ?", id). + Update("model_config_id", mcID).Error; err != nil { + return fmt.Errorf("failed to link budget %q to model config %q: %w", id, mcID, err) + } + } return nil } @@ -2530,6 +3214,11 @@ func createGovernanceConfigInStore(ctx context.Context, config *Config) { if err := config.ConfigStore.CreateModelConfig(ctx, modelConfig, tx); err != nil { return fmt.Errorf("failed to create model config %s: %w", modelConfig.ID, err) } + if len(modelConfig.BudgetIDs) > 0 { + if err := linkModelConfigBudgets(tx, modelConfig.ID, modelConfig.BudgetIDs); err != nil { + return err + } + } } for i := range config.GovernanceConfig.Providers { provider := &config.GovernanceConfig.Providers[i] @@ -2563,9 +3252,26 @@ func createGovernanceConfigInStore(ctx context.Context, config *Config) { } else { customer.ConfigHash = customerHash } - if err := config.ConfigStore.CreateCustomer(ctx, customer, tx); err != nil { + // Work on a copy so the live GovernanceConfig entry keeps its Budgets + // slice — in-memory reads after boot must not see a nil slice. + inlineBudgets := customer.Budgets + customerRow := *customer + customerRow.Budgets = nil + if err := config.ConfigStore.CreateCustomer(ctx, &customerRow, tx); err != nil { return fmt.Errorf("failed to create customer %s: %w", customer.ID, err) } + for j := range inlineBudgets { + cid := customer.ID + inlineBudgets[j].CustomerID = &cid + if err := config.ConfigStore.CreateBudget(ctx, &inlineBudgets[j], tx); err != nil { + return fmt.Errorf("failed to create budget %s for customer %s: %w", inlineBudgets[j].ID, customer.ID, err) + } + } + if customer.BudgetID != nil { + if err := linkCustomerBudgetID(tx, customer.ID, *customer.BudgetID, false); err != nil { + return fmt.Errorf("failed to link budget %s to customer %s: %w", *customer.BudgetID, customer.ID, err) + } + } } for i := range config.GovernanceConfig.Teams { @@ -2849,7 +3555,13 @@ func loadPlugins(ctx context.Context, config *Config, configData *ConfigData) { // Merge with config file plugins if len(configData.Plugins) > 0 { - mergePlugins(ctx, config, configData) + if configData.isConfigJSONSourceOfTruth() && configData.sectionPresent("plugins") { + syncPluginsFromFile(ctx, config, configData) + } else { + mergePlugins(ctx, config, configData) + } + } else if configData.isConfigJSONSourceOfTruth() && configData.sectionPresent("plugins") { + syncPluginsFromFile(ctx, config, configData) } } @@ -2936,6 +3648,69 @@ func mergePlugins(ctx context.Context, config *Config, configData *ConfigData) { } } +// syncPluginsFromFile replaces stored plugin configs with the plugins declared in config.json. +func syncPluginsFromFile(ctx context.Context, config *Config, configData *ConfigData) { + logger.Debug("source_of_truth=config.json: syncing plugins exactly from config file") + if config.ConfigStore == nil { + // No store to reconcile against, so in-memory cannot diverge from the DB. + config.PluginConfigs = configData.Plugins + return + } + keep := make(map[string]bool, len(configData.Plugins)) + for _, plugin := range configData.Plugins { + if plugin == nil { + continue + } + keep[plugin.Name] = true + } + err := config.ConfigStore.ExecuteTransaction(ctx, func(tx *gorm.DB) error { + existing, err := config.ConfigStore.GetPlugins(ctx) + if err != nil { + return fmt.Errorf("failed to get plugins from store: %w", err) + } + for _, plugin := range existing { + if plugin != nil && !keep[plugin.Name] { + if err := config.ConfigStore.DeletePlugin(ctx, plugin.Name, tx); err != nil { + return fmt.Errorf("failed to delete plugin %s: %w", plugin.Name, err) + } + } + } + for _, plugin := range configData.Plugins { + if plugin == nil { + continue + } + pluginConfigCopy, err := DeepCopy(plugin.Config) + if err != nil { + return fmt.Errorf("failed to deep copy plugin config for %s: %w", plugin.Name, err) + } + if plugin.Version == nil { + plugin.Version = bifrost.Ptr(int16(1)) + } + tablePlugin := &configstoreTables.TablePlugin{ + Name: plugin.Name, + Enabled: plugin.Enabled, + Config: pluginConfigCopy, + Path: plugin.Path, + Version: *plugin.Version, + Placement: plugin.Placement, + Order: plugin.Order, + } + if err := config.ConfigStore.UpsertPlugin(ctx, tablePlugin, tx); err != nil { + return fmt.Errorf("failed to upsert plugin %s: %w", plugin.Name, err) + } + } + return nil + }) + if err != nil { + // Leave config.PluginConfigs untouched so in-memory state stays consistent + // with the DB, which rolled back on failure. + logger.Warn("failed to sync plugins from config file: %v", err) + return + } + // Only adopt the file-declared plugins in memory after the durable commit. + config.PluginConfigs = configData.Plugins +} + // buildMCPPricingDataFromStore builds MCP pricing data from the config store func buildMCPPricingDataFromStore(ctx context.Context, configStore configstore.ConfigStore) mcpcatalog.MCPPricingData { mcpPricingData := mcpcatalog.MCPPricingData{} diff --git a/transports/bifrost-http/lib/config_test.go b/transports/bifrost-http/lib/config_test.go index 0ba5d488303..481332040f4 100644 --- a/transports/bifrost-http/lib/config_test.go +++ b/transports/bifrost-http/lib/config_test.go @@ -636,6 +636,16 @@ func (m *MockConfigStore) GetMCPClientsPaginated(ctx context.Context, params con } func (m *MockConfigStore) DeleteMCPClientConfig(ctx context.Context, id string) error { + if m.mcpConfig == nil { + return nil + } + filtered := make([]*schemas.MCPClientConfig, 0, len(m.mcpConfig.ClientConfigs)) + for _, client := range m.mcpConfig.ClientConfigs { + if client == nil || client.ID != id { + filtered = append(filtered, client) + } + } + m.mcpConfig.ClientConfigs = filtered return nil } @@ -735,7 +745,7 @@ func (m *MockConfigStore) UpdateCustomer(ctx context.Context, customer *tables.T return nil } -func (m *MockConfigStore) DeleteCustomer(ctx context.Context, id string) error { +func (m *MockConfigStore) DeleteCustomer(ctx context.Context, id string, tx ...*gorm.DB) error { return nil } @@ -764,7 +774,7 @@ func (m *MockConfigStore) UpdateTeam(ctx context.Context, team *tables.TableTeam return nil } -func (m *MockConfigStore) DeleteTeam(ctx context.Context, id string) error { +func (m *MockConfigStore) DeleteTeam(ctx context.Context, id string, tx ...*gorm.DB) error { return nil } @@ -957,6 +967,13 @@ func (m *MockConfigStore) UpdatePlugin(ctx context.Context, plugin *tables.Table } func (m *MockConfigStore) DeletePlugin(ctx context.Context, name string, tx ...*gorm.DB) error { + filtered := make([]*tables.TablePlugin, 0, len(m.plugins)) + for _, plugin := range m.plugins { + if plugin == nil || plugin.Name != name { + filtered = append(filtered, plugin) + } + } + m.plugins = filtered return nil } @@ -1098,11 +1115,19 @@ func (m *MockConfigStore) GetModelConfigs(ctx context.Context) ([]tables.TableMo return nil, nil } +func (m *MockConfigStore) GetModelConfigsByScopeAndScopeIDs(ctx context.Context, scope string, scopeIDs []string) ([]tables.TableModelConfig, error) { + return nil, nil +} + +func (m *MockConfigStore) GetProviderGovernanceModelConfigs(ctx context.Context) ([]tables.TableModelConfig, error) { + return nil, nil +} + func (m *MockConfigStore) GetModelConfigsPaginated(ctx context.Context, params configstore.ModelConfigsQueryParams) ([]tables.TableModelConfig, int64, error) { return nil, 0, nil } -func (m *MockConfigStore) GetModelConfig(ctx context.Context, modelName string, provider *string) (*tables.TableModelConfig, error) { +func (m *MockConfigStore) GetModelConfig(ctx context.Context, scope string, scopeID *string, modelName string, provider *string) (*tables.TableModelConfig, error) { return nil, nil } @@ -1122,16 +1147,16 @@ func (m *MockConfigStore) UpdateModelConfigs(ctx context.Context, modelConfigs [ return nil } -func (m *MockConfigStore) DeleteModelConfig(ctx context.Context, id string) error { +func (m *MockConfigStore) DeleteModelConfig(ctx context.Context, id string, tx ...*gorm.DB) error { return nil } // Budget/Rate limit usage -func (m *MockConfigStore) UpdateBudgetUsage(ctx context.Context, id string, currentUsage float64) error { +func (m *MockConfigStore) UpdateBudgetUsage(ctx context.Context, id string, currentUsage float64, tx ...*gorm.DB) error { return nil } -func (m *MockConfigStore) UpdateRateLimitUsage(ctx context.Context, id string, tokenCurrentUsage int64, requestCurrentUsage int64) error { +func (m *MockConfigStore) UpdateRateLimitUsage(ctx context.Context, id string, tokenCurrentUsage int64, requestCurrentUsage int64, tx ...*gorm.DB) error { return nil } @@ -1485,6 +1510,40 @@ func createConfigFile(t *testing.T, dir string, data *ConfigData) { } } +// TestConfigDataSourceOfTruthDefaultsToSplit verifies omitted source_of_truth uses split mode. +func TestConfigDataSourceOfTruthDefaultsToSplit(t *testing.T) { + var configData ConfigData + if err := json.Unmarshal([]byte(`{}`), &configData); err != nil { + t.Fatalf("failed to unmarshal config data: %v", err) + } + require.Equal(t, SourceOfTruthSplit, configData.SourceOfTruth) + require.False(t, configData.isConfigJSONSourceOfTruth()) + require.False(t, configData.sectionPresent("providers")) +} + +// TestConfigDataSourceOfTruthTracksSectionPresence verifies present empty sections are distinguishable from missing sections. +func TestConfigDataSourceOfTruthTracksSectionPresence(t *testing.T) { + var configData ConfigData + if err := json.Unmarshal([]byte(`{"source_of_truth":"config.json","providers":{},"mcp":{},"governance":{"budgets":[]}}`), &configData); err != nil { + t.Fatalf("failed to unmarshal config data: %v", err) + } + require.True(t, configData.isConfigJSONSourceOfTruth()) + require.True(t, configData.sectionPresent("providers")) + require.True(t, configData.sectionPresent("mcp")) + require.True(t, configData.sectionPresent("governance")) + require.True(t, configData.governanceSectionPresent("budgets")) + require.False(t, configData.governanceSectionPresent("teams")) +} + +// TestConfigSchemaSourceOfTruthValidation verifies source_of_truth is schema validated. +func TestConfigSchemaSourceOfTruthValidation(t *testing.T) { + valid := []byte(`{"source_of_truth":"config.json"}`) + require.NoError(t, ValidateConfigSchema(valid)) + + invalid := []byte(`{"source_of_truth":"database"}`) + require.Error(t, ValidateConfigSchema(invalid)) +} + // Test fixtures func makeClientConfig(initialPoolSize int, enableLogging bool) *configstore.ClientConfig { @@ -2096,7 +2155,7 @@ func TestMergeMCPConfig_HashReconciliationUpdatesAndCreates(t *testing.T) { }, } - cfg := &Config{ConfigStore: store} + cfg := &Config{ConfigStore: store, ClientConfig: &configstore.ClientConfig{}} mergeMCPConfig(ctx, cfg, &ConfigData{MCP: fileMCP}, store.mcpConfig) require.Len(t, store.mcpClientConfigUpdates, 1, "expected one updated MCP client") @@ -2120,6 +2179,42 @@ func TestMergeMCPConfig_HashReconciliationUpdatesAndCreates(t *testing.T) { require.NotEmpty(t, byName["filesystem_tools"].ConfigHash) } +// TestSourceOfTruthConfigJSON_MCPMissingLeavesDBUntouched verifies missing mcp does not prune DB MCP clients. +func TestSourceOfTruthConfigJSON_MCPMissingLeavesDBUntouched(t *testing.T) { + initTestLogger() + store := NewMockConfigStore() + store.mcpConfig = &schemas.MCPConfig{ + ClientConfigs: []*schemas.MCPClientConfig{{ID: "client-db", Name: "db-client"}}, + } + cfg := &Config{ConfigStore: store, ClientConfig: &configstore.ClientConfig{}} + + loadMCPConfig(context.Background(), cfg, &ConfigData{SourceOfTruth: SourceOfTruthConfigJSON}) + + require.NotNil(t, cfg.MCPConfig) + require.Len(t, cfg.MCPConfig.ClientConfigs, 1) + require.Equal(t, "db-client", cfg.MCPConfig.ClientConfigs[0].Name) +} + +// TestSourceOfTruthConfigJSON_MCPPresentEmptyPrunesDB verifies present empty mcp removes DB MCP clients. +func TestSourceOfTruthConfigJSON_MCPPresentEmptyPrunesDB(t *testing.T) { + initTestLogger() + store := NewMockConfigStore() + store.mcpConfig = &schemas.MCPConfig{ + ClientConfigs: []*schemas.MCPClientConfig{{ID: "client-db", Name: "db-client"}}, + } + cfg := &Config{ConfigStore: store} + configData := &ConfigData{ + SourceOfTruth: SourceOfTruthConfigJSON, + MCP: &schemas.MCPConfig{ClientConfigs: []*schemas.MCPClientConfig{}}, + } + + loadMCPConfig(context.Background(), cfg, configData) + + require.NotNil(t, cfg.MCPConfig) + require.Empty(t, cfg.MCPConfig.ClientConfigs) + require.Empty(t, store.mcpConfig.ClientConfigs) +} + // TestLoadConfig_Governance_Merge tests governance config merge from DB and file func TestLoadConfig_Governance_Merge(t *testing.T) { // Setup DB governance config @@ -7539,6 +7634,59 @@ func TestSQLite_Provider_DBOnlyProvider_Preserved(t *testing.T) { verifyProviderInDB(t, config2.ConfigStore, schemas.Anthropic, 1) } +// TestSQLite_SourceOfTruthConfigJSON_ProviderAndKeysPruned verifies config.json SOT removes DB-only providers and keys. +func TestSQLite_SourceOfTruthConfigJSON_ProviderAndKeysPruned(t *testing.T) { + initTestLogger() + tempDir := createTempDir(t) + + providers := map[string]configstore.ProviderConfig{ + "openai": makeProviderConfigWithNetwork("openai-key-1", "sk-test-123", "https://api.openai.com"), + } + configData := makeConfigDataWithProvidersAndDir(providers, tempDir) + createConfigFile(t, tempDir, configData) + + ctx := context.Background() + config1, err := LoadConfig(ctx, tempDir) + require.NoError(t, err) + + existingProviders, err := config1.ConfigStore.GetProvidersConfig(ctx) + require.NoError(t, err) + openaiConfig := existingProviders[schemas.OpenAI] + openaiConfig.Keys = append(openaiConfig.Keys, schemas.Key{ + ID: uuid.NewString(), + Name: "dashboard-openai-key", + Value: *schemas.NewEnvVar("sk-dashboard-openai"), + Weight: 1, + }) + existingProviders[schemas.OpenAI] = openaiConfig + existingProviders[schemas.Anthropic] = configstore.ProviderConfig{ + Keys: []schemas.Key{{ + ID: uuid.NewString(), + Name: "anthropic-key-1", + Value: *schemas.NewEnvVar("sk-anthropic-123"), + Weight: 1, + }}, + NetworkConfig: &schemas.NetworkConfig{BaseURL: "https://api.anthropic.com"}, + } + require.NoError(t, config1.ConfigStore.UpdateProvidersConfig(ctx, existingProviders)) + config1.Close(ctx) + + configData.SourceOfTruth = SourceOfTruthConfigJSON + createConfigFile(t, tempDir, configData) + + config2, err := LoadConfig(ctx, tempDir) + require.NoError(t, err) + defer config2.Close(ctx) + + dbProviders, err := config2.ConfigStore.GetProvidersConfig(ctx) + require.NoError(t, err) + require.Len(t, dbProviders, 1) + require.Contains(t, dbProviders, schemas.OpenAI) + require.NotContains(t, dbProviders, schemas.Anthropic) + require.Len(t, dbProviders[schemas.OpenAI].Keys, 1) + require.Equal(t, "openai-key-1", dbProviders[schemas.OpenAI].Keys[0].Name) +} + // TestSQLite_Provider_RoundTrip tests load -> modify via DB -> reload same file -> no changes func TestSQLite_Provider_RoundTrip(t *testing.T) { initTestLogger() @@ -11551,11 +11699,12 @@ func TestSQLite_RateLimit_HashMismatch_FileSync(t *testing.T) { func TestGenerateCustomerHash(t *testing.T) { initTestLogger() - budgetID := "budget-1" customer1 := tables.TableCustomer{ - ID: "customer-1", - Name: "Test Customer", - BudgetID: &budgetID, + ID: "customer-1", + Name: "Test Customer", + Budgets: []tables.TableBudget{ + {ID: "budget-1", MaxLimit: 100, ResetDuration: "1M"}, + }, } hash1, err := configstore.GenerateCustomerHash(customer1) @@ -11588,21 +11737,55 @@ func TestGenerateCustomerHash(t *testing.T) { t.Error("Different Name should produce different hash") } - // Different BudgetID should produce different hash - newBudgetID := "budget-2" + // Different budget ID should produce different hash customer4 := customer1 - customer4.BudgetID = &newBudgetID - hash4, _ := configstore.GenerateCustomerHash(customer4) + customer4.Budgets = []tables.TableBudget{{ID: "budget-2", MaxLimit: 100, ResetDuration: "1M"}} + hash4, err := configstore.GenerateCustomerHash(customer4) + if err != nil { + t.Fatalf("failed to generate customer4 hash: %v", err) + } if hash1 == hash4 { - t.Error("Different BudgetID should produce different hash") + t.Error("Different budget ID should produce different hash") } - // Nil BudgetID should produce different hash + // No budgets should produce different hash customer5 := customer1 - customer5.BudgetID = nil - hash5, _ := configstore.GenerateCustomerHash(customer5) + customer5.Budgets = nil + hash5, err := configstore.GenerateCustomerHash(customer5) + if err != nil { + t.Fatalf("failed to generate customer5 hash: %v", err) + } if hash1 == hash5 { - t.Error("Nil BudgetID should produce different hash") + t.Error("No budgets should produce different hash") + } + + // Multi-budget hash must be order-independent + customerA := tables.TableCustomer{ + ID: "customer-order", + Name: "Order Test", + Budgets: []tables.TableBudget{ + {ID: "budget-x", MaxLimit: 100, ResetDuration: "1M"}, + {ID: "budget-y", MaxLimit: 200, ResetDuration: "1Y"}, + }, + } + customerB := tables.TableCustomer{ + ID: "customer-order", + Name: "Order Test", + Budgets: []tables.TableBudget{ + {ID: "budget-y", MaxLimit: 200, ResetDuration: "1Y"}, + {ID: "budget-x", MaxLimit: 100, ResetDuration: "1M"}, + }, + } + hashA, err := configstore.GenerateCustomerHash(customerA) + if err != nil { + t.Fatalf("failed to generate hashA: %v", err) + } + hashB, err := configstore.GenerateCustomerHash(customerB) + if err != nil { + t.Fatalf("failed to generate hashB: %v", err) + } + if hashA != hashB { + t.Error("multi-budget hash should be order-independent") } t.Log("✓ Customer hash generation works correctly for all fields") @@ -12384,6 +12567,37 @@ func TestMergePluginsFromFile_NoChangeSkipsMerge(t *testing.T) { require.Equal(t, "db-value", configMap["setting"], "config should remain from DB when version and placement are unchanged") } +// TestSourceOfTruthConfigJSON_PluginsMissingLeavesDBUntouched verifies missing plugins does not prune DB plugins. +func TestSourceOfTruthConfigJSON_PluginsMissingLeavesDBUntouched(t *testing.T) { + initTestLogger() + store := NewMockConfigStore() + store.plugins = []*tables.TablePlugin{{Name: "db-plugin", Enabled: true, Config: map[string]any{"setting": "db"}}} + config := &Config{ConfigStore: store} + + loadPlugins(context.Background(), config, &ConfigData{SourceOfTruth: SourceOfTruthConfigJSON}) + + require.Len(t, config.PluginConfigs, 1) + require.Equal(t, "db-plugin", config.PluginConfigs[0].Name) + require.Len(t, store.plugins, 1) +} + +// TestSourceOfTruthConfigJSON_PluginsPresentEmptyPrunesDB verifies present empty plugins removes DB plugins. +func TestSourceOfTruthConfigJSON_PluginsPresentEmptyPrunesDB(t *testing.T) { + initTestLogger() + store := NewMockConfigStore() + store.plugins = []*tables.TablePlugin{{Name: "db-plugin", Enabled: true, Config: map[string]any{"setting": "db"}}} + config := &Config{ConfigStore: store} + configData := &ConfigData{ + SourceOfTruth: SourceOfTruthConfigJSON, + Plugins: []*schemas.PluginConfig{}, + } + + loadPlugins(context.Background(), config, configData) + + require.Empty(t, config.PluginConfigs) + require.Empty(t, store.plugins) +} + // =================================================================================== // CLIENT CONFIG HASH TESTS // =================================================================================== @@ -12672,6 +12886,100 @@ func TestSQLite_Governance_DBOnly_AllPreserved(t *testing.T) { t.Log("✓ All dashboard-added entities preserved on reload") } +// TestSQLite_SourceOfTruthConfigJSON_BulkEntityPruning verifies config.json SOT prunes DB-only rows across sections. +func TestSQLite_SourceOfTruthConfigJSON_BulkEntityPruning(t *testing.T) { + initTestLogger() + tempDir := createTempDir(t) + ctx := context.Background() + + tokenMax := int64(1000) + tokenDur := "1h" + configData := makeConfigDataWithProvidersAndDir(map[string]configstore.ProviderConfig{ + "openai": makeProviderConfigWithNetwork("openai-key-1", "sk-test-123", "https://api.openai.com"), + }, tempDir) + configData.MCP = &schemas.MCPConfig{ + ClientConfigs: []*schemas.MCPClientConfig{{ID: "mcp-file", Name: "file_mcp", ConnectionType: schemas.MCPConnectionTypeHTTP}}, + } + configData.Plugins = []*schemas.PluginConfig{{Name: "file-plugin", Enabled: true, Config: map[string]any{"setting": "file"}}} + configData.Governance = &configstore.GovernanceConfig{ + Budgets: []tables.TableBudget{{ID: "budget-file", MaxLimit: 100.0, ResetDuration: "1d"}}, + RateLimits: []tables.TableRateLimit{{ID: "rl-file", TokenMaxLimit: &tokenMax, TokenResetDuration: &tokenDur}}, + Customers: []tables.TableCustomer{{ID: "customer-file", Name: "File Customer"}}, + Teams: []tables.TableTeam{{ID: "team-file", Name: "File Team"}}, + } + createConfigFile(t, tempDir, configData) + + config1, err := LoadConfig(ctx, tempDir) + require.NoError(t, err) + + dbProviders, err := config1.ConfigStore.GetProvidersConfig(ctx) + require.NoError(t, err) + openaiConfig := dbProviders[schemas.OpenAI] + openaiConfig.Keys = append(openaiConfig.Keys, schemas.Key{ + ID: uuid.NewString(), + Name: "dashboard-openai-key", + Value: *schemas.NewEnvVar("sk-dashboard-openai"), + Weight: 1, + }) + dbProviders[schemas.OpenAI] = openaiConfig + dbProviders[schemas.Anthropic] = configstore.ProviderConfig{ + Keys: []schemas.Key{{ID: uuid.NewString(), Name: "anthropic-key-1", Value: *schemas.NewEnvVar("sk-anthropic"), Weight: 1}}, + } + require.NoError(t, config1.ConfigStore.UpdateProvidersConfig(ctx, dbProviders)) + + require.NoError(t, config1.ConfigStore.CreateMCPClientConfig(ctx, &schemas.MCPClientConfig{ + ID: "mcp-dashboard", + Name: "dashboard_mcp", + ConnectionType: schemas.MCPConnectionTypeHTTP, + })) + require.NoError(t, config1.ConfigStore.UpsertPlugin(ctx, &tables.TablePlugin{ + Name: "dashboard-plugin", + Enabled: true, + Config: map[string]any{"setting": "dashboard"}, + Version: 1, + })) + require.NoError(t, config1.ConfigStore.CreateBudget(ctx, &tables.TableBudget{ID: "budget-dashboard", MaxLimit: 500.0, ResetDuration: "1w"})) + dashboardTokenMax := int64(2000) + require.NoError(t, config1.ConfigStore.CreateRateLimit(ctx, &tables.TableRateLimit{ID: "rl-dashboard", TokenMaxLimit: &dashboardTokenMax, TokenResetDuration: &tokenDur})) + require.NoError(t, config1.ConfigStore.CreateCustomer(ctx, &tables.TableCustomer{ID: "customer-dashboard", Name: "Dashboard Customer"})) + require.NoError(t, config1.ConfigStore.CreateTeam(ctx, &tables.TableTeam{ID: "team-dashboard", Name: "Dashboard Team"})) + config1.Close(ctx) + + configData.SourceOfTruth = SourceOfTruthConfigJSON + createConfigFile(t, tempDir, configData) + + config2, err := LoadConfig(ctx, tempDir) + require.NoError(t, err) + defer config2.Close(ctx) + + dbProviders, err = config2.ConfigStore.GetProvidersConfig(ctx) + require.NoError(t, err) + require.Len(t, dbProviders, 1) + require.Len(t, dbProviders[schemas.OpenAI].Keys, 1) + require.Equal(t, "openai-key-1", dbProviders[schemas.OpenAI].Keys[0].Name) + + mcpConfig, err := config2.ConfigStore.GetMCPConfig(ctx) + require.NoError(t, err) + require.Len(t, mcpConfig.ClientConfigs, 1) + require.Equal(t, "file_mcp", mcpConfig.ClientConfigs[0].Name) + + pluginRows, err := config2.ConfigStore.GetPlugins(ctx) + require.NoError(t, err) + require.Len(t, pluginRows, 1) + require.Equal(t, "file-plugin", pluginRows[0].Name) + + gov, err := config2.ConfigStore.GetGovernanceConfig(ctx) + require.NoError(t, err) + require.Len(t, gov.Budgets, 1) + require.Equal(t, "budget-file", gov.Budgets[0].ID) + require.Len(t, gov.RateLimits, 1) + require.Equal(t, "rl-file", gov.RateLimits[0].ID) + require.Len(t, gov.Customers, 1) + require.Equal(t, "customer-file", gov.Customers[0].ID) + require.Len(t, gov.Teams, 1) + require.Equal(t, "team-file", gov.Teams[0].ID) +} + func TestUpdateGovernanceConfigInStore_RejectsSharedGovernanceIDs(t *testing.T) { initTestLogger() ctx := context.Background() diff --git a/transports/bifrost-http/lib/validator.go b/transports/bifrost-http/lib/validator.go index c1ee50b4dd1..20458e81c80 100644 --- a/transports/bifrost-http/lib/validator.go +++ b/transports/bifrost-http/lib/validator.go @@ -18,6 +18,7 @@ import ( var localSchemaCandidates = []string{ "config.schema.json", // running from transports/ "../config.schema.json", // running from transports/bifrost-http/ + "../../config.schema.json", // running from transports/bifrost-http/lib/ "transports/config.schema.json", // running from repo root } diff --git a/transports/bifrost-http/server/server.go b/transports/bifrost-http/server/server.go index 8a2d2ff9530..56b380cf880 100644 --- a/transports/bifrost-http/server/server.go +++ b/transports/bifrost-http/server/server.go @@ -380,6 +380,17 @@ func (s *BifrostHTTPServer) ReloadVirtualKey(ctx context.Context, id string) (*t if err != nil { return nil, err } + // Fetch VK-scoped model configs up front, alongside the VK load, so that a DB + // failure here aborts before we mutate any in-memory state. Reloading these + // reflects governance changes made via the VK sheet (syncVKGovernanceToModelConfigs) + // in memory immediately — both on the node that handled the update and on peers + // that receive this reload via the cluster gossip broadcast. + mcs, err := s.Config.ConfigStore.GetModelConfigsByScopeAndScopeIDs( + ctx, tables.ModelConfigScopeVirtualKey, []string{id}, + ) + if err != nil { + return virtualKey, fmt.Errorf("failed to reload VK-scoped model configs for VK %s: %w", id, err) + } if governanceData := governancePlugin.GetGovernanceStore().GetGovernanceData(ctx); governanceData != nil { for _, existingVK := range governanceData.VirtualKeys { if existingVK != nil && existingVK.ID == virtualKey.ID && existingVK.Value != "" && existingVK.Value != virtualKey.Value { @@ -388,7 +399,23 @@ func (s *BifrostHTTPServer) ReloadVirtualKey(ctx context.Context, id string) (*t } } } - governancePlugin.GetGovernanceStore().UpdateVirtualKeyInMemory(ctx, virtualKey, nil, nil, nil) + store := governancePlugin.GetGovernanceStore() + store.UpdateVirtualKeyInMemory(ctx, virtualKey, nil, nil, nil) + // Snapshot in-memory VK-scoped config IDs before the upserts so we can evict + // the ones that no longer exist in the DB (e.g. a standalone VK adopted into + // an access profile has its VK-scoped governance model configs deleted). + // Without this their stale budgets keep enforcing. + staleIDs := make(map[string]bool) + for _, mcID := range store.ScopedModelConfigIDs(tables.ModelConfigScopeVirtualKey, id) { + staleIDs[mcID] = true + } + for i := range mcs { + delete(staleIDs, mcs[i].ID) + store.UpdateModelConfigInMemory(ctx, &mcs[i]) + } + for mcID := range staleIDs { + store.DeleteModelConfigInMemory(ctx, mcID) + } s.MCPServerHandler.SyncVKMCPServer(virtualKey) return virtualKey, nil } @@ -507,10 +534,16 @@ func (s *BifrostHTTPServer) ReloadModelConfig(ctx context.Context, id string) (* return preloadedMC, nil } - // Sync updated usage values back to database if they changed - if updatedMC.Budget != nil && preloadedMC.Budget != nil { - if updatedMC.Budget.CurrentUsage != preloadedMC.Budget.CurrentUsage { - if err := s.Config.ConfigStore.UpdateBudgetUsage(ctx, updatedMC.Budget.ID, updatedMC.Budget.CurrentUsage); err != nil { + // Sync updated budget usage values back to database if they changed (per budget ID, + // since a model config may own multiple budgets). + preloadedUsage := make(map[string]float64, len(preloadedMC.Budgets)) + for i := range preloadedMC.Budgets { + preloadedUsage[preloadedMC.Budgets[i].ID] = preloadedMC.Budgets[i].CurrentUsage + } + for i := range updatedMC.Budgets { + b := &updatedMC.Budgets[i] + if old, ok := preloadedUsage[b.ID]; ok && old != b.CurrentUsage { + if err := s.Config.ConfigStore.UpdateBudgetUsage(ctx, b.ID, b.CurrentUsage); err != nil { logger.Error("failed to sync budget usage to database: %v", err) } } diff --git a/transports/changelog.md b/transports/changelog.md index 4265d637f96..9a5b6775f5b 100644 --- a/transports/changelog.md +++ b/transports/changelog.md @@ -9,4 +9,4 @@ - **Text Completion Chunk Model** — Added the missing `Model` field to `TextCompletionChunkResponse` (#3970) (thanks [@kuishou68](https://github.com/kuishou68)!) - **Orphaned Tool Results** — Orphaned tool results in the OpenAI to Anthropic conversion flow are no longer rejected by the Anthropic API (#3919) - **MCP Inline stdio Env** — MCP stdio server configs now accept inline environment variable assignments (#3861) (thanks [@Shushmitaaaa](https://github.com/Shushmitaaaa)!) -- **Model Pool Pricing Reloads** — Non-pricing model pool entries are preserved across pricing reloads instead of being dropped (#3999) +- **Model Pool Pricing Reloads** — Non-pricing model pool entries are preserved across pricing reloads instead of being dropped (#3999) \ No newline at end of file diff --git a/transports/config.schema.json b/transports/config.schema.json index 914caa5d24f..08b9b20727e 100644 --- a/transports/config.schema.json +++ b/transports/config.schema.json @@ -16,6 +16,12 @@ "enum": [1, 2], "default": 2 }, + "source_of_truth": { + "type": "string", + "description": "Controls how config.json is reconciled with the config store. \"split\" preserves existing merge behavior. \"config.json\" makes present config.json sections authoritative during startup sync; missing sections leave database rows untouched.", + "enum": ["split", "config.json"], + "default": "split" + }, "encryption_key": { "type": "string", "description": "You can set the value as env. to use an environment variable. We also read encryption key from BIFROST_ENCRYPTION_KEY environment variable. Note: once set, the encryption key cannot be changed unless you clean up the database. Accepts any string; a secure 32-byte AES-256 key will be derived using Argon2id KDF. If not provided, data will be saved in plain text. Recommended: use a passphrase of at least 16 bytes for better security" @@ -463,16 +469,31 @@ "type": "string", "description": "Customer name" }, + "budgets": { + "type": "array", + "description": "Inline budget configurations for this customer. Each entry must have a unique reset_duration. Mutually exclusive with budget_id.", + "items": { + "$ref": "#/$defs/budget_line" + } + }, "budget_id": { "type": "string", - "description": "Associated budget ID" + "description": "Single budget reference (pre-declared in governance.budgets). Deprecated in favour of inline budgets." }, "rate_limit_id": { "type": "string", "description": "Associated rate limit ID" + }, + "calendar_aligned": { + "type": "boolean", + "description": "Snap the customer's budget and rate-limit reset windows to clean calendar boundaries (day, week, month, year)", + "default": false } }, "required": ["id", "name"], + "not": { + "required": ["budgets", "budget_id"] + }, "additionalProperties": false } }, @@ -584,6 +605,54 @@ "additionalProperties": false } }, + "roles": { + "type": "array", + "description": "RBAC role definitions. Roles are created or updated on startup if the config hash changes; system roles and dashboard-created roles are never deleted.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Unique role name" + }, + "description": { + "type": "string", + "description": "Human-readable description of the role" + }, + "dac": { + "type": "string", + "description": "Data Access Control scope: own-data (user's own records), team-data (user's team records), all-data (unrestricted). Defaults to all-data.", + "enum": ["own-data", "team-data", "all-data"], + "default": "all-data" + }, + "access_profile": { + "type": "string", + "description": "Name of the access profile (defined in access_profiles) to attach as the default for this role. Changing this field triggers a re-sync on next startup. Leave unset to let the dashboard manage the assignment." + }, + "permissions": { + "type": "array", + "description": "List of resource+operation permission pairs to grant to this role", + "items": { + "type": "object", + "properties": { + "resource": { + "type": "string", + "description": "Permission resource (e.g. Logs, VirtualKeys, Users, ModelProvider, Metrics, Guardrails, AccessProfiles, MCPToolGroups, Roles, BusinessUnits, AuditLogs, Billing)" + }, + "operation": { + "type": "string", + "description": "Permission operation (e.g. View, Create, Update, Delete, Download, RunInference, ManageInference, ViewInference)" + } + }, + "required": ["resource", "operation"], + "additionalProperties": false + } + } + }, + "required": ["name"], + "additionalProperties": false + } + }, "virtual_keys": { "type": "array", "description": "Virtual key configurations", @@ -687,9 +756,23 @@ "type": "string", "description": "Optional provider name to scope this config" }, + "scope": { + "type": "string", + "description": "Scope where this config applies: \"global\" (default) or \"virtual_key\"", + "default": "global" + }, + "scope_id": { + "type": "string", + "description": "Target entity ID for non-global scopes (e.g. virtual key ID). Required when scope != \"global\"" + }, "budget_id": { "type": "string", - "description": "Budget ID to associate with this model" + "description": "Deprecated — single budget reference. Use budget_ids instead." + }, + "budget_ids": { + "type": "array", + "description": "List of budget IDs (from governance.budgets) to associate with this model config. Supports multiple budgets (e.g. daily + monthly). Replaces budget_id.", + "items": { "type": "string" } }, "rate_limit_id": { "type": "string", @@ -1395,6 +1478,13 @@ "log_repo_id": { "type": "string", "description": "Optional default ID for the Maxim logger instance" + }, + "request_headers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Request header name patterns (exact or wildcard like x-custom-*) to capture and attach as trace tags. Note: * captures all headers including sensitive ones like Authorization." } }, "required": ["api_key"], @@ -1547,94 +1637,11 @@ "required": ["config"], "properties": { "config": { - "type": "object", - "description": "Configuration for the OpenTelemetry plugin", - "properties": { - "service_name": { - "type": "string", - "description": "Service name to be used for tracing", - "default": "bifrost" - }, - "collector_url": { - "type": "string", - "description": "URL of the OpenTelemetry collector", - "anyOf": [ - { - "format": "uri" - }, - { - "pattern": "^[^:\\s]+:\\d+$" - } - ] - }, - "trace_type": { - "type": "string", - "description": "Type of trace to use for the OTEL collector", - "enum": ["genai_extension", "vercel", "open_inference"] - }, - "protocol": { - "type": "string", - "description": "Protocol to use for the OTEL collector", - "enum": ["http", "grpc"] - }, - "metrics_enabled": { - "type": "boolean", - "description": "Enable push-based metrics export via OTLP. Recommended for multi-node cluster deployments.", - "default": false - }, - "metrics_endpoint": { - "type": "string", - "description": "OTLP metrics endpoint URL (e.g., http://otel-collector:4318/v1/metrics for HTTP or otel-collector:4317 for gRPC)", - "anyOf": [ - { - "format": "uri" - }, - { - "pattern": "^[^:\\s]+:\\d+$" - } - ] - }, - "metrics_push_interval": { - "type": "integer", - "description": "Metrics push interval in seconds", - "default": 15, - "minimum": 1, - "maximum": 300 - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "description": "Custom headers for the collector. Supports env.VAR_NAME prefix for environment variable substitution." - }, - "tls_ca_cert": { - "type": "string", - "description": "Path to TLS CA certificate file" - }, - "insecure": { - "type": "boolean", - "description": "Skip TLS verification (ignored if tls_ca_cert is set)" - }, - "plugin_span_filter": { - "type": "object", - "description": "Controls which plugin hook spans are exported to the OTEL collector. Omit to export all plugin spans.", - "properties": { - "mode": { - "type": "string", - "enum": ["include", "exclude"] - }, - "plugins": { - "type": "array", - "items": { "type": "string" } - } - }, - "required": ["mode", "plugins"], - "additionalProperties": false - } - }, - "required": ["collector_url", "trace_type", "protocol"], - "additionalProperties": false + "anyOf": [ + { "$ref": "#/$defs/otel_profile_config" }, + { "$ref": "#/$defs/otel_profiles_config" } + ], + "description": "Configuration for the OpenTelemetry plugin. Supports the legacy single-profile shape or the profiles wrapper for multiple collectors." } } } @@ -1724,6 +1731,160 @@ }, "additionalProperties": false, "$defs": { + "otel_endpoint": { + "type": "string", + "anyOf": [ + { + "format": "uri" + }, + { + "pattern": "^[^:\\s]+:\\d+$" + }, + { + "pattern": "^env\\.[A-Za-z_][A-Za-z0-9_]*$" + } + ] + }, + "otel_plugin_span_filter": { + "type": "object", + "description": "Controls which plugin hook spans are exported to the OTEL collector. Omit to export all plugin spans.", + "properties": { + "mode": { + "type": "string", + "enum": ["include", "exclude"] + }, + "plugins": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["mode", "plugins"], + "additionalProperties": false + }, + "otel_profile_config": { + "type": "object", + "description": "OpenTelemetry export profile. This legacy single-profile shape is still accepted directly as the plugin config.", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether this profile exports traces and metrics", + "default": true + }, + "service_name": { + "type": "string", + "description": "Service name to be used for tracing", + "default": "bifrost" + }, + "collector_url": { + "$ref": "#/$defs/otel_endpoint", + "description": "URL of the OpenTelemetry collector" + }, + "trace_type": { + "type": "string", + "description": "Type of trace to use for the OTEL collector", + "enum": ["genai_extension", "vercel", "open_inference"] + }, + "protocol": { + "type": "string", + "description": "Protocol to use for the OTEL collector", + "enum": ["http", "grpc"] + }, + "metrics_enabled": { + "type": "boolean", + "description": "Enable push-based metrics export via OTLP. Recommended for multi-node cluster deployments.", + "default": false + }, + "metrics_endpoint": { + "$ref": "#/$defs/otel_endpoint", + "description": "OTLP metrics endpoint URL (e.g., http://otel-collector:4318/v1/metrics for HTTP or otel-collector:4317 for gRPC)" + }, + "metrics_push_interval": { + "type": "integer", + "description": "Metrics push interval in seconds", + "default": 15, + "minimum": 1, + "maximum": 300 + }, + "request_headers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Request header name patterns (exact or wildcard like x-custom-*) to capture and emit as span attributes. Note: * captures all headers including sensitive ones like Authorization." + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Custom headers for the collector. Supports env.VAR_NAME prefix for environment variable substitution." + }, + "tls_ca_cert": { + "type": "string", + "description": "Path to TLS CA certificate file" + }, + "insecure": { + "type": "boolean", + "description": "Skip TLS verification (ignored if tls_ca_cert is set)", + "default": true + }, + "plugin_span_filter": { + "$ref": "#/$defs/otel_plugin_span_filter" + } + }, + "allOf": [ + { + "if": { + "not": { + "properties": { + "enabled": { + "const": false + } + }, + "required": ["enabled"] + } + }, + "then": { + "required": ["collector_url", "trace_type", "protocol"] + } + }, + { + "if": { + "properties": { + "metrics_enabled": { + "const": true + } + }, + "required": ["metrics_enabled"] + }, + "then": { + "required": ["metrics_endpoint"] + } + } + ], + "additionalProperties": false + }, + "otel_profiles_config": { + "type": "object", + "description": "OpenTelemetry plugin configuration with one or more export profiles.", + "properties": { + "profiles": { + "type": "array", + "description": "OpenTelemetry export profiles", + "items": { + "$ref": "#/$defs/otel_profile_config" + }, + "minItems": 1 + }, + "plugin_span_filter": { + "$ref": "#/$defs/otel_plugin_span_filter" + } + }, + "required": ["profiles"], + "additionalProperties": false + }, "feature_flags_config": { "type": "object", "description": "Boot-time overrides for feature flags. Flags themselves are declared in code via featureflags.Register; this block only sets initial values. Anything set here is rendered as locked in the UI - operators must edit the config (or Helm values) to change it.", @@ -2082,6 +2243,11 @@ "type": "boolean" }, "description": "Override default Anthropic beta header support per provider. Keys are header prefixes (e.g. 'redact-thinking-'), values are true (supported) or false (unsupported). Headers not listed use the built-in defaults." + }, + "allow_private_network": { + "type": "boolean", + "description": "Allow connections to RFC 1918 private IPs (10.x, 172.16.x, 192.168.x). Enable for providers on a k8s pod network, LAN, or private VPC. Loopback addresses (localhost, 127.0.0.1, ::1) remain allowed regardless of this setting. Link-local addresses (169.254.x.x) are always blocked.", + "default": false } }, "additionalProperties": false @@ -2146,6 +2312,11 @@ "type": "boolean" }, "description": "Override default Anthropic beta header support per provider. Keys are header prefixes (e.g. 'redact-thinking-'), values are true (supported) or false (unsupported). Headers not listed use the built-in defaults." + }, + "allow_private_network": { + "type": "boolean", + "description": "Allow connections to RFC 1918 private IPs (10.x, 172.16.x, 192.168.x). Enable for providers on a k8s pod network, LAN, or private VPC. Loopback addresses (localhost, 127.0.0.1, ::1) remain allowed regardless of this setting. Link-local addresses (169.254.x.x) are always blocked.", + "default": false } }, "additionalProperties": false @@ -2832,7 +3003,13 @@ }, "auth_type": { "type": "string", - "enum": ["none", "headers", "oauth", "per_user_oauth", "per_user_headers"], + "enum": [ + "none", + "headers", + "oauth", + "per_user_oauth", + "per_user_headers" + ], "description": "Authentication type for MCP connection" }, "oauth_config_id": { @@ -3676,9 +3853,18 @@ "items": { "type": "object", "properties": { - "attribute": { "type": "string", "description": "JWT claim name (supports dot paths, e.g. 'realm_access.roles')" }, - "value": { "type": "string", "description": "Claim value to match (case-insensitive)" }, - "role": { "type": "string", "description": "Bifrost role to assign on match" } + "attribute": { + "type": "string", + "description": "JWT claim name (supports dot paths, e.g. 'realm_access.roles')" + }, + "value": { + "type": "string", + "description": "Claim value to match (case-insensitive)" + }, + "role": { + "type": "string", + "description": "Bifrost role to assign on match" + } }, "required": ["attribute", "value", "role"], "additionalProperties": false @@ -3686,13 +3872,31 @@ }, "attributeTeamMappings": { "type": "array", - "description": "Attribute -> team mappings (all matches apply). Use value '*' for pass-through.", + "description": "Attribute -> team mappings (all matches apply). Use value '*' for pass-through. Add attributeType/attributeValue to enable SCIM provisioning for a mapping.", "items": { "type": "object", "properties": { - "attribute": { "type": "string" }, - "value": { "type": "string" }, - "team": { "type": "string" } + "attribute": { + "type": "string", + "description": "JWT claim name" + }, + "value": { + "type": "string", + "description": "Claim value to match, or '*' for pass-through" + }, + "team": { + "type": "string", + "description": "Bifrost team slug to assign" + }, + "attributeType": { + "type": "string", + "enum": ["user", "group"], + "description": "SCIM provisioning type: 'user' matches SCIM User attributes, 'group' matches SCIM Group displayName" + }, + "attributeValue": { + "type": "string", + "description": "SCIM attribute value to match (for attributeType 'user': the SCIM user attribute value; for 'group': the SCIM group displayName, auto-set to 'displayName')" + } }, "required": ["attribute", "value", "team"], "additionalProperties": false @@ -3700,13 +3904,31 @@ }, "attributeBusinessUnitMappings": { "type": "array", - "description": "Attribute -> business-unit mappings (all matches apply).", + "description": "Attribute -> business-unit mappings (all matches apply). Add attributeType/attributeValue to enable SCIM provisioning for a mapping.", "items": { "type": "object", "properties": { - "attribute": { "type": "string" }, - "value": { "type": "string" }, - "business_unit": { "type": "string" } + "attribute": { + "type": "string", + "description": "JWT claim name" + }, + "value": { + "type": "string", + "description": "Claim value to match" + }, + "business_unit": { + "type": "string", + "description": "Bifrost business unit slug to assign" + }, + "attributeType": { + "type": "string", + "enum": ["user", "group"], + "description": "SCIM provisioning type: 'user' matches SCIM User attributes, 'group' matches SCIM Group displayName" + }, + "attributeValue": { + "type": "string", + "description": "SCIM attribute value to match (for attributeType 'user': the SCIM user attribute value; for 'group': the SCIM group displayName, auto-set to 'displayName')" + } }, "required": ["attribute", "value", "business_unit"], "additionalProperties": false diff --git a/transports/go.mod b/transports/go.mod index 9de54b917b9..1cabe89d9c0 100644 --- a/transports/go.mod +++ b/transports/go.mod @@ -3,14 +3,14 @@ module github.com/maximhq/bifrost/transports go 1.26.3 require ( - github.com/andybalholm/brotli v1.2.0 + github.com/andybalholm/brotli v1.2.1 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 - github.com/bytedance/sonic v1.15.0 + github.com/bytedance/sonic v1.15.1 github.com/fasthttp/router v1.5.4 github.com/fasthttp/websocket v1.5.12 github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f github.com/google/uuid v1.6.0 - github.com/klauspost/compress v1.18.2 + github.com/klauspost/compress v1.18.6 github.com/mark3labs/mcp-go v0.43.2 github.com/maximhq/bifrost/core v1.5.15 github.com/maximhq/bifrost/framework v1.3.15 @@ -29,7 +29,7 @@ require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 - github.com/valyala/fasthttp v1.68.0 + github.com/valyala/fasthttp v1.71.0 go.uber.org/automaxprocs v1.6.0 golang.org/x/sync v0.20.0 gorm.io/driver/sqlite v1.6.0 @@ -39,7 +39,7 @@ require ( require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect @@ -52,7 +52,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect - github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.11 // indirect @@ -76,16 +76,16 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/bytedance/gopkg v0.1.3 // indirect - github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dlclark/regexp2 v1.11.4 // indirect - github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect - github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -111,12 +111,12 @@ require ( github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-openapi/validate v0.25.1 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect - github.com/google/cel-go v0.26.1 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/cel-go v0.28.1 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect - github.com/googleapis/gax-go/v2 v2.19.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -156,15 +156,14 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.17.0 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.19.2 // indirect github.com/qdrant/go-client v1.16.2 // indirect github.com/redis/go-redis/v9 v9.17.2 // indirect github.com/rs/zerolog v1.34.0 // indirect github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect - github.com/stoewer/go-strcase v1.3.1 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/tidwall/sjson v1.2.5 // indirect @@ -177,9 +176,9 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.mongodb.org/mongo-driver v1.17.6 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.40.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect @@ -189,22 +188,22 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.starlark.net v0.0.0-20260102030733-3fee463870c9 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.23.0 // indirect golang.org/x/crypto v0.52.0 // indirect - golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect + golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/api v0.274.0 // indirect - google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 // indirect + google.golang.org/api v0.282.0 // indirect + google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/grpc v1.80.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/driver/postgres v1.6.0 // indirect ) diff --git a/transports/go.sum b/transports/go.sum index d0f2235a2cc..b69f5453b63 100644 --- a/transports/go.sum +++ b/transports/go.sum @@ -2,8 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= 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/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= @@ -41,10 +41,10 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= -github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= @@ -98,18 +98,18 @@ github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJ github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= -github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= -github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= -github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= -github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= +github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= 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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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= @@ -121,12 +121,12 @@ github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yA github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= -github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= -github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= -github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/fasthttp/router v1.5.4 h1:oxdThbBwQgsDIYZ3wR1IavsNl6ZS9WdjKukeMikOnC8= github.com/fasthttp/router v1.5.4/go.mod h1:3/hysWq6cky7dTfzaaEPZGdptwjwx0qzTgFCKEWRjgc= github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE= @@ -190,15 +190,15 @@ github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6 github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-openapi/validate v0.25.1 h1:sSACUI6Jcnbo5IWqbYHgjibrhhmt3vR6lCzKZnmAgBw= github.com/go-openapi/validate v0.25.1/go.mod h1:RMVyVFYte0gbSTaZ0N4KmTn6u/kClvAFp+mAVfS/DQc= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= -github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/cel-go v0.28.1 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM= +github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= 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/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= @@ -209,10 +209,10 @@ 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.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= -github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= -github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= +github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw= +github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= @@ -236,8 +236,8 @@ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/ github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -340,10 +340,10 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= -github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/qdrant/go-client v1.16.2 h1:UUMJJfvXTByhwhH1DwWdbkhZ2cTdvSqVkXSIfBrVWSg= github.com/qdrant/go-client v1.16.2/go.mod h1:I+EL3h4HRoRTeHtbfOd/4kDXwCukZfkd41j/9wryGkw= github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= @@ -362,8 +362,6 @@ github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qq github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= -github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= -github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= 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= @@ -374,7 +372,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -392,8 +389,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok= -github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= github.com/weaviate/weaviate v1.36.5 h1:lCiuEfQ08+5wK0DkTCUBb6ayNep9QpBH6JJhmZaRfzk= github.com/weaviate/weaviate v1.36.5/go.mod h1:ljzrgEmGKn3CRzDdcxvhmBUUZIcghwIYd1Lmn54f3Z8= github.com/weaviate/weaviate-go-client/v5 v5.7.1 h1:vEMxh486QqRqWaq58UEe/TiTbGbo9T5x7ZPFd5QENvQ= @@ -410,12 +407,12 @@ go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUps go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= 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/contrib/detectors/gcp v1.40.0 h1:Awaf8gmW99tZTOWqkLCOl6aw1/rxAWVlHsHIZ3fT2sA= -go.opentelemetry.io/contrib/detectors/gcp v1.40.0/go.mod h1:99OY9ZCqyLkzJLTh5XhECpLRSxcZl+ZDKBEO+jMBFR4= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc= @@ -440,16 +437,16 @@ go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= -golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= @@ -468,18 +465,18 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA= -google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= -google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE= -google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw= +google.golang.org/api v0.282.0 h1:WmJiSVqUnKqJCpJOx7YADbXaC+9DDsnGSfllFSj7R2I= +google.golang.org/api v0.282.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/ui/app/_fallbacks/enterprise/lib/registrations/modelLimitScopes.ts b/ui/app/_fallbacks/enterprise/lib/registrations/modelLimitScopes.ts new file mode 100644 index 00000000000..2a4f62a6897 --- /dev/null +++ b/ui/app/_fallbacks/enterprise/lib/registrations/modelLimitScopes.ts @@ -0,0 +1,7 @@ +// OSS-build fallback for the enterprise scope registrations. +// +// Side-effect imports of this module from OSS code (e.g. from the Model +// Limits page) compile to a no-op when the @enterprise alias resolves to +// _fallbacks/. The enterprise build replaces this module with one that +// registers the "user" scope (and its picker + deep-link). +export {}; diff --git a/ui/app/workspace/config/views/loggingView.tsx b/ui/app/workspace/config/views/loggingView.tsx index e9a9af94b82..32c308b77cb 100644 --- a/ui/app/workspace/config/views/loggingView.tsx +++ b/ui/app/workspace/config/views/loggingView.tsx @@ -234,15 +234,16 @@ export default function LoggingView() { Logging Headers

- Comma-separated list of request headers to capture in log metadata. Values are extracted from incoming requests and stored in - the metadata field of log entries. Headers with the x-bf-lh- prefix are always captured - automatically. + Comma-separated list of request headers to capture in log metadata. Supports exact names and wildcard patterns (e.g.{" "} + x-custom-* captures all headers with that prefix, * logs + all headers — note that * will capture sensitive headers like Authorization). Values are extracted from incoming requests and stored in the metadata field of log entries. Headers with the{" "} + x-bf-lh- prefix are always captured automatically.