mirror of
https://github.com/katanemo/plano.git
synced 2026-06-17 15:25:17 +02:00
Merge main into salmanap/signals-clean
This commit is contained in:
commit
1a50c2127b
134 changed files with 5565 additions and 8733 deletions
6
.github/workflows/docker-push-main.yml
vendored
6
.github/workflows/docker-push-main.yml
vendored
|
|
@ -9,7 +9,7 @@ on:
|
|||
- main
|
||||
|
||||
jobs:
|
||||
# Build ARM64 image on native ARM64 runner
|
||||
# Build ARM64 image on native ARM64 runner.
|
||||
build-arm64:
|
||||
runs-on: [linux-arm64]
|
||||
steps:
|
||||
|
|
@ -34,7 +34,7 @@ jobs:
|
|||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./arch/Dockerfile
|
||||
file: ./Dockerfile
|
||||
platforms: linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}-arm64
|
||||
|
|
@ -64,7 +64,7 @@ jobs:
|
|||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./arch/Dockerfile
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}-amd64
|
||||
|
|
|
|||
4
.github/workflows/docker-push-release.yml
vendored
4
.github/workflows/docker-push-release.yml
vendored
|
|
@ -33,7 +33,7 @@ jobs:
|
|||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./arch/Dockerfile
|
||||
file: ./Dockerfile
|
||||
platforms: linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}-arm64
|
||||
|
|
@ -63,7 +63,7 @@ jobs:
|
|||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./arch/Dockerfile
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}-amd64
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ jobs:
|
|||
|
||||
- name: build arch docker image
|
||||
run: |
|
||||
cd ../../ && docker build -f arch/Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.0 -t katanemo/plano:latest
|
||||
cd ../../ && docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.0 -t katanemo/plano:latest
|
||||
|
||||
- name: start plano
|
||||
env:
|
||||
|
|
@ -48,19 +48,16 @@ jobs:
|
|||
run: |
|
||||
source common.sh && wait_for_healthz http://localhost:10000/healthz
|
||||
|
||||
- name: install poetry
|
||||
run: |
|
||||
export POETRY_VERSION=2.2.1
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
- name: install uv
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
- name: install test dependencies
|
||||
run: |
|
||||
poetry install
|
||||
uv sync
|
||||
|
||||
- name: run plano tests
|
||||
run: |
|
||||
poetry run pytest || tail -100 plano.logs
|
||||
uv run pytest || tail -100 plano.logs
|
||||
|
||||
- name: stop plano docker container
|
||||
env:
|
||||
12
.github/workflows/e2e_test_currency_convert.yml
vendored
12
.github/workflows/e2e_test_currency_convert.yml
vendored
|
|
@ -24,12 +24,10 @@ jobs:
|
|||
|
||||
- name: build plano docker image
|
||||
run: |
|
||||
docker build -f arch/Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.0
|
||||
docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.0
|
||||
|
||||
- name: install poetry
|
||||
run: |
|
||||
export POETRY_VERSION=2.2.1
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
- name: install uv
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
- name: setup python venv
|
||||
run: |
|
||||
|
|
@ -43,8 +41,8 @@ jobs:
|
|||
- name: install arch gateway and test dependencies
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
cd arch/tools && echo "installing plano cli" && poetry install
|
||||
cd ../../demos/shared/test_runner && echo "installing test dependencies" && poetry install
|
||||
cd cli && echo "installing plano cli" && uv sync && uv tool install .
|
||||
cd ../demos/shared/test_runner && echo "installing test dependencies" && uv sync
|
||||
|
||||
- name: run demo tests
|
||||
env:
|
||||
|
|
|
|||
|
|
@ -24,12 +24,10 @@ jobs:
|
|||
|
||||
- name: build arch docker image
|
||||
run: |
|
||||
docker build -f arch/Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.0
|
||||
docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.0
|
||||
|
||||
- name: install poetry
|
||||
run: |
|
||||
export POETRY_VERSION=2.2.1
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
- name: install uv
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
- name: setup python venv
|
||||
run: |
|
||||
|
|
@ -43,8 +41,8 @@ jobs:
|
|||
- name: install arch gateway and test dependencies
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
cd arch/tools && echo "installing plano cli" && poetry install
|
||||
cd ../../demos/shared/test_runner && echo "installing test dependencies" && poetry install
|
||||
cd cli && echo "installing plano cli" && uv sync && uv tool install .
|
||||
cd ../demos/shared/test_runner && echo "installing test dependencies" && uv sync
|
||||
|
||||
- name: run demo tests
|
||||
env:
|
||||
|
|
|
|||
7
.github/workflows/e2e_tests.yml
vendored
7
.github/workflows/e2e_tests.yml
vendored
|
|
@ -45,11 +45,8 @@ jobs:
|
|||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Poetry
|
||||
run: |
|
||||
export POETRY_VERSION=2.2.1
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
- name: Install uv
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
- name: Run e2e tests
|
||||
env:
|
||||
|
|
|
|||
4
.github/workflows/ghrc-push-main.yml
vendored
4
.github/workflows/ghrc-push-main.yml
vendored
|
|
@ -30,7 +30,7 @@ jobs:
|
|||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./arch/Dockerfile
|
||||
file: ./Dockerfile
|
||||
platforms: linux/arm64
|
||||
push: true
|
||||
# produce ghcr.io/<owner>/plano:latest-arm64
|
||||
|
|
@ -58,7 +58,7 @@ jobs:
|
|||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./arch/Dockerfile
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}-amd64
|
||||
|
|
|
|||
4
.github/workflows/ghrc-push-release.yml
vendored
4
.github/workflows/ghrc-push-release.yml
vendored
|
|
@ -30,7 +30,7 @@ jobs:
|
|||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./arch/Dockerfile
|
||||
file: ./Dockerfile
|
||||
platforms: linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}-arm64
|
||||
|
|
@ -57,7 +57,7 @@ jobs:
|
|||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./arch/Dockerfile
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}-amd64
|
||||
|
|
|
|||
13
.github/workflows/plano_tools_tests.yml
vendored
13
.github/workflows/plano_tools_tests.yml
vendored
|
|
@ -14,7 +14,7 @@ jobs:
|
|||
runs-on: ubuntu-latest-m
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./arch/tools
|
||||
working-directory: ./cli
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
|
@ -25,16 +25,13 @@ jobs:
|
|||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: install poetry
|
||||
run: |
|
||||
export POETRY_VERSION=2.2.1
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
- name: install uv
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
- name: install plano tools
|
||||
run: |
|
||||
poetry install
|
||||
uv sync --extra dev
|
||||
|
||||
- name: run tests
|
||||
run: |
|
||||
poetry run pytest
|
||||
uv run pytest
|
||||
|
|
|
|||
38
.github/workflows/publish-pypi.yml
vendored
Normal file
38
.github/workflows/publish-pypi.yml
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
name: Publish planoai to PyPI
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
publish-pypi:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./cli
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Build package
|
||||
run: uv build
|
||||
|
||||
- name: Publish to PyPI
|
||||
env:
|
||||
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
|
||||
run: uv publish
|
||||
2
.github/workflows/rust_tests.yml
vendored
2
.github/workflows/rust_tests.yml
vendored
|
|
@ -18,7 +18,7 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup | Rust
|
||||
run: rustup toolchain install 1.82 --profile minimal
|
||||
run: rustup toolchain install 1.92 --profile minimal
|
||||
|
||||
- name: Setup | Install wasm toolchain
|
||||
run: rustup target add wasm32-wasip1
|
||||
|
|
|
|||
4
.github/workflows/validate_arch_config.yml
vendored
4
.github/workflows/validate_arch_config.yml
vendored
|
|
@ -24,8 +24,8 @@ jobs:
|
|||
|
||||
- name: build arch docker image
|
||||
run: |
|
||||
docker build -f arch/Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.0
|
||||
docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.0
|
||||
|
||||
- name: validate arch config
|
||||
run: |
|
||||
bash arch/validate_plano_config.sh
|
||||
bash config/validate_plano_config.sh
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -108,8 +108,8 @@ venv.bak/
|
|||
# =========================================
|
||||
|
||||
# Arch
|
||||
arch/tools/config
|
||||
arch/tools/build
|
||||
cli/config
|
||||
cli/build
|
||||
|
||||
# Archgw - Docs
|
||||
docs/build/
|
||||
|
|
@ -145,3 +145,5 @@ apps/*/out/
|
|||
apps/*/dist/
|
||||
./node_modules
|
||||
.vercel
|
||||
|
||||
*.logs
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ repos:
|
|||
rev: v4.6.0
|
||||
hooks:
|
||||
- id: check-yaml
|
||||
exclude: arch/envoy.template*
|
||||
exclude: config/envoy.template*
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
- repo: local
|
||||
|
|
@ -13,19 +13,22 @@ repos:
|
|||
name: cargo-fmt
|
||||
language: system
|
||||
types: [file, rust]
|
||||
entry: bash -c "cd crates/llm_gateway && cargo fmt"
|
||||
entry: bash -c "cd crates && cargo fmt --all -- --check"
|
||||
pass_filenames: false
|
||||
|
||||
- id: cargo-clippy
|
||||
name: cargo-clippy
|
||||
language: system
|
||||
types: [file, rust]
|
||||
entry: bash -c "cd crates/llm_gateway && cargo clippy --all"
|
||||
entry: bash -c "cd crates && cargo clippy --locked --offline --all-targets --all-features -- -D warnings || cargo clippy --locked --all-targets --all-features -- -D warnings"
|
||||
pass_filenames: false
|
||||
|
||||
- id: cargo-test
|
||||
name: cargo-test
|
||||
language: system
|
||||
types: [file, rust]
|
||||
entry: bash -c "cd crates && cargo test --lib"
|
||||
pass_filenames: false
|
||||
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 23.1.0
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
# build docker image for arch gateway
|
||||
FROM rust:1.82.0 AS builder
|
||||
FROM rust:1.92.0 AS builder
|
||||
RUN rustup -v target add wasm32-wasip1
|
||||
WORKDIR /arch
|
||||
COPY crates .
|
||||
RUN cargo build --release --target wasm32-wasip1 -p prompt_gateway -p llm_gateway
|
||||
RUN cargo build --release -p brightstaff
|
||||
|
||||
FROM docker.io/envoyproxy/envoy:v1.34-latest AS envoy
|
||||
FROM docker.io/envoyproxy/envoy:v1.36.4 AS envoy
|
||||
|
||||
FROM python:3.13.6-slim AS arch
|
||||
# Purge PAM to avoid CVE-2025-6020 and install needed tools
|
||||
|
|
@ -30,14 +30,22 @@ COPY --from=builder /arch/target/release/brightstaff /app/brightstaff
|
|||
COPY --from=envoy /usr/local/bin/envoy /usr/local/bin/envoy
|
||||
|
||||
WORKDIR /app
|
||||
COPY arch/requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
COPY arch/tools .
|
||||
COPY arch/envoy.template.yaml .
|
||||
COPY arch/arch_config_schema.yaml .
|
||||
COPY arch/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
|
||||
RUN pip install requests
|
||||
# Install uv using pip
|
||||
RUN pip install --no-cache-dir uv
|
||||
|
||||
# Copy Python dependency files
|
||||
COPY cli/pyproject.toml ./
|
||||
COPY cli/uv.lock ./
|
||||
COPY cli/README.md ./
|
||||
|
||||
RUN uv run pip install --no-cache-dir .
|
||||
|
||||
# Copy the rest of the application
|
||||
COPY cli .
|
||||
COPY config/envoy.template.yaml .
|
||||
COPY config/arch_config_schema.yaml .
|
||||
COPY config/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
RUN mkdir -p /var/log/supervisor && touch /var/log/envoy.log /var/log/supervisor/supervisord.log
|
||||
|
||||
RUN mkdir -p /var/log && \
|
||||
24
README.md
24
README.md
|
|
@ -58,7 +58,19 @@ Before you begin, ensure you have the following:
|
|||
Plano's CLI allows you to manage and interact with the Plano gateway efficiently. To install the CLI, simply run the following command:
|
||||
|
||||
> [!TIP]
|
||||
> We recommend that developers create a new Python virtual environment to isolate dependencies before installing Plano. This ensures that plano and its dependencies do not interfere with other packages on your system.
|
||||
> We recommend using **uv** for fast, reliable Python package management. Install uv if you haven't already:
|
||||
>
|
||||
> ```console
|
||||
> $ curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
> ```
|
||||
|
||||
**Option 1: Install planoai with uv (Recommended)**
|
||||
|
||||
```console
|
||||
$ uv tool install planoai==0.4.0
|
||||
```
|
||||
|
||||
**Option 2: Install with pip (Traditional)**
|
||||
|
||||
```console
|
||||
$ python3.12 -m venv venv
|
||||
|
|
@ -161,6 +173,8 @@ Run your `flight_agent` and `hotel_agent` services (see the [Orchestration guide
|
|||
|
||||
```console
|
||||
$ planoai up plano_config.yaml
|
||||
# Or if installed with uv tool:
|
||||
$ uvx planoai up plano_config.yaml
|
||||
```
|
||||
|
||||
Plano will start the orchestrator and expose an agent listener on port `8001`.
|
||||
|
|
@ -233,10 +247,10 @@ endpoints:
|
|||
|
||||
```sh
|
||||
$ planoai up plano_config.yaml
|
||||
2024-12-05 16:56:27,979 - cli.main - INFO - Starting plano cli version: 0.4.0
|
||||
2024-12-05 16:56:28,485 - cli.utils - INFO - Schema validation successful!
|
||||
2024-12-05 16:56:28,485 - cli.main - INFO - Starting plano model server and plano gateway
|
||||
2024-12-05 16:56:51,647 - cli.core - INFO - Container is healthy!
|
||||
2024-12-05 16:56:27,979 - planoai.main - INFO - Starting plano cli version: 0.4.0
|
||||
2024-12-05 16:56:28,485 - planoai.utils - INFO - Schema validation successful!
|
||||
2024-12-05 16:56:28,485 - planoai.main - INFO - Starting plano model server and plano gateway
|
||||
2024-12-05 16:56:51,647 - planoai.core - INFO - Container is healthy!
|
||||
```
|
||||
|
||||
Once the gateway is up you can start interacting with it at port `10000` using the OpenAI chat completion API.
|
||||
|
|
|
|||
779
arch/tools/poetry.lock
generated
779
arch/tools/poetry.lock
generated
|
|
@ -1,779 +0,0 @@
|
|||
# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "25.4.0"
|
||||
description = "Classes Without Boilerplate"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"},
|
||||
{file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.11.12"
|
||||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"},
|
||||
{file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.4"
|
||||
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"},
|
||||
{file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"},
|
||||
{file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"},
|
||||
{file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"},
|
||||
{file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"},
|
||||
{file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"},
|
||||
{file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"},
|
||||
{file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"},
|
||||
{file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"},
|
||||
{file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.0"
|
||||
description = "Composable command line interface toolkit"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"},
|
||||
{file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
colorama = {version = "*", markers = "platform_system == \"Windows\""}
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
description = "Cross-platform colored terminal text."
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\""}
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
description = "Backport of PEP 654 (exception groups)"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["dev"]
|
||||
markers = "python_version == \"3.10\""
|
||||
files = [
|
||||
{file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"},
|
||||
{file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""}
|
||||
|
||||
[package.extras]
|
||||
test = ["pytest (>=6)"]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"},
|
||||
{file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
description = "brain-dead simple config-ini parsing"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
|
||||
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
description = "A very fast and expressive template engine."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"},
|
||||
{file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
MarkupSafe = ">=2.0"
|
||||
|
||||
[package.extras]
|
||||
i18n = ["Babel (>=2.7)"]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.25.1"
|
||||
description = "An implementation of JSON Schema validation for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63"},
|
||||
{file = "jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
attrs = ">=22.2.0"
|
||||
jsonschema-specifications = ">=2023.03.6"
|
||||
referencing = ">=0.28.4"
|
||||
rpds-py = ">=0.7.1"
|
||||
|
||||
[package.extras]
|
||||
format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"]
|
||||
format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-specifications"
|
||||
version = "2025.9.1"
|
||||
description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"},
|
||||
{file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
referencing = ">=0.31.0"
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.3"
|
||||
description = "Safely add untrusted strings to HTML/XML markup."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"},
|
||||
{file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"},
|
||||
{file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"},
|
||||
{file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"},
|
||||
{file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"},
|
||||
{file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"},
|
||||
{file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"},
|
||||
{file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"},
|
||||
{file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"},
|
||||
{file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"},
|
||||
{file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"},
|
||||
{file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"},
|
||||
{file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"},
|
||||
{file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"},
|
||||
{file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"},
|
||||
{file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"},
|
||||
{file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"},
|
||||
{file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"},
|
||||
{file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"},
|
||||
{file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"},
|
||||
{file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"},
|
||||
{file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"},
|
||||
{file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"},
|
||||
{file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"},
|
||||
{file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"},
|
||||
{file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"},
|
||||
{file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"},
|
||||
{file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"},
|
||||
{file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"},
|
||||
{file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"},
|
||||
{file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"},
|
||||
{file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"},
|
||||
{file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"},
|
||||
{file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"},
|
||||
{file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"},
|
||||
{file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"},
|
||||
{file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"},
|
||||
{file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"},
|
||||
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"},
|
||||
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"},
|
||||
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"},
|
||||
{file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"},
|
||||
{file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"},
|
||||
{file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"},
|
||||
{file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
description = "Core utilities for Python packages"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
|
||||
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
description = "plugin and hook calling mechanisms for python"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
|
||||
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
dev = ["pre-commit", "tox"]
|
||||
testing = ["coverage", "pytest", "pytest-benchmark"]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
description = "Pygments is a syntax highlighting package written in Python."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
|
||||
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
windows-terminal = ["colorama (>=0.4.6)"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.4.2"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"},
|
||||
{file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
|
||||
exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""}
|
||||
iniconfig = ">=1"
|
||||
packaging = ">=20"
|
||||
pluggy = ">=1.5,<2"
|
||||
pygments = ">=2.7.2"
|
||||
tomli = {version = ">=1", markers = "python_version < \"3.11\""}
|
||||
|
||||
[package.extras]
|
||||
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
description = "YAML parser and emitter for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
|
||||
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
|
||||
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"},
|
||||
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"},
|
||||
{file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"},
|
||||
{file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"},
|
||||
{file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"},
|
||||
{file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"},
|
||||
{file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"},
|
||||
{file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"},
|
||||
{file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"},
|
||||
{file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"},
|
||||
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"},
|
||||
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"},
|
||||
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"},
|
||||
{file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"},
|
||||
{file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"},
|
||||
{file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"},
|
||||
{file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"},
|
||||
{file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"},
|
||||
{file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"},
|
||||
{file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"},
|
||||
{file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"},
|
||||
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"},
|
||||
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"},
|
||||
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"},
|
||||
{file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"},
|
||||
{file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"},
|
||||
{file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"},
|
||||
{file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"},
|
||||
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.37.0"
|
||||
description = "JSON Referencing + Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"},
|
||||
{file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
attrs = ">=22.2.0"
|
||||
rpds-py = ">=0.7.0"
|
||||
typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""}
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
description = "Python HTTP for Humans."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
|
||||
{file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=2017.4.17"
|
||||
charset_normalizer = ">=2,<4"
|
||||
idna = ">=2.5,<4"
|
||||
urllib3 = ">=1.21.1,<3"
|
||||
|
||||
[package.extras]
|
||||
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
|
||||
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
|
||||
|
||||
[[package]]
|
||||
name = "rpds-py"
|
||||
version = "0.28.0"
|
||||
description = "Python bindings to Rust's persistent data structures (rpds)"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "rpds_py-0.28.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7b6013db815417eeb56b2d9d7324e64fcd4fa289caeee6e7a78b2e11fc9b438a"},
|
||||
{file = "rpds_py-0.28.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a4c6b05c685c0c03f80dabaeb73e74218c49deea965ca63f76a752807397207"},
|
||||
{file = "rpds_py-0.28.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4794c6c3fbe8f9ac87699b131a1f26e7b4abcf6d828da46a3a52648c7930eba"},
|
||||
{file = "rpds_py-0.28.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e8456b6ee5527112ff2354dd9087b030e3429e43a74f480d4a5ca79d269fd85"},
|
||||
{file = "rpds_py-0.28.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:beb880a9ca0a117415f241f66d56025c02037f7c4efc6fe59b5b8454f1eaa50d"},
|
||||
{file = "rpds_py-0.28.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6897bebb118c44b38c9cb62a178e09f1593c949391b9a1a6fe777ccab5934ee7"},
|
||||
{file = "rpds_py-0.28.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b553dd06e875249fd43efd727785efb57a53180e0fde321468222eabbeaafa"},
|
||||
{file = "rpds_py-0.28.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:f0b2044fdddeea5b05df832e50d2a06fe61023acb44d76978e1b060206a8a476"},
|
||||
{file = "rpds_py-0.28.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05cf1e74900e8da73fa08cc76c74a03345e5a3e37691d07cfe2092d7d8e27b04"},
|
||||
{file = "rpds_py-0.28.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:efd489fec7c311dae25e94fe7eeda4b3d06be71c68f2cf2e8ef990ffcd2cd7e8"},
|
||||
{file = "rpds_py-0.28.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ada7754a10faacd4f26067e62de52d6af93b6d9542f0df73c57b9771eb3ba9c4"},
|
||||
{file = "rpds_py-0.28.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c2a34fd26588949e1e7977cfcbb17a9a42c948c100cab890c6d8d823f0586457"},
|
||||
{file = "rpds_py-0.28.0-cp310-cp310-win32.whl", hash = "sha256:f9174471d6920cbc5e82a7822de8dfd4dcea86eb828b04fc8c6519a77b0ee51e"},
|
||||
{file = "rpds_py-0.28.0-cp310-cp310-win_amd64.whl", hash = "sha256:6e32dd207e2c4f8475257a3540ab8a93eff997abfa0a3fdb287cae0d6cd874b8"},
|
||||
{file = "rpds_py-0.28.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:03065002fd2e287725d95fbc69688e0c6daf6c6314ba38bdbaa3895418e09296"},
|
||||
{file = "rpds_py-0.28.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28ea02215f262b6d078daec0b45344c89e161eab9526b0d898221d96fdda5f27"},
|
||||
{file = "rpds_py-0.28.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25dbade8fbf30bcc551cb352376c0ad64b067e4fc56f90e22ba70c3ce205988c"},
|
||||
{file = "rpds_py-0.28.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c03002f54cc855860bfdc3442928ffdca9081e73b5b382ed0b9e8efe6e5e205"},
|
||||
{file = "rpds_py-0.28.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9699fa7990368b22032baf2b2dce1f634388e4ffc03dfefaaac79f4695edc95"},
|
||||
{file = "rpds_py-0.28.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b9b06fe1a75e05e0713f06ea0c89ecb6452210fd60e2f1b6ddc1067b990e08d9"},
|
||||
{file = "rpds_py-0.28.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac9f83e7b326a3f9ec3ef84cda98fb0a74c7159f33e692032233046e7fd15da2"},
|
||||
{file = "rpds_py-0.28.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:0d3259ea9ad8743a75a43eb7819324cdab393263c91be86e2d1901ee65c314e0"},
|
||||
{file = "rpds_py-0.28.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a7548b345f66f6695943b4ef6afe33ccd3f1b638bd9afd0f730dd255c249c9e"},
|
||||
{file = "rpds_py-0.28.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9a40040aa388b037eb39416710fbcce9443498d2eaab0b9b45ae988b53f5c67"},
|
||||
{file = "rpds_py-0.28.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f60c7ea34e78c199acd0d3cda37a99be2c861dd2b8cf67399784f70c9f8e57d"},
|
||||
{file = "rpds_py-0.28.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1571ae4292649100d743b26d5f9c63503bb1fedf538a8f29a98dce2d5ba6b4e6"},
|
||||
{file = "rpds_py-0.28.0-cp311-cp311-win32.whl", hash = "sha256:5cfa9af45e7c1140af7321fa0bef25b386ee9faa8928c80dc3a5360971a29e8c"},
|
||||
{file = "rpds_py-0.28.0-cp311-cp311-win_amd64.whl", hash = "sha256:dd8d86b5d29d1b74100982424ba53e56033dc47720a6de9ba0259cf81d7cecaa"},
|
||||
{file = "rpds_py-0.28.0-cp311-cp311-win_arm64.whl", hash = "sha256:4e27d3a5709cc2b3e013bf93679a849213c79ae0573f9b894b284b55e729e120"},
|
||||
{file = "rpds_py-0.28.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6b4f28583a4f247ff60cd7bdda83db8c3f5b05a7a82ff20dd4b078571747708f"},
|
||||
{file = "rpds_py-0.28.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d678e91b610c29c4b3d52a2c148b641df2b4676ffe47c59f6388d58b99cdc424"},
|
||||
{file = "rpds_py-0.28.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e819e0e37a44a78e1383bf1970076e2ccc4dc8c2bbaa2f9bd1dc987e9afff628"},
|
||||
{file = "rpds_py-0.28.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5ee514e0f0523db5d3fb171f397c54875dbbd69760a414dccf9d4d7ad628b5bd"},
|
||||
{file = "rpds_py-0.28.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3fa06d27fdcee47f07a39e02862da0100cb4982508f5ead53ec533cd5fe55e"},
|
||||
{file = "rpds_py-0.28.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46959ef2e64f9e4a41fc89aa20dbca2b85531f9a72c21099a3360f35d10b0d5a"},
|
||||
{file = "rpds_py-0.28.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8455933b4bcd6e83fde3fefc987a023389c4b13f9a58c8d23e4b3f6d13f78c84"},
|
||||
{file = "rpds_py-0.28.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ad50614a02c8c2962feebe6012b52f9802deec4263946cddea37aaf28dd25a66"},
|
||||
{file = "rpds_py-0.28.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e5deca01b271492553fdb6c7fd974659dce736a15bae5dad7ab8b93555bceb28"},
|
||||
{file = "rpds_py-0.28.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:735f8495a13159ce6a0d533f01e8674cec0c57038c920495f87dcb20b3ddb48a"},
|
||||
{file = "rpds_py-0.28.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:961ca621ff10d198bbe6ba4957decca61aa2a0c56695384c1d6b79bf61436df5"},
|
||||
{file = "rpds_py-0.28.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2374e16cc9131022e7d9a8f8d65d261d9ba55048c78f3b6e017971a4f5e6353c"},
|
||||
{file = "rpds_py-0.28.0-cp312-cp312-win32.whl", hash = "sha256:d15431e334fba488b081d47f30f091e5d03c18527c325386091f31718952fe08"},
|
||||
{file = "rpds_py-0.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:a410542d61fc54710f750d3764380b53bf09e8c4edbf2f9141a82aa774a04f7c"},
|
||||
{file = "rpds_py-0.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:1f0cfd1c69e2d14f8c892b893997fa9a60d890a0c8a603e88dca4955f26d1edd"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e9e184408a0297086f880556b6168fa927d677716f83d3472ea333b42171ee3b"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:edd267266a9b0448f33dc465a97cfc5d467594b600fe28e7fa2f36450e03053a"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85beb8b3f45e4e32f6802fb6cd6b17f615ef6c6a52f265371fb916fae02814aa"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2412be8d00a1b895f8ad827cc2116455196e20ed994bb704bf138fe91a42724"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cf128350d384b777da0e68796afdcebc2e9f63f0e9f242217754e647f6d32491"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2036d09b363aa36695d1cc1a97b36865597f4478470b0697b5ee9403f4fe399"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8e1e9be4fa6305a16be628959188e4fd5cd6f1b0e724d63c6d8b2a8adf74ea6"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0a403460c9dd91a7f23fc3188de6d8977f1d9603a351d5db6cf20aaea95b538d"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d7366b6553cdc805abcc512b849a519167db8f5e5c3472010cd1228b224265cb"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b43c6a3726efd50f18d8120ec0551241c38785b68952d240c45ea553912ac41"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0cb7203c7bc69d7c1585ebb33a2e6074492d2fc21ad28a7b9d40457ac2a51ab7"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a52a5169c664dfb495882adc75c304ae1d50df552fbd68e100fdc719dee4ff9"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313-win32.whl", hash = "sha256:2e42456917b6687215b3e606ab46aa6bca040c77af7df9a08a6dcfe8a4d10ca5"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:e0a0311caedc8069d68fc2bf4c9019b58a2d5ce3cd7cb656c845f1615b577e1e"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:04c1b207ab8b581108801528d59ad80aa83bb170b35b0ddffb29c20e411acdc1"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f296ea3054e11fc58ad42e850e8b75c62d9a93a9f981ad04b2e5ae7d2186ff9c"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5a7306c19b19005ad98468fcefeb7100b19c79fc23a5f24a12e06d91181193fa"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5d9b86aa501fed9862a443c5c3116f6ead8bc9296185f369277c42542bd646b"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e5bbc701eff140ba0e872691d573b3d5d30059ea26e5785acba9132d10c8c31d"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a5690671cd672a45aa8616d7374fdf334a1b9c04a0cac3c854b1136e92374fe"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9f1d92ecea4fa12f978a367c32a5375a1982834649cdb96539dcdc12e609ab1a"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d252db6b1a78d0a3928b6190156042d54c93660ce4d98290d7b16b5296fb7cc"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d61b355c3275acb825f8777d6c4505f42b5007e357af500939d4a35b19177259"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:acbe5e8b1026c0c580d0321c8aae4b0a1e1676861d48d6e8c6586625055b606a"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8aa23b6f0fc59b85b4c7d89ba2965af274346f738e8d9fc2455763602e62fd5f"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7b14b0c680286958817c22d76fcbca4800ddacef6f678f3a7c79a1fe7067fe37"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:bcf1d210dfee61a6c86551d67ee1031899c0fdbae88b2d44a569995d43797712"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313t-win32.whl", hash = "sha256:3aa4dc0fdab4a7029ac63959a3ccf4ed605fee048ba67ce89ca3168da34a1342"},
|
||||
{file = "rpds_py-0.28.0-cp313-cp313t-win_amd64.whl", hash = "sha256:7b7d9d83c942855e4fdcfa75d4f96f6b9e272d42fffcb72cd4bb2577db2e2907"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:dcdcb890b3ada98a03f9f2bb108489cdc7580176cb73b4f2d789e9a1dac1d472"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f274f56a926ba2dc02976ca5b11c32855cbd5925534e57cfe1fda64e04d1add2"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fe0438ac4a29a520ea94c8c7f1754cdd8feb1bc490dfda1bfd990072363d527"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a358a32dd3ae50e933347889b6af9a1bdf207ba5d1a3f34e1a38cd3540e6733"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e80848a71c78aa328fefaba9c244d588a342c8e03bda518447b624ea64d1ff56"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f586db2e209d54fe177e58e0bc4946bea5fb0102f150b1b2f13de03e1f0976f8"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ae8ee156d6b586e4292491e885d41483136ab994e719a13458055bec14cf370"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a805e9b3973f7e27f7cab63a6b4f61d90f2e5557cff73b6e97cd5b8540276d3d"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5d3fd16b6dc89c73a4da0b4ac8b12a7ecc75b2864b95c9e5afed8003cb50a728"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6796079e5d24fdaba6d49bda28e2c47347e89834678f2bc2c1b4fc1489c0fb01"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:76500820c2af232435cbe215e3324c75b950a027134e044423f59f5b9a1ba515"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bbdc5640900a7dbf9dd707fe6388972f5bbd883633eb68b76591044cfe346f7e"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314-win32.whl", hash = "sha256:adc8aa88486857d2b35d75f0640b949759f79dc105f50aa2c27816b2e0dd749f"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:66e6fa8e075b58946e76a78e69e1a124a21d9a48a5b4766d15ba5b06869d1fa1"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6fe887c2c5c59413353b7c0caff25d0e566623501ccfff88957fa438a69377d"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7a69df082db13c7070f7b8b1f155fa9e687f1d6aefb7b0e3f7231653b79a067b"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b1cde22f2c30ebb049a9e74c5374994157b9b70a16147d332f89c99c5960737a"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5338742f6ba7a51012ea470bd4dc600a8c713c0c72adaa0977a1b1f4327d6592"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1460ebde1bcf6d496d80b191d854adedcc619f84ff17dc1c6d550f58c9efbba"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e3eb248f2feba84c692579257a043a7699e28a77d86c77b032c1d9fbb3f0219c"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3bbba5def70b16cd1c1d7255666aad3b290fbf8d0fe7f9f91abafb73611a91"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3114f4db69ac5a1f32e7e4d1cbbe7c8f9cf8217f78e6e002cedf2d54c2a548ed"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4b0cb8a906b1a0196b863d460c0222fb8ad0f34041568da5620f9799b83ccf0b"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf681ac76a60b667106141e11a92a3330890257e6f559ca995fbb5265160b56e"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1e8ee6413cfc677ce8898d9cde18cc3a60fc2ba756b0dec5b71eb6eb21c49fa1"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b3072b16904d0b5572a15eb9d31c1954e0d3227a585fc1351aa9878729099d6c"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b670c30fd87a6aec281c3c9896d3bae4b205fd75d79d06dc87c2503717e46092"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314t-win32.whl", hash = "sha256:8014045a15b4d2b3476f0a287fcc93d4f823472d7d1308d47884ecac9e612be3"},
|
||||
{file = "rpds_py-0.28.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7a4e59c90d9c27c561eb3160323634a9ff50b04e4f7820600a2beb0ac90db578"},
|
||||
{file = "rpds_py-0.28.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f5e7101145427087e493b9c9b959da68d357c28c562792300dd21a095118ed16"},
|
||||
{file = "rpds_py-0.28.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:31eb671150b9c62409a888850aaa8e6533635704fe2b78335f9aaf7ff81eec4d"},
|
||||
{file = "rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48b55c1f64482f7d8bd39942f376bfdf2f6aec637ee8c805b5041e14eeb771db"},
|
||||
{file = "rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24743a7b372e9a76171f6b69c01aedf927e8ac3e16c474d9fe20d552a8cb45c7"},
|
||||
{file = "rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:389c29045ee8bbb1627ea190b4976a310a295559eaf9f1464a1a6f2bf84dde78"},
|
||||
{file = "rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23690b5827e643150cf7b49569679ec13fe9a610a15949ed48b85eb7f98f34ec"},
|
||||
{file = "rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f0c9266c26580e7243ad0d72fc3e01d6b33866cfab5084a6da7576bcf1c4f72"},
|
||||
{file = "rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:4c6c4db5d73d179746951486df97fd25e92396be07fc29ee8ff9a8f5afbdfb27"},
|
||||
{file = "rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3b695a8fa799dd2cfdb4804b37096c5f6dba1ac7f48a7fbf6d0485bcd060316"},
|
||||
{file = "rpds_py-0.28.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:6aa1bfce3f83baf00d9c5fcdbba93a3ab79958b4c7d7d1f55e7fe68c20e63912"},
|
||||
{file = "rpds_py-0.28.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:7b0f9dceb221792b3ee6acb5438eb1f02b0cb2c247796a72b016dcc92c6de829"},
|
||||
{file = "rpds_py-0.28.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:5d0145edba8abd3db0ab22b5300c99dc152f5c9021fab861be0f0544dc3cbc5f"},
|
||||
{file = "rpds_py-0.28.0.tar.gz", hash = "sha256:abd4df20485a0983e2ca334a216249b6186d6e3c1627e106651943dbdb791aea"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.3.0"
|
||||
description = "A lil' TOML parser"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
markers = "python_version == \"3.10\""
|
||||
files = [
|
||||
{file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"},
|
||||
{file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"},
|
||||
{file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"},
|
||||
{file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"},
|
||||
{file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"},
|
||||
{file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"},
|
||||
{file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"},
|
||||
{file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"},
|
||||
{file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"},
|
||||
{file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"},
|
||||
{file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"},
|
||||
{file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"},
|
||||
{file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"},
|
||||
{file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"},
|
||||
{file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"},
|
||||
{file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"},
|
||||
{file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"},
|
||||
{file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"},
|
||||
{file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"},
|
||||
{file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"},
|
||||
{file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"},
|
||||
{file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"},
|
||||
{file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"},
|
||||
{file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"},
|
||||
{file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"},
|
||||
{file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"},
|
||||
{file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"},
|
||||
{file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"},
|
||||
{file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"},
|
||||
{file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"},
|
||||
{file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"},
|
||||
{file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"},
|
||||
{file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"},
|
||||
{file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"},
|
||||
{file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"},
|
||||
{file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"},
|
||||
{file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"},
|
||||
{file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"},
|
||||
{file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"},
|
||||
{file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"},
|
||||
{file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"},
|
||||
{file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
description = "Backported and Experimental Type Hints for Python 3.9+"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
|
||||
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
||||
]
|
||||
markers = {main = "python_version < \"3.13\"", dev = "python_version == \"3.10\""}
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.5.0"
|
||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
|
||||
{file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""]
|
||||
h2 = ["h2 (>=4,<5)"]
|
||||
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||
zstd = ["zstandard (>=0.18.0)"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.10"
|
||||
content-hash = "597534a122f4396a842eec6430e963f8fc6dd8d929cae1d5d6306aa262fcd1ee"
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
[tool.poetry]
|
||||
name = "planoai"
|
||||
version = "0.4.0"
|
||||
description = "Python-based CLI tool to manage Plano."
|
||||
authors = ["Katanemo Labs, Inc."]
|
||||
readme = "README.md"
|
||||
packages = [{ include = "cli" }]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.10"
|
||||
click = ">=8.1.7,<9.0.0"
|
||||
jinja2 = ">=3.1.4,<4.0.0"
|
||||
jsonschema = ">=4.23.0,<5.0.0"
|
||||
pyyaml = ">=6.0.2,<7.0.0"
|
||||
requests = ">=2.31.0,<3.0.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = ">=8.4.1,<9.0.0"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
planoai = "cli.main:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = ["-v"]
|
||||
|
|
@ -1,32 +1,24 @@
|
|||
{
|
||||
"folders": [
|
||||
{
|
||||
"name": "root",
|
||||
"name": "plano",
|
||||
"path": "."
|
||||
},
|
||||
{
|
||||
"name": "crates",
|
||||
"name": "rust_crates",
|
||||
"path": "crates"
|
||||
},
|
||||
{
|
||||
"name": "archgw_cli",
|
||||
"path": "arch/tools"
|
||||
"name": "cli",
|
||||
"path": "cli"
|
||||
},
|
||||
{
|
||||
"name": "tests_e2e",
|
||||
"path": "tests/e2e"
|
||||
"name": "docs",
|
||||
"path": "docs"
|
||||
},
|
||||
{
|
||||
"name": "tests_archgw",
|
||||
"path": "tests/archgw"
|
||||
},
|
||||
{
|
||||
"name": "chatbot_ui",
|
||||
"path": "demos/shared/chatbot_ui"
|
||||
},
|
||||
{
|
||||
"name": "java_demo",
|
||||
"path": "demos/samples_java/weather_forcecast_service"
|
||||
"name": "website",
|
||||
"path": "apps/www"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
docker build -f arch/Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.0
|
||||
docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.0
|
||||
|
|
|
|||
|
|
@ -31,36 +31,44 @@ pip uninstall planoai
|
|||
|
||||
This guide will walk you through the steps to set up the plano cli on your local machine when you want to develop the plano CLI
|
||||
|
||||
### Step 1: Create a Python virtual environment
|
||||
### Step 1: Install uv
|
||||
|
||||
In the tools directory, create a Python virtual environment by running:
|
||||
Install uv if you haven't already:
|
||||
|
||||
```bash
|
||||
python -m venv venv
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
```
|
||||
|
||||
### Step 2: Activate the virtual environment
|
||||
### Step 2: Create a Python virtual environment and install dependencies
|
||||
|
||||
In the cli directory, run:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
This will create a virtual environment and install all dependencies.
|
||||
|
||||
### Step 3: Activate the virtual environment (optional)
|
||||
|
||||
uv will automatically use the virtual environment, but if you need to activate it manually:
|
||||
|
||||
* On Linux/MacOS:
|
||||
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
### Step 3: Run the build script
|
||||
```bash
|
||||
poetry install
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
### Step 4: build Arch
|
||||
```bash
|
||||
planoai build
|
||||
uv run planoai build
|
||||
```
|
||||
|
||||
### Logs
|
||||
`plano` command can also view logs from the gateway. Use following command to view logs,
|
||||
|
||||
```bash
|
||||
planoai logs --follow
|
||||
uv run planoai logs --follow
|
||||
```
|
||||
|
||||
## Uninstall Instructions: plano CLI
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
#!/bin/bash
|
||||
|
||||
echo "Building the cli"
|
||||
poetry install
|
||||
uv sync
|
||||
3
cli/planoai/__init__.py
Normal file
3
cli/planoai/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""Plano CLI - Intelligent Prompt Gateway."""
|
||||
|
||||
__version__ = "0.4.0"
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import json
|
||||
import os
|
||||
from cli.utils import convert_legacy_listeners
|
||||
from planoai.utils import convert_legacy_listeners
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
import yaml
|
||||
from jsonschema import validate
|
||||
|
|
@ -5,13 +5,13 @@ import time
|
|||
import sys
|
||||
|
||||
import yaml
|
||||
from cli.utils import convert_legacy_listeners, getLogger
|
||||
from cli.consts import (
|
||||
from planoai.utils import convert_legacy_listeners, getLogger
|
||||
from planoai.consts import (
|
||||
PLANO_DOCKER_IMAGE,
|
||||
PLANO_DOCKER_NAME,
|
||||
)
|
||||
import subprocess
|
||||
from cli.docker_cli import (
|
||||
from planoai.docker_cli import (
|
||||
docker_container_status,
|
||||
docker_remove_container,
|
||||
docker_start_plano_detached,
|
||||
|
|
@ -3,11 +3,11 @@ import json
|
|||
import sys
|
||||
import requests
|
||||
|
||||
from cli.consts import (
|
||||
from planoai.consts import (
|
||||
PLANO_DOCKER_IMAGE,
|
||||
PLANO_DOCKER_NAME,
|
||||
)
|
||||
from cli.utils import getLogger
|
||||
from planoai.utils import getLogger
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
|
@ -127,7 +127,7 @@ def docker_validate_plano_schema(arch_config_file):
|
|||
"python",
|
||||
PLANO_DOCKER_IMAGE,
|
||||
"-m",
|
||||
"cli.config_generator",
|
||||
"planoai.config_generator",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
|
@ -5,26 +5,27 @@ import subprocess
|
|||
import multiprocessing
|
||||
import importlib.metadata
|
||||
import json
|
||||
from cli import targets
|
||||
from cli.docker_cli import (
|
||||
from planoai import targets
|
||||
from planoai.docker_cli import (
|
||||
docker_validate_plano_schema,
|
||||
stream_gateway_logs,
|
||||
docker_container_status,
|
||||
)
|
||||
from cli.utils import (
|
||||
from planoai.utils import (
|
||||
getLogger,
|
||||
get_llm_provider_access_keys,
|
||||
has_ingress_listener,
|
||||
load_env_file_to_dict,
|
||||
stream_access_logs,
|
||||
find_config_file,
|
||||
find_repo_root,
|
||||
)
|
||||
from cli.core import (
|
||||
from planoai.core import (
|
||||
start_arch,
|
||||
stop_docker_container,
|
||||
start_cli_agent,
|
||||
)
|
||||
from cli.consts import (
|
||||
from planoai.consts import (
|
||||
PLANO_DOCKER_IMAGE,
|
||||
PLANO_DOCKER_NAME,
|
||||
SERVICE_NAME_ARCHGW,
|
||||
|
|
@ -44,15 +45,22 @@ ______ _
|
|||
"""
|
||||
|
||||
# Command to build plano Docker images
|
||||
ARCHGW_DOCKERFILE = "./arch/Dockerfile"
|
||||
ARCHGW_DOCKERFILE = "./Dockerfile"
|
||||
|
||||
|
||||
def get_version():
|
||||
try:
|
||||
version = importlib.metadata.version("plano")
|
||||
# First try to get version from package metadata (for installed packages)
|
||||
version = importlib.metadata.version("planoai")
|
||||
return version
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return "version not found"
|
||||
# Fallback to version defined in __init__.py (for development)
|
||||
try:
|
||||
from planoai import __version__
|
||||
|
||||
return __version__
|
||||
except ImportError:
|
||||
return "version not found"
|
||||
|
||||
|
||||
@click.group(invoke_without_command=True)
|
||||
|
|
@ -73,35 +81,41 @@ def main(ctx, version):
|
|||
|
||||
@click.command()
|
||||
def build():
|
||||
"""Build Arch from source. Must be in root of cloned repo."""
|
||||
"""Build Arch from source. Works from any directory within the repo."""
|
||||
|
||||
# Check if /arch/Dockerfile exists
|
||||
if os.path.exists(ARCHGW_DOCKERFILE):
|
||||
if os.path.exists(ARCHGW_DOCKERFILE):
|
||||
click.echo("Building plano image...")
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"build",
|
||||
"-f",
|
||||
ARCHGW_DOCKERFILE,
|
||||
"-t",
|
||||
f"{PLANO_DOCKER_IMAGE}",
|
||||
".",
|
||||
"--add-host=host.docker.internal:host-gateway",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
click.echo("archgw image built successfully.")
|
||||
except subprocess.CalledProcessError as e:
|
||||
click.echo(f"Error building plano image: {e}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
click.echo("Error: Dockerfile not found in /arch")
|
||||
sys.exit(1)
|
||||
# Find the repo root
|
||||
repo_root = find_repo_root()
|
||||
if not repo_root:
|
||||
click.echo(
|
||||
"Error: Could not find repository root. Make sure you're inside the plano repository."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
click.echo("archgw image built successfully.")
|
||||
dockerfile_path = os.path.join(repo_root, "Dockerfile")
|
||||
|
||||
if not os.path.exists(dockerfile_path):
|
||||
click.echo(f"Error: Dockerfile not found at {dockerfile_path}")
|
||||
sys.exit(1)
|
||||
|
||||
click.echo(f"Building plano image from {repo_root}...")
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"build",
|
||||
"-f",
|
||||
dockerfile_path,
|
||||
"-t",
|
||||
f"{PLANO_DOCKER_IMAGE}",
|
||||
repo_root,
|
||||
"--add-host=host.docker.internal:host-gateway",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
click.echo("archgw image built successfully.")
|
||||
except subprocess.CalledProcessError as e:
|
||||
click.echo(f"Error building plano image: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@click.command()
|
||||
|
|
@ -4,6 +4,7 @@ import subprocess
|
|||
import sys
|
||||
import yaml
|
||||
import logging
|
||||
from planoai.consts import PLANO_DOCKER_NAME
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
|
|
@ -21,6 +22,33 @@ def getLogger(name="cli"):
|
|||
log = getLogger(__name__)
|
||||
|
||||
|
||||
def find_repo_root(start_path=None):
|
||||
"""Find the repository root by looking for Dockerfile or .git directory."""
|
||||
if start_path is None:
|
||||
start_path = os.getcwd()
|
||||
|
||||
current = os.path.abspath(start_path)
|
||||
|
||||
while current != os.path.dirname(current): # Stop at filesystem root
|
||||
# Check for markers that indicate repo root
|
||||
if (
|
||||
os.path.exists(os.path.join(current, "Dockerfile"))
|
||||
and os.path.exists(os.path.join(current, "crates"))
|
||||
and os.path.exists(os.path.join(current, "config"))
|
||||
):
|
||||
return current
|
||||
|
||||
# Also check for .git as fallback
|
||||
if os.path.exists(os.path.join(current, ".git")):
|
||||
# Verify it's the right repo by checking for expected structure
|
||||
if os.path.exists(os.path.join(current, "crates")):
|
||||
return current
|
||||
|
||||
current = os.path.dirname(current)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def has_ingress_listener(arch_config_file):
|
||||
"""Check if the arch config file has ingress_traffic listener configured."""
|
||||
try:
|
||||
|
|
@ -221,7 +249,7 @@ def stream_access_logs(follow):
|
|||
stream_command = [
|
||||
"docker",
|
||||
"exec",
|
||||
"archgw",
|
||||
PLANO_DOCKER_NAME,
|
||||
"sh",
|
||||
"-c",
|
||||
f"tail {follow_arg} /var/log/access_*.log",
|
||||
40
cli/pyproject.toml
Normal file
40
cli/pyproject.toml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
[project]
|
||||
name = "planoai"
|
||||
version = "0.4.0"
|
||||
description = "Python-based CLI tool to manage Plano."
|
||||
authors = [{name = "Katanemo Labs, Inc."}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"click>=8.1.7,<9.0.0",
|
||||
"jinja2>=3.1.4,<4.0.0",
|
||||
"jsonschema>=4.23.0,<5.0.0",
|
||||
"pyyaml>=6.0.2,<7.0.0",
|
||||
"requests>=2.31.0,<3.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.4.1,<9.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
planoai = "planoai.main:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.version]
|
||||
path = "planoai/__init__.py"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["planoai"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = ["-v"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.4.2",
|
||||
]
|
||||
|
|
@ -1,15 +1,7 @@
|
|||
import json
|
||||
import pytest
|
||||
from unittest import mock
|
||||
import sys
|
||||
from cli.config_generator import validate_and_render_schema
|
||||
|
||||
# Patch sys.path to allow import from cli/
|
||||
import os
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "cli"))
|
||||
)
|
||||
from planoai.config_generator import validate_and_render_schema
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
@ -59,13 +51,13 @@ tracing:
|
|||
random_sampling: 100
|
||||
"""
|
||||
arch_config_schema = ""
|
||||
with open("../arch_config_schema.yaml", "r") as file:
|
||||
with open("../config/arch_config_schema.yaml", "r") as file:
|
||||
arch_config_schema = file.read()
|
||||
|
||||
m_open = mock.mock_open()
|
||||
# Provide enough file handles for all open() calls in validate_and_render_schema
|
||||
m_open.side_effect = [
|
||||
mock.mock_open(read_data="").return_value,
|
||||
# Removed empty read - was causing validation failures
|
||||
mock.mock_open(read_data=arch_config).return_value, # ARCH_CONFIG_FILE
|
||||
mock.mock_open(
|
||||
read_data=arch_config_schema
|
||||
|
|
@ -78,7 +70,7 @@ tracing:
|
|||
mock.mock_open().return_value, # ARCH_CONFIG_FILE_RENDERED (write)
|
||||
]
|
||||
with mock.patch("builtins.open", m_open):
|
||||
with mock.patch("config_generator.Environment"):
|
||||
with mock.patch("planoai.config_generator.Environment"):
|
||||
validate_and_render_schema()
|
||||
|
||||
|
||||
|
|
@ -108,7 +100,7 @@ agents:
|
|||
listeners:
|
||||
- name: tmobile
|
||||
type: agent
|
||||
router: arch_agent_v2
|
||||
router: plano_orchestrator_v1
|
||||
agents:
|
||||
- name: simple_tmobile_rag_agent
|
||||
description: t-mobile virtual assistant for device contracts.
|
||||
|
|
@ -132,13 +124,13 @@ listeners:
|
|||
model: openai/gpt-4o
|
||||
"""
|
||||
arch_config_schema = ""
|
||||
with open("../arch_config_schema.yaml", "r") as file:
|
||||
with open("../config/arch_config_schema.yaml", "r") as file:
|
||||
arch_config_schema = file.read()
|
||||
|
||||
m_open = mock.mock_open()
|
||||
# Provide enough file handles for all open() calls in validate_and_render_schema
|
||||
m_open.side_effect = [
|
||||
mock.mock_open(read_data="").return_value,
|
||||
# Removed empty read - was causing validation failures
|
||||
mock.mock_open(read_data=arch_config).return_value, # ARCH_CONFIG_FILE
|
||||
mock.mock_open(
|
||||
read_data=arch_config_schema
|
||||
|
|
@ -151,7 +143,7 @@ listeners:
|
|||
mock.mock_open().return_value, # ARCH_CONFIG_FILE_RENDERED (write)
|
||||
]
|
||||
with mock.patch("builtins.open", m_open):
|
||||
with mock.patch("cli.config_generator.Environment"):
|
||||
with mock.patch("planoai.config_generator.Environment"):
|
||||
validate_and_render_schema()
|
||||
|
||||
|
||||
|
|
@ -319,26 +311,29 @@ def test_validate_and_render_schema_tests(monkeypatch, arch_config_test_case):
|
|||
expected_error = arch_config_test_case.get("expected_error")
|
||||
|
||||
arch_config_schema = ""
|
||||
with open("../arch_config_schema.yaml", "r") as file:
|
||||
with open("../config/arch_config_schema.yaml", "r") as file:
|
||||
arch_config_schema = file.read()
|
||||
|
||||
m_open = mock.mock_open()
|
||||
# Provide enough file handles for all open() calls in validate_and_render_schema
|
||||
m_open.side_effect = [
|
||||
mock.mock_open(read_data="").return_value,
|
||||
mock.mock_open(read_data=arch_config).return_value, # ARCH_CONFIG_FILE
|
||||
mock.mock_open(
|
||||
read_data=arch_config
|
||||
).return_value, # validate_prompt_config: ARCH_CONFIG_FILE
|
||||
mock.mock_open(
|
||||
read_data=arch_config_schema
|
||||
).return_value, # ARCH_CONFIG_SCHEMA_FILE
|
||||
mock.mock_open(read_data=arch_config).return_value, # ARCH_CONFIG_FILE
|
||||
).return_value, # validate_prompt_config: ARCH_CONFIG_SCHEMA_FILE
|
||||
mock.mock_open(
|
||||
read_data=arch_config
|
||||
).return_value, # validate_and_render_schema: ARCH_CONFIG_FILE
|
||||
mock.mock_open(
|
||||
read_data=arch_config_schema
|
||||
).return_value, # ARCH_CONFIG_SCHEMA_FILE
|
||||
).return_value, # validate_and_render_schema: ARCH_CONFIG_SCHEMA_FILE
|
||||
mock.mock_open().return_value, # ENVOY_CONFIG_FILE_RENDERED (write)
|
||||
mock.mock_open().return_value, # ARCH_CONFIG_FILE_RENDERED (write)
|
||||
]
|
||||
with mock.patch("builtins.open", m_open):
|
||||
with mock.patch("config_generator.Environment"):
|
||||
with mock.patch("planoai.config_generator.Environment"):
|
||||
if expected_error:
|
||||
# Test expects an error
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
|
|
@ -350,7 +345,7 @@ def test_validate_and_render_schema_tests(monkeypatch, arch_config_test_case):
|
|||
|
||||
|
||||
def test_convert_legacy_llm_providers():
|
||||
from cli.utils import convert_legacy_listeners
|
||||
from planoai.utils import convert_legacy_listeners
|
||||
|
||||
listeners = {
|
||||
"ingress_traffic": {
|
||||
|
|
@ -420,7 +415,7 @@ def test_convert_legacy_llm_providers():
|
|||
|
||||
|
||||
def test_convert_legacy_llm_providers_no_prompt_gateway():
|
||||
from cli.utils import convert_legacy_listeners
|
||||
from planoai.utils import convert_legacy_listeners
|
||||
|
||||
listeners = {
|
||||
"egress_traffic": {
|
||||
124
arch/tools/uv.lock → cli/uv.lock
generated
124
arch/tools/uv.lock → cli/uv.lock
generated
|
|
@ -1,31 +1,6 @@
|
|||
version = 1
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[[package]]
|
||||
name = "archgw"
|
||||
version = "0.3.10"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "huggingface-hub" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "click" },
|
||||
{ name = "huggingface-hub", specifier = ">=0.34.4" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "pytest", specifier = ">=8.4.2" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests", specifier = ">=2.32.5" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "25.3.0"
|
||||
|
|
@ -141,58 +116,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.19.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fsspec"
|
||||
version = "2025.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/e0/bab50af11c2d75c9c4a2a26a5254573c0bd97cea152254401510950486fa/fsspec-2025.9.0.tar.gz", hash = "sha256:19fd429483d25d28b65ec68f9f4adc16c17ea2c7c7bf54ec61360d478fb19c19", size = 304847 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/71/70db47e4f6ce3e5c37a607355f80da8860a33226be640226ac52cb05ef2e/fsspec-2025.9.0-py3-none-any.whl", hash = "sha256:530dc2a2af60a414a832059574df4a6e10cce927f6f4a78209390fe38955cfb7", size = 199289 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hf-xet"
|
||||
version = "1.1.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/0f/5b60fc28ee7f8cc17a5114a584fd6b86e11c3e0a6e142a7f97a161e9640a/hf_xet-1.1.9.tar.gz", hash = "sha256:c99073ce404462e909f1d5839b2d14a3827b8fe75ed8aed551ba6609c026c803", size = 484242 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/12/56e1abb9a44cdef59a411fe8a8673313195711b5ecce27880eb9c8fa90bd/hf_xet-1.1.9-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a3b6215f88638dd7a6ff82cb4e738dcbf3d863bf667997c093a3c990337d1160", size = 2762553 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/e6/2d0d16890c5f21b862f5df3146519c182e7f0ae49b4b4bf2bd8a40d0b05e/hf_xet-1.1.9-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:9b486de7a64a66f9a172f4b3e0dfe79c9f0a93257c501296a2521a13495a698a", size = 2623216 },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/42/7e6955cf0621e87491a1fb8cad755d5c2517803cea174229b0ec00ff0166/hf_xet-1.1.9-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4c5a840c2c4e6ec875ed13703a60e3523bc7f48031dfd750923b2a4d1a5fc3c", size = 3186789 },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/8b/759233bce05457f5f7ec062d63bbfd2d0c740b816279eaaa54be92aa452a/hf_xet-1.1.9-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:96a6139c9e44dad1c52c52520db0fffe948f6bce487cfb9d69c125f254bb3790", size = 3088747 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/3c/28cc4db153a7601a996985bcb564f7b8f5b9e1a706c7537aad4b4809f358/hf_xet-1.1.9-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ad1022e9a998e784c97b2173965d07fe33ee26e4594770b7785a8cc8f922cd95", size = 3251429 },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/17/7caf27a1d101bfcb05be85850d4aa0a265b2e1acc2d4d52a48026ef1d299/hf_xet-1.1.9-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86754c2d6d5afb11b0a435e6e18911a4199262fe77553f8c50d75e21242193ea", size = 3354643 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/50/0c39c9eed3411deadcc98749a6699d871b822473f55fe472fad7c01ec588/hf_xet-1.1.9-cp37-abi3-win_amd64.whl", hash = "sha256:5aad3933de6b725d61d51034e04174ed1dce7a57c63d530df0014dea15a40127", size = 2804797 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "huggingface-hub"
|
||||
version = "0.34.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "filelock" },
|
||||
{ name = "fsspec" },
|
||||
{ name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/45/c9/bdbe19339f76d12985bc03572f330a01a93c04dffecaaea3061bdd7fb892/huggingface_hub-0.34.4.tar.gz", hash = "sha256:a4228daa6fb001be3f4f4bdaf9a0db00e1739235702848df00885c9b5742c85c", size = 459768 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/7b/bb06b061991107cd8783f300adff3e7b7f284e330fd82f507f2a1417b11d/huggingface_hub-0.34.4-py3-none-any.whl", hash = "sha256:9b365d781739c93ff90c359844221beef048403f1bc1f1c123c191257c3c890a", size = 561452 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
|
|
@ -317,6 +240,41 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "planoai"
|
||||
version = "0.4.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "click", specifier = "<9.0.0,>=8.1.7" },
|
||||
{ name = "jinja2", specifier = "<4.0.0,>=3.1.4" },
|
||||
{ name = "jsonschema", specifier = "<5.0.0,>=4.23.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = "<9.0.0,>=8.4.1" },
|
||||
{ name = "pyyaml", specifier = "<7.0.0,>=6.0.2" },
|
||||
{ name = "requests", specifier = "<3.0.0,>=2.31.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest", specifier = ">=8.4.2" }]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
|
|
@ -600,18 +558,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tqdm"
|
||||
version = "4.67.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "platform_system == 'Windows'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
|
|
@ -12,7 +12,7 @@ services:
|
|||
- /etc/ssl/cert.pem:/etc/ssl/cert.pem
|
||||
- ./envoy.template.yaml:/app/envoy.template.yaml
|
||||
- ./arch_config_schema.yaml:/app/arch_config_schema.yaml
|
||||
- ./tools/cli/config_generator.py:/app/config_generator.py
|
||||
- ../cli/planoai/config_generator.py:/app/planoai/config_generator.py
|
||||
- ../crates/target/wasm32-wasip1/release/llm_gateway.wasm:/etc/envoy/proxy-wasm-plugins/llm_gateway.wasm
|
||||
- ../crates/target/wasm32-wasip1/release/prompt_gateway.wasm:/etc/envoy/proxy-wasm-plugins/prompt_gateway.wasm
|
||||
- ~/archgw_logs:/var/log/
|
||||
|
|
@ -9,7 +9,7 @@ stdout_logfile_maxbytes=0
|
|||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:envoy]
|
||||
command=/bin/sh -c "python -m cli.config_generator && envsubst < /etc/envoy/envoy.yaml > /etc/envoy.env_sub.yaml && envoy -c /etc/envoy.env_sub.yaml --component-log-level wasm:info --log-format '[%%Y-%%m-%%d %%T.%%e][%%l] %%v' 2>&1 | tee /var/log/envoy.log | while IFS= read -r line; do echo '[archgw_logs]' \"$line\"; done"
|
||||
command=/bin/sh -c "uv run python -m planoai.config_generator && envsubst < /etc/envoy/envoy.yaml > /etc/envoy.env_sub.yaml && envoy -c /etc/envoy.env_sub.yaml --component-log-level wasm:info --log-format '[%%Y-%%m-%%d %%T.%%e][%%l] %%v' 2>&1 | tee /var/log/envoy.log | while IFS= read -r line; do echo '[archgw_logs]' \"$line\"; done"
|
||||
stdout_logfile=/dev/stdout
|
||||
redirect_stderr=true
|
||||
stdout_logfile_maxbytes=0
|
||||
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
failed_files=()
|
||||
|
||||
for file in $(find . -name arch_config.yaml -o -name arch_config_full_reference.yaml); do
|
||||
for file in $(find . -name config.yaml -o -name arch_config_full_reference.yaml); do
|
||||
echo "Validating ${file}..."
|
||||
touch $(pwd)/${file}_rendered
|
||||
if ! docker run --rm -v "$(pwd)/${file}:/app/arch_config.yaml:ro" -v "$(pwd)/${file}_rendered:/app/arch_config_rendered.yaml:rw" --entrypoint /bin/sh katanemo/plano:0.4.0 -c "python -m cli.config_generator" 2>&1 > /dev/null ; then
|
||||
if ! docker run --rm -v "$(pwd)/${file}:/app/arch_config.yaml:ro" -v "$(pwd)/${file}_rendered:/app/arch_config_rendered.yaml:rw" --entrypoint /bin/sh katanemo/plano:0.4.0 -c "python -m planoai.config_generator" 2>&1 > /dev/null ; then
|
||||
echo "Validation failed for $file"
|
||||
failed_files+=("$file")
|
||||
fi
|
||||
|
|
@ -3,7 +3,7 @@ use std::time::{Instant, SystemTime};
|
|||
|
||||
use bytes::Bytes;
|
||||
use common::consts::TRACE_PARENT_HEADER;
|
||||
use common::traces::{SpanBuilder, SpanKind, parse_traceparent, generate_random_span_id};
|
||||
use common::traces::{generate_random_span_id, parse_traceparent, SpanBuilder, SpanKind};
|
||||
use hermesllm::apis::OpenAIMessage;
|
||||
use hermesllm::clients::SupportedAPIsFromClient;
|
||||
use hermesllm::providers::request::ProviderRequest;
|
||||
|
|
@ -18,7 +18,7 @@ use super::agent_selector::{AgentSelectionError, AgentSelector};
|
|||
use super::pipeline_processor::{PipelineError, PipelineProcessor};
|
||||
use super::response_handler::ResponseHandler;
|
||||
use crate::router::plano_orchestrator::OrchestratorService;
|
||||
use crate::tracing::{OperationNameBuilder, operation_component, http};
|
||||
use crate::tracing::{http, operation_component, OperationNameBuilder};
|
||||
|
||||
/// Main errors for agent chat completions
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
|
@ -61,7 +61,6 @@ pub async fn agent_chat(
|
|||
body,
|
||||
}) = &err
|
||||
{
|
||||
|
||||
warn!(
|
||||
"Client error from agent '{}' (HTTP {}): {}",
|
||||
agent, status, body
|
||||
|
|
@ -77,8 +76,8 @@ pub async fn agent_chat(
|
|||
|
||||
let json_string = error_json.to_string();
|
||||
let mut response = Response::new(ResponseHandler::create_full_body(json_string));
|
||||
*response.status_mut() = hyper::StatusCode::from_u16(*status)
|
||||
.unwrap_or(hyper::StatusCode::BAD_REQUEST);
|
||||
*response.status_mut() =
|
||||
hyper::StatusCode::from_u16(*status).unwrap_or(hyper::StatusCode::BAD_REQUEST);
|
||||
response.headers_mut().insert(
|
||||
hyper::header::CONTENT_TYPE,
|
||||
"application/json".parse().unwrap(),
|
||||
|
|
@ -234,8 +233,18 @@ async fn handle_agent_chat(
|
|||
.with_attribute(http::TARGET, "/agents/select")
|
||||
.with_attribute("selection.listener", listener.name.clone())
|
||||
.with_attribute("selection.agent_count", selected_agents.len().to_string())
|
||||
.with_attribute("selection.agents", selected_agents.iter().map(|a| a.id.as_str()).collect::<Vec<_>>().join(","))
|
||||
.with_attribute("duration_ms", format!("{:.2}", selection_elapsed.as_secs_f64() * 1000.0));
|
||||
.with_attribute(
|
||||
"selection.agents",
|
||||
selected_agents
|
||||
.iter()
|
||||
.map(|a| a.id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
)
|
||||
.with_attribute(
|
||||
"duration_ms",
|
||||
format!("{:.2}", selection_elapsed.as_secs_f64() * 1000.0),
|
||||
);
|
||||
|
||||
if !trace_id.is_empty() {
|
||||
selection_span_builder = selection_span_builder.with_trace_id(trace_id.clone());
|
||||
|
|
@ -318,8 +327,14 @@ async fn handle_agent_chat(
|
|||
.with_attribute(http::METHOD, "POST")
|
||||
.with_attribute(http::TARGET, full_path)
|
||||
.with_attribute("agent.name", agent_name.clone())
|
||||
.with_attribute("agent.sequence", format!("{}/{}", agent_index + 1, agent_count))
|
||||
.with_attribute("duration_ms", format!("{:.2}", agent_elapsed.as_secs_f64() * 1000.0));
|
||||
.with_attribute(
|
||||
"agent.sequence",
|
||||
format!("{}/{}", agent_index + 1, agent_count),
|
||||
)
|
||||
.with_attribute(
|
||||
"duration_ms",
|
||||
format!("{:.2}", agent_elapsed.as_secs_f64() * 1000.0),
|
||||
);
|
||||
|
||||
if !trace_id.is_empty() {
|
||||
span_builder = span_builder.with_trace_id(trace_id.clone());
|
||||
|
|
@ -333,7 +348,10 @@ async fn handle_agent_chat(
|
|||
|
||||
// If this is the last agent, return the streaming response
|
||||
if is_last_agent {
|
||||
info!("Completed agent chain, returning response from last agent: {}", agent_name);
|
||||
info!(
|
||||
"Completed agent chain, returning response from last agent: {}",
|
||||
agent_name
|
||||
);
|
||||
return response_handler
|
||||
.create_streaming_response(llm_response)
|
||||
.await
|
||||
|
|
@ -341,7 +359,10 @@ async fn handle_agent_chat(
|
|||
}
|
||||
|
||||
// For intermediate agents, collect the full response and pass to next agent
|
||||
debug!("Collecting response from intermediate agent: {}", agent_name);
|
||||
debug!(
|
||||
"Collecting response from intermediate agent: {}",
|
||||
agent_name
|
||||
);
|
||||
let response_text = response_handler.collect_full_response(llm_response).await?;
|
||||
|
||||
info!(
|
||||
|
|
@ -364,7 +385,6 @@ async fn handle_agent_chat(
|
|||
});
|
||||
|
||||
current_messages.push(last_message);
|
||||
|
||||
}
|
||||
|
||||
// This should never be reached since we return in the last agent iteration
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use std::collections::HashMap;
|
|||
use std::sync::Arc;
|
||||
|
||||
use common::configuration::{
|
||||
Agent, AgentFilterChain, Listener, AgentUsagePreference, OrchestrationPreference,
|
||||
Agent, AgentFilterChain, AgentUsagePreference, Listener, OrchestrationPreference,
|
||||
};
|
||||
use hermesllm::apis::openai::Message;
|
||||
use tracing::{debug, warn};
|
||||
|
|
|
|||
|
|
@ -1,20 +1,18 @@
|
|||
use bytes::Bytes;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::StreamExt;
|
||||
use hermesllm::apis::openai::{
|
||||
ChatCompletionsRequest, ChatCompletionsResponse, Choice, FinishReason, FunctionCall, Message,
|
||||
MessageContent, ResponseMessage, Role, Tool, ToolCall, Usage,
|
||||
};
|
||||
use http_body_util::{combinators::BoxBody, BodyExt, Full};
|
||||
use hyper::body::Incoming;
|
||||
use hyper::{Request, Response, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use thiserror::Error;
|
||||
use tracing::{info, error};
|
||||
use futures::StreamExt;
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{combinators::BoxBody, BodyExt, Full};
|
||||
use hyper::body::Incoming;
|
||||
use hyper::{Request, Response, StatusCode};
|
||||
use eventsource_stream::Eventsource;
|
||||
|
||||
|
||||
use tracing::{error, info};
|
||||
|
||||
// ============================================================================
|
||||
// CONSTANTS FOR HALLUCINATION DETECTION
|
||||
|
|
@ -273,17 +271,14 @@ impl ArchFunctionHandler {
|
|||
let mut stack: Vec<char> = Vec::new();
|
||||
let mut fixed_str = String::new();
|
||||
|
||||
let matching_bracket: HashMap<char, char> =
|
||||
[(')', '('), ('}', '{'), (']', '[')]
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let opening_bracket: HashMap<char, char> = matching_bracket
|
||||
let matching_bracket: HashMap<char, char> = [(')', '('), ('}', '{'), (']', '[')]
|
||||
.iter()
|
||||
.map(|(k, v)| (*v, *k))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let opening_bracket: HashMap<char, char> =
|
||||
matching_bracket.iter().map(|(k, v)| (*v, *k)).collect();
|
||||
|
||||
for ch in json_str.chars() {
|
||||
if ch == '{' || ch == '[' || ch == '(' {
|
||||
stack.push(ch);
|
||||
|
|
@ -332,12 +327,18 @@ impl ArchFunctionHandler {
|
|||
// Remove markdown code blocks
|
||||
let mut content = content.trim().to_string();
|
||||
if content.starts_with("```") && content.ends_with("```") {
|
||||
content = content.trim_start_matches("```").trim_end_matches("```").to_string();
|
||||
content = content
|
||||
.trim_start_matches("```")
|
||||
.trim_end_matches("```")
|
||||
.to_string();
|
||||
if content.starts_with("json") {
|
||||
content = content.trim_start_matches("json").to_string();
|
||||
}
|
||||
// Trim again after removing code blocks to eliminate internal whitespace
|
||||
content = content.trim_start_matches(r"\n").trim_end_matches(r"\n").to_string();
|
||||
content = content
|
||||
.trim_start_matches(r"\n")
|
||||
.trim_end_matches(r"\n")
|
||||
.to_string();
|
||||
content = content.trim().to_string();
|
||||
// Unescape the quotes: \" -> "
|
||||
// The model sometimes returns escaped JSON inside markdown blocks
|
||||
|
|
@ -453,12 +454,12 @@ impl ArchFunctionHandler {
|
|||
/// Helper method to check if a value matches the expected type
|
||||
fn check_value_type(&self, value: &Value, target_type: &str) -> bool {
|
||||
match target_type {
|
||||
"int" | "integer" => value.is_i64() || value.is_u64(),
|
||||
"int" | "integer" => value.is_i64() || value.is_u64(),
|
||||
"float" | "number" => value.is_f64() || value.is_i64() || value.is_u64(),
|
||||
"bool" | "boolean" => value.is_boolean(),
|
||||
"str" | "string" => value.is_string(),
|
||||
"list" | "array" => value.is_array(),
|
||||
"dict" | "object" => value.is_object(),
|
||||
"bool" | "boolean" => value.is_boolean(),
|
||||
"str" | "string" => value.is_string(),
|
||||
"list" | "array" => value.is_array(),
|
||||
"dict" | "object" => value.is_object(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
|
@ -505,15 +506,19 @@ impl ArchFunctionHandler {
|
|||
let func_name = &tool_call.function.name;
|
||||
|
||||
// Parse arguments as JSON
|
||||
let func_args: HashMap<String, Value> = match serde_json::from_str(&tool_call.function.arguments) {
|
||||
Ok(args) => args,
|
||||
Err(e) => {
|
||||
verification.is_valid = false;
|
||||
verification.invalid_tool_call = Some(tool_call.clone());
|
||||
verification.error_message = format!("Failed to parse arguments for function '{}': {}", func_name, e);
|
||||
break;
|
||||
}
|
||||
};
|
||||
let func_args: HashMap<String, Value> =
|
||||
match serde_json::from_str(&tool_call.function.arguments) {
|
||||
Ok(args) => args,
|
||||
Err(e) => {
|
||||
verification.is_valid = false;
|
||||
verification.invalid_tool_call = Some(tool_call.clone());
|
||||
verification.error_message = format!(
|
||||
"Failed to parse arguments for function '{}': {}",
|
||||
func_name, e
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if function is available
|
||||
if let Some(function_params) = functions.get(func_name) {
|
||||
|
|
@ -541,14 +546,23 @@ impl ArchFunctionHandler {
|
|||
if let Some(properties_obj) = properties.as_object() {
|
||||
for (param_name, param_value) in &func_args {
|
||||
if let Some(param_schema) = properties_obj.get(param_name) {
|
||||
if let Some(target_type) = param_schema.get("type").and_then(|v| v.as_str()) {
|
||||
if self.config.support_data_types.contains(&target_type.to_string()) {
|
||||
if let Some(target_type) =
|
||||
param_schema.get("type").and_then(|v| v.as_str())
|
||||
{
|
||||
if self
|
||||
.config
|
||||
.support_data_types
|
||||
.contains(&target_type.to_string())
|
||||
{
|
||||
// Validate data type using helper method
|
||||
match self.validate_or_convert_parameter(param_value, target_type) {
|
||||
match self
|
||||
.validate_or_convert_parameter(param_value, target_type)
|
||||
{
|
||||
Ok(is_valid) => {
|
||||
if !is_valid {
|
||||
verification.is_valid = false;
|
||||
verification.invalid_tool_call = Some(tool_call.clone());
|
||||
verification.invalid_tool_call =
|
||||
Some(tool_call.clone());
|
||||
verification.error_message = format!(
|
||||
"Parameter `{}` is expected to have the data type `{}`, got incompatible type.",
|
||||
param_name, target_type
|
||||
|
|
@ -558,7 +572,8 @@ impl ArchFunctionHandler {
|
|||
}
|
||||
Err(_) => {
|
||||
verification.is_valid = false;
|
||||
verification.invalid_tool_call = Some(tool_call.clone());
|
||||
verification.invalid_tool_call =
|
||||
Some(tool_call.clone());
|
||||
verification.error_message = format!(
|
||||
"Parameter `{}` is expected to have the data type `{}`, got incompatible type.",
|
||||
param_name, target_type
|
||||
|
|
@ -569,7 +584,10 @@ impl ArchFunctionHandler {
|
|||
} else {
|
||||
verification.is_valid = false;
|
||||
verification.invalid_tool_call = Some(tool_call.clone());
|
||||
verification.error_message = format!("Data type `{}` is not supported.", target_type);
|
||||
verification.error_message = format!(
|
||||
"Data type `{}` is not supported.",
|
||||
target_type
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -598,11 +616,8 @@ impl ArchFunctionHandler {
|
|||
/// Formats the system prompt with tools
|
||||
pub fn format_system_prompt(&self, tools: &[Tool]) -> Result<String> {
|
||||
let tools_str = self.convert_tools(tools)?;
|
||||
let system_prompt = self
|
||||
.config
|
||||
.task_prompt
|
||||
.replace("{tools}", &tools_str)
|
||||
+ &self.config.format_prompt;
|
||||
let system_prompt =
|
||||
self.config.task_prompt.replace("{tools}", &tools_str) + &self.config.format_prompt;
|
||||
|
||||
Ok(system_prompt)
|
||||
}
|
||||
|
|
@ -665,15 +680,22 @@ impl ArchFunctionHandler {
|
|||
|
||||
// Strip markdown code blocks
|
||||
if tool_call_msg.starts_with("```") && tool_call_msg.ends_with("```") {
|
||||
tool_call_msg = tool_call_msg.trim_start_matches("```").trim_end_matches("```").trim().to_string();
|
||||
tool_call_msg = tool_call_msg
|
||||
.trim_start_matches("```")
|
||||
.trim_end_matches("```")
|
||||
.trim()
|
||||
.to_string();
|
||||
if tool_call_msg.starts_with("json") {
|
||||
tool_call_msg = tool_call_msg.trim_start_matches("json").trim().to_string();
|
||||
tool_call_msg =
|
||||
tool_call_msg.trim_start_matches("json").trim().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Extract function name
|
||||
if let Ok(parsed) = serde_json::from_str::<Value>(&tool_call_msg) {
|
||||
if let Some(tool_calls_arr) = parsed.get("tool_calls").and_then(|v| v.as_array()) {
|
||||
if let Some(tool_calls_arr) =
|
||||
parsed.get("tool_calls").and_then(|v| v.as_array())
|
||||
{
|
||||
if let Some(first_tool_call) = tool_calls_arr.first() {
|
||||
let func_name = first_tool_call
|
||||
.get("name")
|
||||
|
|
@ -685,8 +707,10 @@ impl ArchFunctionHandler {
|
|||
"result": content,
|
||||
});
|
||||
|
||||
content = format!("<tool_response>\n{}\n</tool_response>",
|
||||
serde_json::to_string(&tool_response)?);
|
||||
content = format!(
|
||||
"<tool_response>\n{}\n</tool_response>",
|
||||
serde_json::to_string(&tool_response)?
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -717,7 +741,7 @@ impl ArchFunctionHandler {
|
|||
if let Some(instruction) = extra_instruction {
|
||||
if let Some(last) = processed_messages.last_mut() {
|
||||
if let MessageContent::Text(content) = &mut last.content {
|
||||
content.push_str("\n");
|
||||
content.push('\n');
|
||||
content.push_str(instruction);
|
||||
}
|
||||
}
|
||||
|
|
@ -750,13 +774,11 @@ impl ArchFunctionHandler {
|
|||
for i in (conversation_idx..messages.len()).rev() {
|
||||
if let MessageContent::Text(content) = &messages[i].content {
|
||||
num_tokens += content.len() / 4;
|
||||
if num_tokens >= max_tokens {
|
||||
if messages[i].role == Role::User {
|
||||
// Set message_idx to current position and break
|
||||
// This matches Python's behavior where message_idx is set before break
|
||||
message_idx = i;
|
||||
break;
|
||||
}
|
||||
if num_tokens >= max_tokens && messages[i].role == Role::User {
|
||||
// Set message_idx to current position and break
|
||||
// This matches Python's behavior where message_idx is set before break
|
||||
message_idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Only update message_idx if we haven't hit the token limit yet
|
||||
|
|
@ -789,7 +811,11 @@ impl ArchFunctionHandler {
|
|||
}
|
||||
|
||||
/// Helper to create a request with VLLM-specific parameters
|
||||
fn create_request_with_extra_body(&self, messages: Vec<Message>, stream: bool) -> ChatCompletionsRequest {
|
||||
fn create_request_with_extra_body(
|
||||
&self,
|
||||
messages: Vec<Message>,
|
||||
stream: bool,
|
||||
) -> ChatCompletionsRequest {
|
||||
ChatCompletionsRequest {
|
||||
model: self.model_name.clone(),
|
||||
messages,
|
||||
|
|
@ -813,24 +839,38 @@ impl ArchFunctionHandler {
|
|||
}
|
||||
|
||||
/// Makes a streaming request and returns the SSE event stream
|
||||
async fn make_streaming_request(&self, request: ChatCompletionsRequest) -> Result<std::pin::Pin<Box<dyn futures::Stream<Item = std::result::Result<Value, String>> + Send>>> {
|
||||
let request_body = serde_json::to_string(&request)
|
||||
.map_err(|e| FunctionCallingError::InvalidModelResponse(format!("Failed to serialize request: {}", e)))?;
|
||||
async fn make_streaming_request(
|
||||
&self,
|
||||
request: ChatCompletionsRequest,
|
||||
) -> Result<
|
||||
std::pin::Pin<Box<dyn futures::Stream<Item = std::result::Result<Value, String>> + Send>>,
|
||||
> {
|
||||
let request_body = serde_json::to_string(&request).map_err(|e| {
|
||||
FunctionCallingError::InvalidModelResponse(format!(
|
||||
"Failed to serialize request: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
let response = self.http_client
|
||||
let response = self
|
||||
.http_client
|
||||
.post(&self.endpoint_url)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| FunctionCallingError::HttpError(e))?;
|
||||
.map_err(FunctionCallingError::HttpError)?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(FunctionCallingError::InvalidModelResponse(
|
||||
format!("HTTP error {}: {}", status, error_text)
|
||||
));
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(FunctionCallingError::InvalidModelResponse(format!(
|
||||
"HTTP error {}: {}",
|
||||
status, error_text
|
||||
)));
|
||||
}
|
||||
|
||||
// Parse SSE stream
|
||||
|
|
@ -856,38 +896,51 @@ impl ArchFunctionHandler {
|
|||
}
|
||||
|
||||
/// Makes a non-streaming request and returns the response
|
||||
async fn make_non_streaming_request(&self, request: ChatCompletionsRequest) -> Result<ChatCompletionsResponse> {
|
||||
let request_body = serde_json::to_string(&request)
|
||||
.map_err(|e| FunctionCallingError::InvalidModelResponse(format!("Failed to serialize request: {}", e)))?;
|
||||
async fn make_non_streaming_request(
|
||||
&self,
|
||||
request: ChatCompletionsRequest,
|
||||
) -> Result<ChatCompletionsResponse> {
|
||||
let request_body = serde_json::to_string(&request).map_err(|e| {
|
||||
FunctionCallingError::InvalidModelResponse(format!(
|
||||
"Failed to serialize request: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
let response = self.http_client
|
||||
let response = self
|
||||
.http_client
|
||||
.post(&self.endpoint_url)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| FunctionCallingError::HttpError(e))?;
|
||||
.map_err(FunctionCallingError::HttpError)?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(FunctionCallingError::InvalidModelResponse(
|
||||
format!("HTTP error {}: {}", status, error_text)
|
||||
));
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(FunctionCallingError::InvalidModelResponse(format!(
|
||||
"HTTP error {}: {}",
|
||||
status, error_text
|
||||
)));
|
||||
}
|
||||
|
||||
let response_text = response.text().await
|
||||
.map_err(|e| FunctionCallingError::HttpError(e))?;
|
||||
let response_text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(FunctionCallingError::HttpError)?;
|
||||
|
||||
serde_json::from_str(&response_text)
|
||||
.map_err(|e| FunctionCallingError::JsonParseError(e))
|
||||
serde_json::from_str(&response_text).map_err(FunctionCallingError::JsonParseError)
|
||||
}
|
||||
|
||||
pub async fn function_calling_chat(
|
||||
&self,
|
||||
request: ChatCompletionsRequest,
|
||||
) -> Result<ChatCompletionsResponse> {
|
||||
use tracing::{info, error};
|
||||
use tracing::{error, info};
|
||||
|
||||
info!("[Arch-Function] - ChatCompletion");
|
||||
|
||||
|
|
@ -899,10 +952,14 @@ impl ArchFunctionHandler {
|
|||
request.metadata.as_ref(),
|
||||
)?;
|
||||
|
||||
info!("[request to arch-fc]: model: {}, messages count: {}",
|
||||
self.model_name, messages.len());
|
||||
info!(
|
||||
"[request to arch-fc]: model: {}, messages count: {}",
|
||||
self.model_name,
|
||||
messages.len()
|
||||
);
|
||||
|
||||
let use_agent_orchestrator = request.metadata
|
||||
let use_agent_orchestrator = request
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("use_agent_orchestrator"))
|
||||
.and_then(|v| v.as_bool())
|
||||
|
|
@ -918,89 +975,95 @@ impl ArchFunctionHandler {
|
|||
|
||||
if use_agent_orchestrator {
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
let chunk = chunk_result.map_err(|e| FunctionCallingError::InvalidModelResponse(e))?;
|
||||
let chunk = chunk_result.map_err(FunctionCallingError::InvalidModelResponse)?;
|
||||
// Extract content from JSON response
|
||||
if let Some(choices) = chunk.get("choices").and_then(|v| v.as_array()) {
|
||||
if let Some(choice) = choices.first() {
|
||||
if let Some(content) = choice.get("delta")
|
||||
if let Some(content) = choice
|
||||
.get("delta")
|
||||
.and_then(|d| d.get("content"))
|
||||
.and_then(|c| c.as_str()) {
|
||||
.and_then(|c| c.as_str())
|
||||
{
|
||||
model_response.push_str(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
info!("[Agent Orchestrator]: response received");
|
||||
} else {
|
||||
if let Some(tools) = request.tools.as_ref() {
|
||||
let mut hallucination_state = HallucinationState::new(tools);
|
||||
let mut has_tool_calls = None;
|
||||
let mut has_hallucination = false;
|
||||
} else if let Some(tools) = request.tools.as_ref() {
|
||||
let mut hallucination_state = HallucinationState::new(tools);
|
||||
let mut has_tool_calls = None;
|
||||
let mut has_hallucination = false;
|
||||
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
let chunk = chunk_result.map_err(|e| FunctionCallingError::InvalidModelResponse(e))?;
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
let chunk = chunk_result.map_err(FunctionCallingError::InvalidModelResponse)?;
|
||||
|
||||
// Extract content and logprobs from JSON response
|
||||
if let Some(choices) = chunk.get("choices").and_then(|v| v.as_array()) {
|
||||
if let Some(choice) = choices.first() {
|
||||
if let Some(content) = choice.get("delta")
|
||||
.and_then(|d| d.get("content"))
|
||||
.and_then(|c| c.as_str()) {
|
||||
// Extract content and logprobs from JSON response
|
||||
if let Some(choices) = chunk.get("choices").and_then(|v| v.as_array()) {
|
||||
if let Some(choice) = choices.first() {
|
||||
if let Some(content) = choice
|
||||
.get("delta")
|
||||
.and_then(|d| d.get("content"))
|
||||
.and_then(|c| c.as_str())
|
||||
{
|
||||
// Extract logprobs
|
||||
let logprobs: Vec<f64> = choice
|
||||
.get("logprobs")
|
||||
.and_then(|lp| lp.get("content"))
|
||||
.and_then(|c| c.as_array())
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|token| token.get("top_logprobs"))
|
||||
.and_then(|tlp| tlp.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.get("logprob").and_then(|lp| lp.as_f64()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Extract logprobs
|
||||
let logprobs: Vec<f64> = choice.get("logprobs")
|
||||
.and_then(|lp| lp.get("content"))
|
||||
.and_then(|c| c.as_array())
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|token| token.get("top_logprobs"))
|
||||
.and_then(|tlp| tlp.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.get("logprob").and_then(|lp| lp.as_f64()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if hallucination_state
|
||||
.append_and_check_token_hallucination(content.to_string(), logprobs)
|
||||
{
|
||||
has_hallucination = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if hallucination_state.append_and_check_token_hallucination(content.to_string(), logprobs) {
|
||||
has_hallucination = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if hallucination_state.tokens.len() > 5 && has_tool_calls.is_none() {
|
||||
let collected_content = hallucination_state.tokens.join("");
|
||||
has_tool_calls = Some(collected_content.contains("tool_calls"));
|
||||
}
|
||||
if hallucination_state.tokens.len() > 5 && has_tool_calls.is_none() {
|
||||
let collected_content = hallucination_state.tokens.join("");
|
||||
has_tool_calls = Some(collected_content.contains("tool_calls"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if has_tool_calls == Some(true) && has_hallucination {
|
||||
info!("[Hallucination]: {}", hallucination_state.error_message);
|
||||
if has_tool_calls == Some(true) && has_hallucination {
|
||||
info!("[Hallucination]: {}", hallucination_state.error_message);
|
||||
|
||||
let clarify_messages = self.prefill_message(messages.clone(), &self.clarify_prefix);
|
||||
let clarify_request = self.create_request_with_extra_body(clarify_messages, false);
|
||||
let clarify_messages = self.prefill_message(messages.clone(), &self.clarify_prefix);
|
||||
let clarify_request = self.create_request_with_extra_body(clarify_messages, false);
|
||||
|
||||
let retry_response = self.make_non_streaming_request(clarify_request).await?;
|
||||
let retry_response = self.make_non_streaming_request(clarify_request).await?;
|
||||
|
||||
if let Some(choice) = retry_response.choices.first() {
|
||||
if let Some(content) = &choice.message.content {
|
||||
model_response = content.clone();
|
||||
}
|
||||
if let Some(choice) = retry_response.choices.first() {
|
||||
if let Some(content) = &choice.message.content {
|
||||
model_response = content.clone();
|
||||
}
|
||||
} else {
|
||||
model_response = hallucination_state.tokens.join("");
|
||||
}
|
||||
} else {
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
let chunk = chunk_result.map_err(|e| FunctionCallingError::InvalidModelResponse(e))?;
|
||||
if let Some(choices) = chunk.get("choices").and_then(|v| v.as_array()) {
|
||||
if let Some(choice) = choices.first() {
|
||||
if let Some(content) = choice.get("delta")
|
||||
.and_then(|d| d.get("content"))
|
||||
.and_then(|c| c.as_str()) {
|
||||
model_response.push_str(content);
|
||||
}
|
||||
model_response = hallucination_state.tokens.join("");
|
||||
}
|
||||
} else {
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
let chunk = chunk_result.map_err(FunctionCallingError::InvalidModelResponse)?;
|
||||
if let Some(choices) = chunk.get("choices").and_then(|v| v.as_array()) {
|
||||
if let Some(choice) = choices.first() {
|
||||
if let Some(content) = choice
|
||||
.get("delta")
|
||||
.and_then(|d| d.get("content"))
|
||||
.and_then(|c| c.as_str())
|
||||
{
|
||||
model_response.push_str(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1009,10 +1072,17 @@ impl ArchFunctionHandler {
|
|||
|
||||
let response_dict = self.parse_model_response(&model_response);
|
||||
|
||||
info!("[arch-fc]: raw model response: {}", response_dict.raw_response);
|
||||
info!(
|
||||
"[arch-fc]: raw model response: {}",
|
||||
response_dict.raw_response
|
||||
);
|
||||
|
||||
// General model response (no intent matched - should route to default target)
|
||||
let model_message = if response_dict.response.as_ref().map_or(false, |s| !s.is_empty()) {
|
||||
let model_message = if response_dict
|
||||
.response
|
||||
.as_ref()
|
||||
.is_some_and(|s| !s.is_empty())
|
||||
{
|
||||
// When arch-fc returns a "response" field, it means no intent was matched
|
||||
// Return empty content and empty tool_calls so prompt_gateway routes to default target
|
||||
ResponseMessage {
|
||||
|
|
@ -1053,8 +1123,11 @@ impl ArchFunctionHandler {
|
|||
let verification = self.verify_tool_calls(tools, &response_dict.tool_calls);
|
||||
|
||||
if verification.is_valid {
|
||||
info!("[Tool calls]: {:?}",
|
||||
response_dict.tool_calls.iter()
|
||||
info!(
|
||||
"[Tool calls]: {:?}",
|
||||
response_dict
|
||||
.tool_calls
|
||||
.iter()
|
||||
.map(|tc| &tc.function)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
|
@ -1092,8 +1165,11 @@ impl ArchFunctionHandler {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
info!("[Tool calls]: {:?}",
|
||||
response_dict.tool_calls.iter()
|
||||
info!(
|
||||
"[Tool calls]: {:?}",
|
||||
response_dict
|
||||
.tool_calls
|
||||
.iter()
|
||||
.map(|tc| &tc.function)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
|
@ -1108,7 +1184,10 @@ impl ArchFunctionHandler {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
error!("Invalid tool calls in response: {}", response_dict.error_message);
|
||||
error!(
|
||||
"Invalid tool calls in response: {}",
|
||||
response_dict.error_message
|
||||
);
|
||||
ResponseMessage {
|
||||
role: Role::Assistant,
|
||||
content: Some(String::new()),
|
||||
|
|
@ -1243,7 +1322,6 @@ pub async fn function_calling_chat_handler(
|
|||
req: Request<Incoming>,
|
||||
llm_provider_url: String,
|
||||
) -> std::result::Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
|
||||
|
||||
use hermesllm::apis::openai::ChatCompletionsRequest;
|
||||
let whole_body = req.collect().await?.to_bytes();
|
||||
|
||||
|
|
@ -1255,10 +1333,13 @@ pub async fn function_calling_chat_handler(
|
|||
let mut response = Response::new(full(
|
||||
serde_json::json!({
|
||||
"error": format!("Invalid request body: {}", e)
|
||||
}).to_string()
|
||||
})
|
||||
.to_string(),
|
||||
));
|
||||
*response.status_mut() = StatusCode::BAD_REQUEST;
|
||||
response.headers_mut().insert("Content-Type", "application/json".parse().unwrap());
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("Content-Type", "application/json".parse().unwrap());
|
||||
return Ok(response);
|
||||
}
|
||||
};
|
||||
|
|
@ -1271,24 +1352,31 @@ pub async fn function_calling_chat_handler(
|
|||
// Parse as ChatCompletionsRequest
|
||||
let chat_request: ChatCompletionsRequest = match serde_json::from_value(body_json) {
|
||||
Ok(req) => {
|
||||
info!("[request body]: {}", serde_json::to_string(&req).unwrap_or_default());
|
||||
info!(
|
||||
"[request body]: {}",
|
||||
serde_json::to_string(&req).unwrap_or_default()
|
||||
);
|
||||
req
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to parse request body: {}", e);
|
||||
let mut response = Response::new(full(
|
||||
serde_json::json!({
|
||||
"error": format!("Invalid request body: {}", e)
|
||||
}).to_string()
|
||||
})
|
||||
.to_string(),
|
||||
));
|
||||
*response.status_mut() = StatusCode::BAD_REQUEST;
|
||||
response.headers_mut().insert("Content-Type", "application/json".parse().unwrap());
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("Content-Type", "application/json".parse().unwrap());
|
||||
return Ok(response);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine which handler to use based on metadata
|
||||
let use_agent_orchestrator = chat_request.metadata
|
||||
let use_agent_orchestrator = chat_request
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("use_agent_orchestrator"))
|
||||
.and_then(|v| v.as_bool())
|
||||
|
|
@ -1309,7 +1397,10 @@ pub async fn function_calling_chat_handler(
|
|||
ARCH_FUNCTION_MODEL_NAME.to_string(),
|
||||
llm_provider_url.clone(),
|
||||
);
|
||||
handler.function_handler.function_calling_chat(chat_request).await
|
||||
handler
|
||||
.function_handler
|
||||
.function_calling_chat(chat_request)
|
||||
.await
|
||||
} else {
|
||||
let handler = ArchFunctionHandler::new(
|
||||
ARCH_FUNCTION_MODEL_NAME.to_string(),
|
||||
|
|
@ -1328,7 +1419,9 @@ pub async fn function_calling_chat_handler(
|
|||
|
||||
let mut response = Response::new(full(response_json));
|
||||
*response.status_mut() = StatusCode::OK;
|
||||
response.headers_mut().insert("Content-Type", "application/json".parse().unwrap());
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("Content-Type", "application/json".parse().unwrap());
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
|
@ -1341,13 +1434,14 @@ pub async fn function_calling_chat_handler(
|
|||
|
||||
let mut response = Response::new(full(error_response.to_string()));
|
||||
*response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
|
||||
response.headers_mut().insert("Content-Type", "application/json".parse().unwrap());
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("Content-Type", "application/json".parse().unwrap());
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// TESTS
|
||||
// ============================================================================
|
||||
|
|
@ -1370,10 +1464,13 @@ mod tests {
|
|||
assert!(config.task_prompt.contains("</tools>\\n\\n"));
|
||||
|
||||
// Format prompt should contain literal escaped newlines and proper JSON examples
|
||||
assert!(config.format_prompt.contains("\\n\\nBased on your analysis"));
|
||||
assert!(config.format_prompt.contains(r#"{\"response\": \"Your response text here\"}"#));
|
||||
assert!(config
|
||||
.format_prompt
|
||||
.contains("\\n\\nBased on your analysis"));
|
||||
assert!(config
|
||||
.format_prompt
|
||||
.contains(r#"{\"response\": \"Your response text here\"}"#));
|
||||
assert!(config.format_prompt.contains(r#"{\"tool_calls\": [{"#));
|
||||
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1384,7 +1481,11 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_fix_json_string_valid() {
|
||||
let handler = ArchFunctionHandler::new("test-model".to_string(), ArchFunctionConfig::default(), "http://localhost:8000".to_string());
|
||||
let handler = ArchFunctionHandler::new(
|
||||
"test-model".to_string(),
|
||||
ArchFunctionConfig::default(),
|
||||
"http://localhost:8000".to_string(),
|
||||
);
|
||||
let json_str = r#"{"name": "test", "value": 123}"#;
|
||||
let result = handler.fix_json_string(json_str);
|
||||
assert!(result.is_ok());
|
||||
|
|
@ -1392,7 +1493,11 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_fix_json_string_missing_bracket() {
|
||||
let handler = ArchFunctionHandler::new("test-model".to_string(), ArchFunctionConfig::default(), "http://localhost:8000".to_string());
|
||||
let handler = ArchFunctionHandler::new(
|
||||
"test-model".to_string(),
|
||||
ArchFunctionConfig::default(),
|
||||
"http://localhost:8000".to_string(),
|
||||
);
|
||||
let json_str = r#"{"name": "test", "value": 123"#;
|
||||
let result = handler.fix_json_string(json_str);
|
||||
assert!(result.is_ok());
|
||||
|
|
@ -1402,8 +1507,13 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_parse_model_response_with_tool_calls() {
|
||||
let handler = ArchFunctionHandler::new("test-model".to_string(), ArchFunctionConfig::default(), "http://localhost:8000".to_string());
|
||||
let content = r#"{"tool_calls": [{"name": "get_weather", "arguments": {"location": "NYC"}}]}"#;
|
||||
let handler = ArchFunctionHandler::new(
|
||||
"test-model".to_string(),
|
||||
ArchFunctionConfig::default(),
|
||||
"http://localhost:8000".to_string(),
|
||||
);
|
||||
let content =
|
||||
r#"{"tool_calls": [{"name": "get_weather", "arguments": {"location": "NYC"}}]}"#;
|
||||
let result = handler.parse_model_response(content);
|
||||
|
||||
assert!(result.is_valid);
|
||||
|
|
@ -1413,8 +1523,13 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_parse_model_response_with_clarification() {
|
||||
let handler = ArchFunctionHandler::new("test-model".to_string(), ArchFunctionConfig::default(), "http://localhost:8000".to_string());
|
||||
let content = r#"{"required_functions": ["get_weather"], "clarification": "What location?"}"#;
|
||||
let handler = ArchFunctionHandler::new(
|
||||
"test-model".to_string(),
|
||||
ArchFunctionConfig::default(),
|
||||
"http://localhost:8000".to_string(),
|
||||
);
|
||||
let content =
|
||||
r#"{"required_functions": ["get_weather"], "clarification": "What location?"}"#;
|
||||
let result = handler.parse_model_response(content);
|
||||
|
||||
assert!(result.is_valid);
|
||||
|
|
@ -1424,7 +1539,11 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_convert_data_type_int_to_float() {
|
||||
let handler = ArchFunctionHandler::new("test-model".to_string(), ArchFunctionConfig::default(), "http://localhost:8000".to_string());
|
||||
let handler = ArchFunctionHandler::new(
|
||||
"test-model".to_string(),
|
||||
ArchFunctionConfig::default(),
|
||||
"http://localhost:8000".to_string(),
|
||||
);
|
||||
let value = json!(42);
|
||||
let result = handler.convert_data_type(&value, "float");
|
||||
assert!(result.is_ok());
|
||||
|
|
@ -1504,13 +1623,12 @@ pub fn check_threshold(
|
|||
}
|
||||
|
||||
/// Checks if a parameter is required in the function description
|
||||
pub fn is_parameter_required(
|
||||
function_description: &Value,
|
||||
parameter_name: &str,
|
||||
) -> bool {
|
||||
pub fn is_parameter_required(function_description: &Value, parameter_name: &str) -> bool {
|
||||
if let Some(required) = function_description.get("required") {
|
||||
if let Some(required_arr) = required.as_array() {
|
||||
return required_arr.iter().any(|v| v.as_str() == Some(parameter_name));
|
||||
return required_arr
|
||||
.iter()
|
||||
.any(|v| v.as_str() == Some(parameter_name));
|
||||
}
|
||||
}
|
||||
false
|
||||
|
|
@ -1559,12 +1677,7 @@ impl HallucinationState {
|
|||
pub fn new(functions: &[Tool]) -> Self {
|
||||
let function_properties: HashMap<String, Value> = functions
|
||||
.iter()
|
||||
.map(|tool| {
|
||||
(
|
||||
tool.function.name.clone(),
|
||||
tool.function.parameters.clone(),
|
||||
)
|
||||
})
|
||||
.map(|tool| (tool.function.name.clone(), tool.function.parameters.clone()))
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
|
|
@ -1620,7 +1733,10 @@ impl HallucinationState {
|
|||
|
||||
// Function name extraction logic
|
||||
if self.state.as_deref() == Some("function_name") {
|
||||
if !FUNC_NAME_END_TOKEN.iter().any(|&t| self.tokens.last().map_or(false, |tok| tok == t)) {
|
||||
if !FUNC_NAME_END_TOKEN
|
||||
.iter()
|
||||
.any(|&t| self.tokens.last().is_some_and(|tok| tok == t))
|
||||
{
|
||||
self.mask.push(MaskToken::FunctionName);
|
||||
} else {
|
||||
self.state = None;
|
||||
|
|
@ -1629,34 +1745,51 @@ impl HallucinationState {
|
|||
}
|
||||
|
||||
// Check for function name start
|
||||
if FUNC_NAME_START_PATTERN.iter().any(|&p| content.ends_with(p)) {
|
||||
if FUNC_NAME_START_PATTERN
|
||||
.iter()
|
||||
.any(|&p| content.ends_with(p))
|
||||
{
|
||||
self.state = Some("function_name".to_string());
|
||||
}
|
||||
|
||||
// Parameter name extraction logic
|
||||
if self.state.as_deref() == Some("parameter_name")
|
||||
&& !PARAMETER_NAME_END_TOKENS.iter().any(|&t| content.ends_with(t)) {
|
||||
&& !PARAMETER_NAME_END_TOKENS
|
||||
.iter()
|
||||
.any(|&t| content.ends_with(t))
|
||||
{
|
||||
self.mask.push(MaskToken::ParameterName);
|
||||
} else if self.state.as_deref() == Some("parameter_name")
|
||||
&& PARAMETER_NAME_END_TOKENS.iter().any(|&t| content.ends_with(t)) {
|
||||
&& PARAMETER_NAME_END_TOKENS
|
||||
.iter()
|
||||
.any(|&t| content.ends_with(t))
|
||||
{
|
||||
self.state = None;
|
||||
self.parameter_name_done = true;
|
||||
self.get_parameter_name();
|
||||
} else if self.parameter_name_done
|
||||
&& !self.open_bracket
|
||||
&& PARAMETER_NAME_START_PATTERN.iter().any(|&p| content.ends_with(p)) {
|
||||
&& PARAMETER_NAME_START_PATTERN
|
||||
.iter()
|
||||
.any(|&p| content.ends_with(p))
|
||||
{
|
||||
self.state = Some("parameter_name".to_string());
|
||||
}
|
||||
|
||||
// First parameter value start
|
||||
if FIRST_PARAM_NAME_START_PATTERN.iter().any(|&p| content.ends_with(p)) {
|
||||
if FIRST_PARAM_NAME_START_PATTERN
|
||||
.iter()
|
||||
.any(|&p| content.ends_with(p))
|
||||
{
|
||||
self.state = Some("parameter_name".to_string());
|
||||
}
|
||||
|
||||
// Parameter value extraction logic
|
||||
if self.state.as_deref() == Some("parameter_value")
|
||||
&& !PARAMETER_VALUE_END_TOKEN.iter().any(|&t| content.ends_with(t)) {
|
||||
|
||||
&& !PARAMETER_VALUE_END_TOKEN
|
||||
.iter()
|
||||
.any(|&t| content.ends_with(t))
|
||||
{
|
||||
// Check for brackets
|
||||
if let Some(last_token) = self.tokens.last() {
|
||||
let open_brackets: Vec<char> = last_token
|
||||
|
|
@ -1694,8 +1827,11 @@ impl HallucinationState {
|
|||
&& self.mask[self.mask.len() - 2] != MaskToken::ParameterValue
|
||||
&& !self.parameter_name.is_empty()
|
||||
{
|
||||
let last_param = self.parameter_name[self.parameter_name.len() - 1].clone();
|
||||
if let Some(func_props) = self.function_properties.get(&self.function_name) {
|
||||
let last_param =
|
||||
self.parameter_name[self.parameter_name.len() - 1].clone();
|
||||
if let Some(func_props) =
|
||||
self.function_properties.get(&self.function_name)
|
||||
{
|
||||
if is_parameter_required(func_props, &last_param)
|
||||
&& !is_parameter_property(func_props, &last_param, "enum")
|
||||
&& !self.check_parameter_name.contains_key(&last_param)
|
||||
|
|
@ -1718,10 +1854,16 @@ impl HallucinationState {
|
|||
}
|
||||
} else if self.state.as_deref() == Some("parameter_value")
|
||||
&& !self.open_bracket
|
||||
&& PARAMETER_VALUE_END_TOKEN.iter().any(|&t| content.ends_with(t)) {
|
||||
&& PARAMETER_VALUE_END_TOKEN
|
||||
.iter()
|
||||
.any(|&t| content.ends_with(t))
|
||||
{
|
||||
self.state = None;
|
||||
} else if self.parameter_name_done
|
||||
&& PARAMETER_VALUE_START_PATTERN.iter().any(|&p| content.ends_with(p)) {
|
||||
&& PARAMETER_VALUE_START_PATTERN
|
||||
.iter()
|
||||
.any(|&p| content.ends_with(p))
|
||||
{
|
||||
self.state = Some("parameter_value".to_string());
|
||||
}
|
||||
|
||||
|
|
@ -1848,18 +1990,18 @@ mod hallucination_tests {
|
|||
let handler = ArchFunctionHandler::new(
|
||||
"test-model".to_string(),
|
||||
ArchFunctionConfig::default(),
|
||||
"http://localhost:8000".to_string()
|
||||
"http://localhost:8000".to_string(),
|
||||
);
|
||||
|
||||
// Test integer types
|
||||
assert!(handler.check_value_type(&json!(42), "integer"));
|
||||
assert!(handler.check_value_type(&json!(42), "int"));
|
||||
assert!(!handler.check_value_type(&json!(3.14), "integer"));
|
||||
assert!(!handler.check_value_type(&json!(3.15), "integer"));
|
||||
|
||||
// Test number types (accepts both int and float)
|
||||
assert!(handler.check_value_type(&json!(3.14), "number"));
|
||||
assert!(handler.check_value_type(&json!(3.15), "number"));
|
||||
assert!(handler.check_value_type(&json!(42), "number"));
|
||||
assert!(handler.check_value_type(&json!(3.14), "float"));
|
||||
assert!(handler.check_value_type(&json!(3.15), "float"));
|
||||
|
||||
// Test boolean
|
||||
assert!(handler.check_value_type(&json!(true), "boolean"));
|
||||
|
|
@ -1890,12 +2032,16 @@ mod hallucination_tests {
|
|||
let handler = ArchFunctionHandler::new(
|
||||
"test-model".to_string(),
|
||||
ArchFunctionConfig::default(),
|
||||
"http://localhost:8000".to_string()
|
||||
"http://localhost:8000".to_string(),
|
||||
);
|
||||
|
||||
// Test valid type - no conversion needed
|
||||
assert!(handler.validate_or_convert_parameter(&json!(42), "integer").unwrap());
|
||||
assert!(handler.validate_or_convert_parameter(&json!("hello"), "string").unwrap());
|
||||
assert!(handler
|
||||
.validate_or_convert_parameter(&json!(42), "integer")
|
||||
.unwrap());
|
||||
assert!(handler
|
||||
.validate_or_convert_parameter(&json!("hello"), "string")
|
||||
.unwrap());
|
||||
|
||||
// Test integer to float conversion (convert_data_type supports this)
|
||||
let result = handler.validate_or_convert_parameter(&json!(42), "float");
|
||||
|
|
@ -1910,8 +2056,12 @@ mod hallucination_tests {
|
|||
assert!(!result.unwrap());
|
||||
|
||||
// Test number accepting both int and float
|
||||
assert!(handler.validate_or_convert_parameter(&json!(42), "number").unwrap());
|
||||
assert!(handler.validate_or_convert_parameter(&json!(3.14), "number").unwrap());
|
||||
assert!(handler
|
||||
.validate_or_convert_parameter(&json!(42), "number")
|
||||
.unwrap());
|
||||
assert!(handler
|
||||
.validate_or_convert_parameter(&json!(3.15), "number")
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use crate::router::plano_orchestrator::OrchestratorService;
|
|||
/// 2. PipelineProcessor - executes the agent pipeline
|
||||
/// 3. ResponseHandler - handles response streaming
|
||||
#[cfg(test)]
|
||||
mod integration_tests {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use common::configuration::{Agent, AgentFilterChain, Listener};
|
||||
|
||||
|
|
@ -62,7 +62,10 @@ mod integration_tests {
|
|||
|
||||
let agent_pipeline = AgentFilterChain {
|
||||
id: "terminal-agent".to_string(),
|
||||
filter_chain: Some(vec!["filter-agent".to_string(), "terminal-agent".to_string()]),
|
||||
filter_chain: Some(vec![
|
||||
"filter-agent".to_string(),
|
||||
"terminal-agent".to_string(),
|
||||
]),
|
||||
description: Some("Test pipeline".to_string()),
|
||||
default: Some(true),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,48 +2,48 @@ use serde::{Deserialize, Serialize};
|
|||
use std::collections::HashMap;
|
||||
|
||||
pub const JSON_RPC_VERSION: &str = "2.0";
|
||||
pub const TOOL_CALL_METHOD : &str = "tools/call";
|
||||
pub const TOOL_CALL_METHOD: &str = "tools/call";
|
||||
pub const MCP_INITIALIZE: &str = "initialize";
|
||||
pub const MCP_INITIALIZE_NOTIFICATION: &str = "notifications/initialized";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum JsonRpcId {
|
||||
String(String),
|
||||
Number(u64),
|
||||
String(String),
|
||||
Number(u64),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonRpcRequest {
|
||||
pub jsonrpc: String,
|
||||
pub id: JsonRpcId,
|
||||
pub method: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub params: Option<HashMap<String, serde_json::Value>>,
|
||||
pub jsonrpc: String,
|
||||
pub id: JsonRpcId,
|
||||
pub method: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub params: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonRpcNotification {
|
||||
pub jsonrpc: String,
|
||||
pub method: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub params: Option<HashMap<String, serde_json::Value>>,
|
||||
pub jsonrpc: String,
|
||||
pub method: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub params: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonRpcError {
|
||||
pub code: i32,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<serde_json::Value>,
|
||||
pub code: i32,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonRpcResponse {
|
||||
pub jsonrpc: String,
|
||||
pub id: JsonRpcId,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<HashMap<String, serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<JsonRpcError>,
|
||||
pub jsonrpc: String,
|
||||
pub id: JsonRpcId,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<HashMap<String, serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<JsonRpcError>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
use bytes::Bytes;
|
||||
use common::configuration::{LlmProvider, ModelAlias};
|
||||
use common::consts::{ARCH_IS_STREAMING_HEADER, ARCH_PROVIDER_HINT_HEADER, REQUEST_ID_HEADER, TRACE_PARENT_HEADER};
|
||||
use common::consts::{
|
||||
ARCH_IS_STREAMING_HEADER, ARCH_PROVIDER_HINT_HEADER, REQUEST_ID_HEADER, TRACE_PARENT_HEADER,
|
||||
};
|
||||
use common::traces::TraceCollector;
|
||||
use hermesllm::apis::openai_responses::InputParam;
|
||||
use hermesllm::clients::{SupportedAPIsFromClient, SupportedUpstreamAPIs};
|
||||
|
|
@ -14,13 +16,14 @@ use std::sync::Arc;
|
|||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::router::llm_router::RouterService;
|
||||
use crate::handlers::utils::{create_streaming_response, ObservableStreamProcessor, truncate_message};
|
||||
use crate::handlers::router_chat::router_chat_get_upstream_model;
|
||||
use crate::handlers::utils::{
|
||||
create_streaming_response, truncate_message, ObservableStreamProcessor,
|
||||
};
|
||||
use crate::router::llm_router::RouterService;
|
||||
use crate::state::response_state_processor::ResponsesStateProcessor;
|
||||
use crate::state::{
|
||||
StateStorage, StateStorageError,
|
||||
extract_input_items, retrieve_and_combine_input
|
||||
extract_input_items, retrieve_and_combine_input, StateStorage, StateStorageError,
|
||||
};
|
||||
use crate::tracing::operation_component;
|
||||
|
||||
|
|
@ -39,7 +42,6 @@ pub async fn llm_chat(
|
|||
trace_collector: Arc<TraceCollector>,
|
||||
state_storage: Option<Arc<dyn StateStorage>>,
|
||||
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
|
||||
|
||||
let request_path = request.uri().path().to_string();
|
||||
let request_headers = request.headers().clone();
|
||||
let request_id = request_headers
|
||||
|
|
@ -74,8 +76,14 @@ pub async fn llm_chat(
|
|||
)) {
|
||||
Ok(request) => request,
|
||||
Err(err) => {
|
||||
warn!("[PLANO_REQ_ID:{}] | FAILURE | Failed to parse request as ProviderRequestType: {}", request_id, err);
|
||||
let err_msg = format!("[PLANO_REQ_ID:{}] | FAILURE | Failed to parse request: {}", request_id, err);
|
||||
warn!(
|
||||
"[PLANO_REQ_ID:{}] | FAILURE | Failed to parse request as ProviderRequestType: {}",
|
||||
request_id, err
|
||||
);
|
||||
let err_msg = format!(
|
||||
"[PLANO_REQ_ID:{}] | FAILURE | Failed to parse request: {}",
|
||||
request_id, err
|
||||
);
|
||||
let mut bad_request = Response::new(full(err_msg));
|
||||
*bad_request.status_mut() = StatusCode::BAD_REQUEST;
|
||||
return Ok(bad_request);
|
||||
|
|
@ -85,7 +93,10 @@ pub async fn llm_chat(
|
|||
// === v1/responses state management: Extract input items early ===
|
||||
let mut original_input_items = Vec::new();
|
||||
let client_api = SupportedAPIsFromClient::from_endpoint(request_path.as_str());
|
||||
let is_responses_api_client = matches!(client_api, Some(SupportedAPIsFromClient::OpenAIResponsesAPI(_)));
|
||||
let is_responses_api_client = matches!(
|
||||
client_api,
|
||||
Some(SupportedAPIsFromClient::OpenAIResponsesAPI(_))
|
||||
);
|
||||
|
||||
// Model alias resolution: update model field in client_request immediately
|
||||
// This ensures all downstream objects use the resolved model
|
||||
|
|
@ -96,7 +107,8 @@ pub async fn llm_chat(
|
|||
|
||||
// Extract tool names and user message preview for span attributes
|
||||
let tool_names = client_request.get_tool_names();
|
||||
let user_message_preview = client_request.get_recent_user_message()
|
||||
let user_message_preview = client_request
|
||||
.get_recent_user_message()
|
||||
.map(|msg| truncate_message(&msg, 50));
|
||||
|
||||
// Extract messages for signal analysis (clone before moving client_request)
|
||||
|
|
@ -104,15 +116,22 @@ pub async fn llm_chat(
|
|||
|
||||
client_request.set_model(resolved_model.clone());
|
||||
if client_request.remove_metadata_key("archgw_preference_config") {
|
||||
debug!("[PLANO_REQ_ID:{}] Removed archgw_preference_config from metadata", request_id);
|
||||
debug!(
|
||||
"[PLANO_REQ_ID:{}] Removed archgw_preference_config from metadata",
|
||||
request_id
|
||||
);
|
||||
}
|
||||
|
||||
// === v1/responses state management: Determine upstream API and combine input if needed ===
|
||||
// Do this BEFORE routing since routing consumes the request
|
||||
// Only process state if state_storage is configured
|
||||
let mut should_manage_state = false;
|
||||
if is_responses_api_client && state_storage.is_some() {
|
||||
if let ProviderRequestType::ResponsesAPIRequest(ref mut responses_req) = client_request {
|
||||
if is_responses_api_client {
|
||||
if let (
|
||||
ProviderRequestType::ResponsesAPIRequest(ref mut responses_req),
|
||||
Some(ref state_store),
|
||||
) = (&mut client_request, &state_storage)
|
||||
{
|
||||
// Extract original input once
|
||||
original_input_items = extract_input_items(&responses_req.input);
|
||||
|
||||
|
|
@ -123,18 +142,22 @@ pub async fn llm_chat(
|
|||
&request_path,
|
||||
&resolved_model,
|
||||
is_streaming_request,
|
||||
).await;
|
||||
)
|
||||
.await;
|
||||
|
||||
let upstream_api = SupportedUpstreamAPIs::from_endpoint(&upstream_path);
|
||||
|
||||
// Only manage state if upstream is NOT OpenAIResponsesAPI (needs translation)
|
||||
should_manage_state = !matches!(upstream_api, Some(SupportedUpstreamAPIs::OpenAIResponsesAPI(_)));
|
||||
should_manage_state = !matches!(
|
||||
upstream_api,
|
||||
Some(SupportedUpstreamAPIs::OpenAIResponsesAPI(_))
|
||||
);
|
||||
|
||||
if should_manage_state {
|
||||
// Retrieve and combine conversation history if previous_response_id exists
|
||||
if let Some(ref prev_resp_id) = responses_req.previous_response_id {
|
||||
match retrieve_and_combine_input(
|
||||
state_storage.as_ref().unwrap().clone(),
|
||||
state_store.clone(),
|
||||
prev_resp_id,
|
||||
original_input_items, // Pass ownership instead of cloning
|
||||
)
|
||||
|
|
@ -169,7 +192,10 @@ pub async fn llm_chat(
|
|||
}
|
||||
}
|
||||
} else {
|
||||
debug!("[PLANO_REQ_ID:{}] | BRIGHT_STAFF | Upstream supports ResponsesAPI natively.", request_id);
|
||||
debug!(
|
||||
"[PLANO_REQ_ID:{}] | BRIGHT_STAFF | Upstream supports ResponsesAPI natively.",
|
||||
request_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -180,7 +206,7 @@ pub async fn llm_chat(
|
|||
// Determine routing using the dedicated router_chat module
|
||||
let routing_result = match router_chat_get_upstream_model(
|
||||
router_service,
|
||||
client_request, // Pass the original request - router_chat will convert it
|
||||
client_request, // Pass the original request - router_chat will convert it
|
||||
&request_headers,
|
||||
trace_collector.clone(),
|
||||
&traceparent,
|
||||
|
|
@ -260,7 +286,8 @@ pub async fn llm_chat(
|
|||
user_message_preview,
|
||||
temperature,
|
||||
&llm_providers,
|
||||
).await;
|
||||
)
|
||||
.await;
|
||||
|
||||
// Create base processor for metrics and tracing
|
||||
let mut base_processor = ObservableStreamProcessor::new(
|
||||
|
|
@ -277,7 +304,11 @@ pub async fn llm_chat(
|
|||
|
||||
// === v1/responses state management: Wrap with ResponsesStateProcessor ===
|
||||
// Only wrap if we need to manage state (client is ResponsesAPI AND upstream is NOT ResponsesAPI AND state_storage is configured)
|
||||
let streaming_response = if should_manage_state && !original_input_items.is_empty() && state_storage.is_some() {
|
||||
let streaming_response = if let (true, false, Some(state_store)) = (
|
||||
should_manage_state,
|
||||
original_input_items.is_empty(),
|
||||
state_storage,
|
||||
) {
|
||||
// Extract Content-Encoding header to handle decompression for state parsing
|
||||
let content_encoding = response_headers
|
||||
.get("content-encoding")
|
||||
|
|
@ -287,7 +318,7 @@ pub async fn llm_chat(
|
|||
// Wrap with state management processor to store state after response completes
|
||||
let state_processor = ResponsesStateProcessor::new(
|
||||
base_processor,
|
||||
state_storage.unwrap(),
|
||||
state_store,
|
||||
original_input_items,
|
||||
resolved_model.clone(),
|
||||
model_name.clone(),
|
||||
|
|
@ -332,6 +363,7 @@ fn resolve_model_alias(
|
|||
}
|
||||
|
||||
/// Builds the LLM span with all required and optional attributes.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn build_llm_span(
|
||||
traceparent: &str,
|
||||
request_path: &str,
|
||||
|
|
@ -345,8 +377,8 @@ async fn build_llm_span(
|
|||
temperature: Option<f32>,
|
||||
llm_providers: &Arc<RwLock<Vec<LlmProvider>>>,
|
||||
) -> common::traces::Span {
|
||||
use common::traces::{SpanBuilder, SpanKind, parse_traceparent};
|
||||
use crate::tracing::{http, llm, OperationNameBuilder};
|
||||
use common::traces::{parse_traceparent, SpanBuilder, SpanKind};
|
||||
|
||||
// Calculate the upstream path based on provider configuration
|
||||
let upstream_path = get_upstream_path(
|
||||
|
|
@ -355,13 +387,14 @@ async fn build_llm_span(
|
|||
request_path,
|
||||
resolved_model,
|
||||
is_streaming,
|
||||
).await;
|
||||
)
|
||||
.await;
|
||||
|
||||
// Build operation name showing path transformation if different
|
||||
let operation_name = if request_path != upstream_path {
|
||||
OperationNameBuilder::new()
|
||||
.with_method("POST")
|
||||
.with_path(&format!("{} >> {}", request_path, upstream_path))
|
||||
.with_path(format!("{} >> {}", request_path, upstream_path))
|
||||
.with_target(resolved_model)
|
||||
.build()
|
||||
} else {
|
||||
|
|
@ -396,7 +429,8 @@ async fn build_llm_span(
|
|||
}
|
||||
|
||||
if let Some(tools) = tool_names {
|
||||
let formatted_tools = tools.iter()
|
||||
let formatted_tools = tools
|
||||
.iter()
|
||||
.map(|name| format!("{}(...)", name))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
|
@ -444,14 +478,21 @@ async fn get_provider_info(
|
|||
|
||||
// First, try to find by model name or provider name
|
||||
let provider = providers_lock.iter().find(|p| {
|
||||
p.model.as_ref().map(|m| m == model_name).unwrap_or(false)
|
||||
|| p.name == model_name
|
||||
p.model.as_ref().map(|m| m == model_name).unwrap_or(false) || p.name == model_name
|
||||
});
|
||||
|
||||
if let Some(provider) = provider {
|
||||
let provider_id = provider.provider_interface.to_provider_id();
|
||||
let prefix = provider.base_url_path_prefix.clone();
|
||||
return (provider_id, prefix);
|
||||
}
|
||||
|
||||
let default_provider = providers_lock.iter().find(|p| p.default.unwrap_or(false));
|
||||
|
||||
if let Some(provider) = default_provider {
|
||||
let provider_id = provider.provider_interface.to_provider_id();
|
||||
let prefix = provider.base_url_path_prefix.clone();
|
||||
(provider_id, prefix)
|
||||
} else {
|
||||
// Last resort: use OpenAI as hardcoded fallback
|
||||
warn!("No default provider found, falling back to OpenAI");
|
||||
|
|
@ -461,11 +502,11 @@ async fn get_provider_info(
|
|||
|
||||
/// Extract messages from ProviderRequestType for signal analysis
|
||||
/// Returns None for non-ChatCompletions requests
|
||||
fn extract_messages_for_signals(request: &ProviderRequestType) -> Option<Vec<hermesllm::apis::openai::Message>> {
|
||||
fn extract_messages_for_signals(
|
||||
request: &ProviderRequestType,
|
||||
) -> Option<Vec<hermesllm::apis::openai::Message>> {
|
||||
match request {
|
||||
ProviderRequestType::ChatCompletionsRequest(chat_req) => {
|
||||
Some(chat_req.messages.clone())
|
||||
}
|
||||
ProviderRequestType::ChatCompletionsRequest(chat_req) => Some(chat_req.messages.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
pub mod agent_chat_completions;
|
||||
pub mod agent_selector;
|
||||
pub mod llm;
|
||||
pub mod router_chat;
|
||||
pub mod models;
|
||||
pub mod function_calling;
|
||||
pub mod jsonrpc;
|
||||
pub mod llm;
|
||||
pub mod models;
|
||||
pub mod pipeline_processor;
|
||||
pub mod response_handler;
|
||||
pub mod router_chat;
|
||||
pub mod utils;
|
||||
pub mod jsonrpc;
|
||||
|
||||
#[cfg(test)]
|
||||
mod integration_tests;
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ impl PipelineProcessor {
|
|||
}
|
||||
|
||||
/// Record a span for filter execution
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn record_filter_span(
|
||||
&self,
|
||||
collector: &std::sync::Arc<common::traces::TraceCollector>,
|
||||
|
|
@ -132,6 +133,7 @@ impl PipelineProcessor {
|
|||
}
|
||||
|
||||
/// Record a span for MCP protocol interactions
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn record_agent_filter_span(
|
||||
&self,
|
||||
collector: &std::sync::Arc<common::traces::TraceCollector>,
|
||||
|
|
@ -156,12 +158,12 @@ impl PipelineProcessor {
|
|||
.build();
|
||||
|
||||
let mut span_builder = SpanBuilder::new(&operation_name)
|
||||
.with_span_id(span_id.unwrap_or_else(|| generate_random_span_id()))
|
||||
.with_span_id(span_id.unwrap_or_else(generate_random_span_id))
|
||||
.with_kind(SpanKind::Client)
|
||||
.with_start_time(start_time)
|
||||
.with_end_time(end_time)
|
||||
.with_attribute(http::METHOD, "POST")
|
||||
.with_attribute(http::TARGET, &format!("/mcp ({})", operation.to_string()))
|
||||
.with_attribute(http::TARGET, format!("/mcp ({})", operation))
|
||||
.with_attribute("mcp.operation", operation.to_string())
|
||||
.with_attribute("mcp.agent_id", agent_id.to_string())
|
||||
.with_attribute(
|
||||
|
|
@ -188,6 +190,7 @@ impl PipelineProcessor {
|
|||
}
|
||||
|
||||
/// Process the filter chain of agents (all except the terminal agent)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn process_filter_chain(
|
||||
&mut self,
|
||||
chat_history: &[Message],
|
||||
|
|
@ -1023,7 +1026,7 @@ mod tests {
|
|||
}
|
||||
});
|
||||
|
||||
let sse_body = format!("event: message\ndata: {}\n\n", rpc_body.to_string());
|
||||
let sse_body = format!("event: message\ndata: {}\n\n", rpc_body);
|
||||
|
||||
let mut server = Server::new_async().await;
|
||||
let _m = server
|
||||
|
|
@ -1061,10 +1064,10 @@ mod tests {
|
|||
.await;
|
||||
|
||||
match result {
|
||||
Err(PipelineError::ClientError { status, body, .. }) => {
|
||||
assert_eq!(status, 400);
|
||||
assert_eq!(body, "bad tool call");
|
||||
}
|
||||
Err(PipelineError::ClientError { status, body, .. }) => {
|
||||
assert_eq!(status, 400);
|
||||
assert_eq!(body, "bad tool call");
|
||||
}
|
||||
_ => panic!("Expected client error when isError flag is set"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,9 +133,7 @@ impl ResponseHandler {
|
|||
let response_headers = llm_response.headers();
|
||||
let is_sse_streaming = response_headers
|
||||
.get(hyper::header::CONTENT_TYPE)
|
||||
.map_or(false, |v| {
|
||||
v.to_str().unwrap_or("").contains("text/event-stream")
|
||||
});
|
||||
.is_some_and(|v| v.to_str().unwrap_or("").contains("text/event-stream"));
|
||||
|
||||
let response_bytes = llm_response
|
||||
.bytes()
|
||||
|
|
@ -164,7 +162,7 @@ impl ResponseHandler {
|
|||
match transformed_event.provider_response() {
|
||||
Ok(provider_response) => {
|
||||
if let Some(content) = provider_response.content_delta() {
|
||||
accumulated_text.push_str(&content);
|
||||
accumulated_text.push_str(content);
|
||||
} else {
|
||||
info!("No content delta in provider response");
|
||||
}
|
||||
|
|
@ -174,7 +172,7 @@ impl ResponseHandler {
|
|||
}
|
||||
}
|
||||
}
|
||||
return Ok(accumulated_text);
|
||||
Ok(accumulated_text)
|
||||
} else {
|
||||
// If not SSE, treat as regular text response
|
||||
let response_text = String::from_utf8(response_bytes.to_vec()).map_err(|e| {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use common::configuration::ModelUsagePreference;
|
||||
use common::consts::{REQUEST_ID_HEADER};
|
||||
use common::traces::{TraceCollector, SpanKind, SpanBuilder, parse_traceparent};
|
||||
use common::consts::REQUEST_ID_HEADER;
|
||||
use common::traces::{parse_traceparent, SpanBuilder, SpanKind, TraceCollector};
|
||||
use hermesllm::clients::endpoints::SupportedUpstreamAPIs;
|
||||
use hermesllm::{ProviderRequest, ProviderRequestType};
|
||||
use hyper::StatusCode;
|
||||
|
|
@ -9,10 +9,10 @@ use std::sync::Arc;
|
|||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::router::llm_router::RouterService;
|
||||
use crate::tracing::{OperationNameBuilder, operation_component, http, routing};
|
||||
use crate::tracing::{http, operation_component, routing, OperationNameBuilder};
|
||||
|
||||
pub struct RoutingResult {
|
||||
pub model_name: String
|
||||
pub model_name: String,
|
||||
}
|
||||
|
||||
pub struct RoutingError {
|
||||
|
|
@ -24,7 +24,7 @@ impl RoutingError {
|
|||
pub fn internal_error(message: String) -> Self {
|
||||
Self {
|
||||
message,
|
||||
status_code: StatusCode::INTERNAL_SERVER_ERROR
|
||||
status_code: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -52,9 +52,7 @@ pub async fn router_chat_get_upstream_model(
|
|||
// Convert to ChatCompletionsRequest for routing (regardless of input type)
|
||||
let chat_request = match ProviderRequestType::try_from((
|
||||
client_request,
|
||||
&SupportedUpstreamAPIs::OpenAIChatCompletions(
|
||||
hermesllm::apis::OpenAIApi::ChatCompletions,
|
||||
),
|
||||
&SupportedUpstreamAPIs::OpenAIChatCompletions(hermesllm::apis::OpenAIApi::ChatCompletions),
|
||||
)) {
|
||||
Ok(ProviderRequestType::ChatCompletionsRequest(req)) => req,
|
||||
Ok(
|
||||
|
|
@ -69,7 +67,10 @@ pub async fn router_chat_get_upstream_model(
|
|||
));
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Failed to convert request to ChatCompletionsRequest: {}", err);
|
||||
warn!(
|
||||
"Failed to convert request to ChatCompletionsRequest: {}",
|
||||
err
|
||||
);
|
||||
return Err(RoutingError::internal_error(format!(
|
||||
"Failed to convert request: {}",
|
||||
err
|
||||
|
|
@ -151,9 +152,7 @@ pub async fn router_chat_get_upstream_model(
|
|||
)
|
||||
.await;
|
||||
|
||||
Ok(RoutingResult {
|
||||
model_name
|
||||
})
|
||||
Ok(RoutingResult { model_name })
|
||||
}
|
||||
None => {
|
||||
// No route determined, use default model from request
|
||||
|
|
@ -176,7 +175,7 @@ pub async fn router_chat_get_upstream_model(
|
|||
.await;
|
||||
|
||||
Ok(RoutingResult {
|
||||
model_name: default_model
|
||||
model_name: default_model,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
|
@ -194,9 +193,10 @@ pub async fn router_chat_get_upstream_model(
|
|||
)
|
||||
.await;
|
||||
|
||||
Err(RoutingError::internal_error(
|
||||
format!("Failed to determine route: {}", err)
|
||||
))
|
||||
Err(RoutingError::internal_error(format!(
|
||||
"Failed to determine route: {}",
|
||||
err
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -230,7 +230,10 @@ async fn record_routing_span(
|
|||
.with_end_time(std::time::SystemTime::now())
|
||||
.with_attribute(http::METHOD, "POST")
|
||||
.with_attribute(http::TARGET, routing_api_path.to_string())
|
||||
.with_attribute(routing::ROUTE_DETERMINATION_MS, start_time.elapsed().as_millis().to_string());
|
||||
.with_attribute(
|
||||
routing::ROUTE_DETERMINATION_MS,
|
||||
start_time.elapsed().as_millis().to_string(),
|
||||
);
|
||||
|
||||
// Only set parent span ID if it exists (not a root span)
|
||||
if let Some(parent) = parent_span_id {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use bytes::Bytes;
|
||||
use common::traces::{Span, Attribute, AttributeValue, TraceCollector, Event};
|
||||
use common::traces::{Attribute, AttributeValue, Event, Span, TraceCollector};
|
||||
use http_body_util::combinators::BoxBody;
|
||||
use http_body_util::StreamBody;
|
||||
use hyper::body::Frame;
|
||||
|
|
@ -11,8 +11,8 @@ use tokio_stream::StreamExt;
|
|||
use tracing::warn;
|
||||
|
||||
// Import tracing constants and signals
|
||||
use crate::tracing::{llm, error, signals as signal_constants};
|
||||
use crate::signals::signals::{SignalAnalyzer, InteractionQuality, FLAG_MARKER};
|
||||
use crate::signals::{InteractionQuality, SignalAnalyzer, FLAG_MARKER};
|
||||
use crate::tracing::{error, llm, signals as signal_constants};
|
||||
use hermesllm::apis::openai::Message;
|
||||
|
||||
/// Trait for processing streaming chunks
|
||||
|
|
@ -107,7 +107,6 @@ impl StreamProcessor for ObservableStreamProcessor {
|
|||
},
|
||||
});
|
||||
|
||||
|
||||
self.span.attributes.push(Attribute {
|
||||
key: llm::DURATION_MS.to_string(),
|
||||
value: AttributeValue {
|
||||
|
|
@ -129,11 +128,9 @@ impl StreamProcessor for ObservableStreamProcessor {
|
|||
if let Ok(start_time_nanos) = self.span.start_time_unix_nano.parse::<u128>() {
|
||||
// Convert ttft from milliseconds to nanoseconds and add to start time
|
||||
let event_timestamp = start_time_nanos + (ttft * 1_000_000);
|
||||
let mut event = Event::new(llm::TIME_TO_FIRST_TOKEN_MS.to_string(), event_timestamp);
|
||||
event.add_attribute(
|
||||
llm::TIME_TO_FIRST_TOKEN_MS.to_string(),
|
||||
ttft.to_string(),
|
||||
);
|
||||
let mut event =
|
||||
Event::new(llm::TIME_TO_FIRST_TOKEN_MS.to_string(), event_timestamp);
|
||||
event.add_attribute(llm::TIME_TO_FIRST_TOKEN_MS.to_string(), ttft.to_string());
|
||||
|
||||
// Initialize events vector if needed
|
||||
if self.span.events.is_none() {
|
||||
|
|
@ -235,7 +232,8 @@ impl StreamProcessor for ObservableStreamProcessor {
|
|||
}
|
||||
|
||||
// Record the finalized span
|
||||
self.collector.record_span(&self.service_name, self.span.clone());
|
||||
self.collector
|
||||
.record_span(&self.service_name, self.span.clone());
|
||||
}
|
||||
|
||||
fn on_error(&mut self, error_msg: &str) {
|
||||
|
|
@ -271,7 +269,8 @@ impl StreamProcessor for ObservableStreamProcessor {
|
|||
});
|
||||
|
||||
// Record the error span
|
||||
self.collector.record_span(&self.service_name, self.span.clone());
|
||||
self.collector
|
||||
.record_span(&self.service_name, self.span.clone());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
pub mod handlers;
|
||||
pub mod router;
|
||||
pub mod state;
|
||||
pub mod signals;
|
||||
pub mod state;
|
||||
pub mod tracing;
|
||||
pub mod utils;
|
||||
|
|
|
|||
|
|
@ -4,13 +4,15 @@ use brightstaff::handlers::llm::llm_chat;
|
|||
use brightstaff::handlers::models::list_models;
|
||||
use brightstaff::router::llm_router::RouterService;
|
||||
use brightstaff::router::plano_orchestrator::OrchestratorService;
|
||||
use brightstaff::state::StateStorage;
|
||||
use brightstaff::state::postgresql::PostgreSQLConversationStorage;
|
||||
use brightstaff::state::memory::MemoryConversationalStorage;
|
||||
use brightstaff::state::postgresql::PostgreSQLConversationStorage;
|
||||
use brightstaff::state::StateStorage;
|
||||
use brightstaff::utils::tracing::init_tracer;
|
||||
use bytes::Bytes;
|
||||
use common::configuration::{Agent, Configuration};
|
||||
use common::consts::{CHAT_COMPLETIONS_PATH, MESSAGES_PATH, OPENAI_RESPONSES_API_PATH, PLANO_ORCHESTRATOR_MODEL_NAME};
|
||||
use common::consts::{
|
||||
CHAT_COMPLETIONS_PATH, MESSAGES_PATH, OPENAI_RESPONSES_API_PATH, PLANO_ORCHESTRATOR_MODEL_NAME,
|
||||
};
|
||||
use common::traces::TraceCollector;
|
||||
use http_body_util::{combinators::BoxBody, BodyExt, Empty};
|
||||
use hyper::body::Incoming;
|
||||
|
|
@ -105,7 +107,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
PLANO_ORCHESTRATOR_MODEL_NAME.to_string(),
|
||||
));
|
||||
|
||||
|
||||
let model_aliases = Arc::new(arch_config.model_aliases.clone());
|
||||
|
||||
// Initialize trace collector and start background flusher
|
||||
|
|
@ -127,33 +128,33 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
// Configurable via arch_config.yaml state_storage section
|
||||
// If not configured, state management is disabled
|
||||
// Environment variables are substituted by envsubst before config is read
|
||||
let state_storage: Option<Arc<dyn StateStorage>> = if let Some(storage_config) = &arch_config.state_storage {
|
||||
let storage: Arc<dyn StateStorage> = match storage_config.storage_type {
|
||||
common::configuration::StateStorageType::Memory => {
|
||||
info!("Initialized conversation state storage: Memory");
|
||||
Arc::new(MemoryConversationalStorage::new())
|
||||
}
|
||||
common::configuration::StateStorageType::Postgres => {
|
||||
let connection_string = storage_config
|
||||
.connection_string
|
||||
.as_ref()
|
||||
.expect("connection_string is required for postgres state_storage");
|
||||
let state_storage: Option<Arc<dyn StateStorage>> =
|
||||
if let Some(storage_config) = &arch_config.state_storage {
|
||||
let storage: Arc<dyn StateStorage> = match storage_config.storage_type {
|
||||
common::configuration::StateStorageType::Memory => {
|
||||
info!("Initialized conversation state storage: Memory");
|
||||
Arc::new(MemoryConversationalStorage::new())
|
||||
}
|
||||
common::configuration::StateStorageType::Postgres => {
|
||||
let connection_string = storage_config
|
||||
.connection_string
|
||||
.as_ref()
|
||||
.expect("connection_string is required for postgres state_storage");
|
||||
|
||||
debug!("Postgres connection string (full): {}", connection_string);
|
||||
info!("Initializing conversation state storage: Postgres");
|
||||
Arc::new(
|
||||
PostgreSQLConversationStorage::new(connection_string.clone())
|
||||
.await
|
||||
.expect("Failed to initialize Postgres state storage"),
|
||||
)
|
||||
}
|
||||
debug!("Postgres connection string (full): {}", connection_string);
|
||||
info!("Initializing conversation state storage: Postgres");
|
||||
Arc::new(
|
||||
PostgreSQLConversationStorage::new(connection_string.clone())
|
||||
.await
|
||||
.expect("Failed to initialize Postgres state storage"),
|
||||
)
|
||||
}
|
||||
};
|
||||
Some(storage)
|
||||
} else {
|
||||
info!("No state_storage configured - conversation state management disabled");
|
||||
None
|
||||
};
|
||||
Some(storage)
|
||||
} else {
|
||||
info!("No state_storage configured - conversation state management disabled");
|
||||
None
|
||||
};
|
||||
|
||||
|
||||
loop {
|
||||
let (stream, _) = listener.accept().await?;
|
||||
|
|
@ -208,12 +209,22 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||
}
|
||||
}
|
||||
match (req.method(), path) {
|
||||
(&Method::POST, CHAT_COMPLETIONS_PATH | MESSAGES_PATH | OPENAI_RESPONSES_API_PATH) => {
|
||||
let fully_qualified_url =
|
||||
format!("{}{}", llm_provider_url, path);
|
||||
llm_chat(req, router_service, fully_qualified_url, model_aliases, llm_providers, trace_collector, state_storage)
|
||||
.with_context(parent_cx)
|
||||
.await
|
||||
(
|
||||
&Method::POST,
|
||||
CHAT_COMPLETIONS_PATH | MESSAGES_PATH | OPENAI_RESPONSES_API_PATH,
|
||||
) => {
|
||||
let fully_qualified_url = format!("{}{}", llm_provider_url, path);
|
||||
llm_chat(
|
||||
req,
|
||||
router_service,
|
||||
fully_qualified_url,
|
||||
model_aliases,
|
||||
llm_providers,
|
||||
trace_collector,
|
||||
state_storage,
|
||||
)
|
||||
.with_context(parent_cx)
|
||||
.await
|
||||
}
|
||||
(&Method::POST, "/function_calling") => {
|
||||
let fully_qualified_url =
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use std::collections::HashMap;
|
|||
|
||||
use common::configuration::{AgentUsagePreference, OrchestrationPreference};
|
||||
use hermesllm::apis::openai::{ChatCompletionsRequest, Message, MessageContent, Role};
|
||||
use serde::{Deserialize, Serialize, ser::Serialize as SerializeTrait};
|
||||
use serde::{ser::Serialize as SerializeTrait, Deserialize, Serialize};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use super::orchestrator_model::{OrchestratorModel, OrchestratorModelError};
|
||||
|
|
@ -144,7 +144,7 @@ impl OrchestratorModelV1 {
|
|||
// Format routes: each route as JSON on its own line with standard spacing
|
||||
let agent_orchestration_json_str = agent_orchestration_values
|
||||
.iter()
|
||||
.map(|pref| to_spaced_json(pref))
|
||||
.map(to_spaced_json)
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n");
|
||||
let agent_orchestration_to_model_map: HashMap<String, String> = agent_orchestrations
|
||||
|
|
@ -238,24 +238,26 @@ impl OrchestratorModel for OrchestratorModelV1 {
|
|||
let selected_conversation_list = selected_messages_list_reversed
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|message| {
|
||||
Message {
|
||||
role: message.role.clone(),
|
||||
content: MessageContent::Text(message.content.to_string()),
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
}
|
||||
.map(|message| Message {
|
||||
role: message.role.clone(),
|
||||
content: MessageContent::Text(message.content.to_string()),
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
})
|
||||
.collect::<Vec<Message>>();
|
||||
|
||||
// Generate the orchestrator request message based on the usage preferences.
|
||||
// If preferences are passed in request then we use them;
|
||||
// Otherwise, we use the default orchestration modelpreferences.
|
||||
let orchestrator_message = match convert_to_orchestrator_preferences(usage_preferences_from_request) {
|
||||
Some(prefs) => generate_orchestrator_message(&prefs, &selected_conversation_list),
|
||||
None => generate_orchestrator_message(&self.agent_orchestration_json_str, &selected_conversation_list),
|
||||
};
|
||||
let orchestrator_message =
|
||||
match convert_to_orchestrator_preferences(usage_preferences_from_request) {
|
||||
Some(prefs) => generate_orchestrator_message(&prefs, &selected_conversation_list),
|
||||
None => generate_orchestrator_message(
|
||||
&self.agent_orchestration_json_str,
|
||||
&selected_conversation_list,
|
||||
),
|
||||
};
|
||||
|
||||
ChatCompletionsRequest {
|
||||
model: self.orchestration_model.clone(),
|
||||
|
|
@ -280,7 +282,8 @@ impl OrchestratorModel for OrchestratorModelV1 {
|
|||
return Ok(None);
|
||||
}
|
||||
let orchestrator_resp_fixed = fix_json_response(content);
|
||||
let orchestrator_response: AgentOrchestratorResponse = serde_json::from_str(orchestrator_resp_fixed.as_str())?;
|
||||
let orchestrator_response: AgentOrchestratorResponse =
|
||||
serde_json::from_str(orchestrator_resp_fixed.as_str())?;
|
||||
|
||||
let selected_routes = orchestrator_response.route.unwrap_or_default();
|
||||
|
||||
|
|
@ -320,7 +323,11 @@ impl OrchestratorModel for OrchestratorModelV1 {
|
|||
} else {
|
||||
// If no usage preferences are passed in request then use the default orchestration model preferences
|
||||
for selected_route in valid_routes {
|
||||
if let Some(model) = self.agent_orchestration_to_model_map.get(&selected_route).cloned() {
|
||||
if let Some(model) = self
|
||||
.agent_orchestration_to_model_map
|
||||
.get(&selected_route)
|
||||
.cloned()
|
||||
{
|
||||
result.push((selected_route, model));
|
||||
} else {
|
||||
warn!(
|
||||
|
|
@ -375,7 +382,7 @@ fn convert_to_orchestrator_preferences(
|
|||
// Format routes: each route as JSON on its own line with standard spacing
|
||||
let routes_str = orchestration_preferences
|
||||
.iter()
|
||||
.map(|pref| to_spaced_json(pref))
|
||||
.map(to_spaced_json)
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n");
|
||||
|
||||
|
|
@ -425,7 +432,10 @@ mod tests {
|
|||
// CRITICAL: Test that colons inside string values are NOT modified
|
||||
let with_colon = serde_json::json!({"name": "foo:bar", "url": "http://example.com"});
|
||||
let result = to_spaced_json(&with_colon);
|
||||
assert_eq!(result, r#"{"name": "foo:bar", "url": "http://example.com"}"#);
|
||||
assert_eq!(
|
||||
result,
|
||||
r#"{"name": "foo:bar", "url": "http://example.com"}"#
|
||||
);
|
||||
|
||||
// Test empty object and array
|
||||
let empty_obj = serde_json::json!({});
|
||||
|
|
@ -446,7 +456,8 @@ mod tests {
|
|||
});
|
||||
let result = to_spaced_json(&complex);
|
||||
// Verify URLs with colons are preserved correctly
|
||||
assert!(result.contains(r#""urls": ["https://api.example.com:8080/path", "file:///local/path"]"#));
|
||||
assert!(result
|
||||
.contains(r#""urls": ["https://api.example.com:8080/path", "file:///local/path"]"#));
|
||||
// Verify spacing format
|
||||
assert!(result.contains(r#""type": "object""#));
|
||||
assert!(result.contains(r#""properties": {}"#));
|
||||
|
|
@ -497,10 +508,16 @@ If no routes are needed, return an empty list for `route`.
|
|||
]
|
||||
}
|
||||
"#;
|
||||
let agent_orchestrations =
|
||||
serde_json::from_str::<HashMap<String, Vec<OrchestrationPreference>>>(orchestrations_str).unwrap();
|
||||
let agent_orchestrations = serde_json::from_str::<
|
||||
HashMap<String, Vec<OrchestrationPreference>>,
|
||||
>(orchestrations_str)
|
||||
.unwrap();
|
||||
let orchestration_model = "test-model".to_string();
|
||||
let orchestrator = OrchestratorModelV1::new(agent_orchestrations, orchestration_model.clone(), usize::MAX);
|
||||
let orchestrator = OrchestratorModelV1::new(
|
||||
agent_orchestrations,
|
||||
orchestration_model.clone(),
|
||||
usize::MAX,
|
||||
);
|
||||
|
||||
let conversation_str = r#"
|
||||
[
|
||||
|
|
@ -568,7 +585,11 @@ If no routes are needed, return an empty list for `route`.
|
|||
// Empty orchestrations map - not used when usage_preferences are provided
|
||||
let agent_orchestrations: HashMap<String, Vec<OrchestrationPreference>> = HashMap::new();
|
||||
let orchestration_model = "test-model".to_string();
|
||||
let orchestrator = OrchestratorModelV1::new(agent_orchestrations, orchestration_model.clone(), usize::MAX);
|
||||
let orchestrator = OrchestratorModelV1::new(
|
||||
agent_orchestrations,
|
||||
orchestration_model.clone(),
|
||||
usize::MAX,
|
||||
);
|
||||
|
||||
let conversation_str = r#"
|
||||
[
|
||||
|
|
@ -640,10 +661,13 @@ If no routes are needed, return an empty list for `route`.
|
|||
]
|
||||
}
|
||||
"#;
|
||||
let agent_orchestrations =
|
||||
serde_json::from_str::<HashMap<String, Vec<OrchestrationPreference>>>(orchestrations_str).unwrap();
|
||||
let agent_orchestrations = serde_json::from_str::<
|
||||
HashMap<String, Vec<OrchestrationPreference>>,
|
||||
>(orchestrations_str)
|
||||
.unwrap();
|
||||
let orchestration_model = "test-model".to_string();
|
||||
let orchestrator = OrchestratorModelV1::new(agent_orchestrations, orchestration_model.clone(), 235);
|
||||
let orchestrator =
|
||||
OrchestratorModelV1::new(agent_orchestrations, orchestration_model.clone(), 235);
|
||||
|
||||
let conversation_str = r#"
|
||||
[
|
||||
|
|
@ -709,11 +733,14 @@ If no routes are needed, return an empty list for `route`.
|
|||
]
|
||||
}
|
||||
"#;
|
||||
let agent_orchestrations =
|
||||
serde_json::from_str::<HashMap<String, Vec<OrchestrationPreference>>>(orchestrations_str).unwrap();
|
||||
let agent_orchestrations = serde_json::from_str::<
|
||||
HashMap<String, Vec<OrchestrationPreference>>,
|
||||
>(orchestrations_str)
|
||||
.unwrap();
|
||||
|
||||
let orchestration_model = "test-model".to_string();
|
||||
let orchestrator = OrchestratorModelV1::new(agent_orchestrations, orchestration_model.clone(), 200);
|
||||
let orchestrator =
|
||||
OrchestratorModelV1::new(agent_orchestrations, orchestration_model.clone(), 200);
|
||||
|
||||
let conversation_str = r#"
|
||||
[
|
||||
|
|
@ -787,10 +814,13 @@ If no routes are needed, return an empty list for `route`.
|
|||
]
|
||||
}
|
||||
"#;
|
||||
let agent_orchestrations =
|
||||
serde_json::from_str::<HashMap<String, Vec<OrchestrationPreference>>>(orchestrations_str).unwrap();
|
||||
let agent_orchestrations = serde_json::from_str::<
|
||||
HashMap<String, Vec<OrchestrationPreference>>,
|
||||
>(orchestrations_str)
|
||||
.unwrap();
|
||||
let orchestration_model = "test-model".to_string();
|
||||
let orchestrator = OrchestratorModelV1::new(agent_orchestrations, orchestration_model.clone(), 230);
|
||||
let orchestrator =
|
||||
OrchestratorModelV1::new(agent_orchestrations, orchestration_model.clone(), 230);
|
||||
|
||||
let conversation_str = r#"
|
||||
[
|
||||
|
|
@ -871,10 +901,16 @@ If no routes are needed, return an empty list for `route`.
|
|||
]
|
||||
}
|
||||
"#;
|
||||
let agent_orchestrations =
|
||||
serde_json::from_str::<HashMap<String, Vec<OrchestrationPreference>>>(orchestrations_str).unwrap();
|
||||
let agent_orchestrations = serde_json::from_str::<
|
||||
HashMap<String, Vec<OrchestrationPreference>>,
|
||||
>(orchestrations_str)
|
||||
.unwrap();
|
||||
let orchestration_model = "test-model".to_string();
|
||||
let orchestrator = OrchestratorModelV1::new(agent_orchestrations, orchestration_model.clone(), usize::MAX);
|
||||
let orchestrator = OrchestratorModelV1::new(
|
||||
agent_orchestrations,
|
||||
orchestration_model.clone(),
|
||||
usize::MAX,
|
||||
);
|
||||
|
||||
let conversation_str = r#"
|
||||
[
|
||||
|
|
@ -957,10 +993,16 @@ If no routes are needed, return an empty list for `route`.
|
|||
]
|
||||
}
|
||||
"#;
|
||||
let agent_orchestrations =
|
||||
serde_json::from_str::<HashMap<String, Vec<OrchestrationPreference>>>(orchestrations_str).unwrap();
|
||||
let agent_orchestrations = serde_json::from_str::<
|
||||
HashMap<String, Vec<OrchestrationPreference>>,
|
||||
>(orchestrations_str)
|
||||
.unwrap();
|
||||
let orchestration_model = "test-model".to_string();
|
||||
let orchestrator = OrchestratorModelV1::new(agent_orchestrations, orchestration_model.clone(), usize::MAX);
|
||||
let orchestrator = OrchestratorModelV1::new(
|
||||
agent_orchestrations,
|
||||
orchestration_model.clone(),
|
||||
usize::MAX,
|
||||
);
|
||||
|
||||
let conversation_str = r#"
|
||||
[
|
||||
|
|
@ -1034,10 +1076,13 @@ If no routes are needed, return an empty list for `route`.
|
|||
]
|
||||
}
|
||||
"#;
|
||||
let agent_orchestrations =
|
||||
serde_json::from_str::<HashMap<String, Vec<OrchestrationPreference>>>(orchestrations_str).unwrap();
|
||||
let agent_orchestrations = serde_json::from_str::<
|
||||
HashMap<String, Vec<OrchestrationPreference>>,
|
||||
>(orchestrations_str)
|
||||
.unwrap();
|
||||
|
||||
let orchestrator = OrchestratorModelV1::new(agent_orchestrations, "test-model".to_string(), 2000);
|
||||
let orchestrator =
|
||||
OrchestratorModelV1::new(agent_orchestrations, "test-model".to_string(), 2000);
|
||||
|
||||
// Case 1: Valid JSON with single route in array
|
||||
let input = r#"{"route": ["Image generation"]}"#;
|
||||
|
|
|
|||
|
|
@ -34,10 +34,7 @@ pub enum OrchestrationError {
|
|||
pub type Result<T> = std::result::Result<T, OrchestrationError>;
|
||||
|
||||
impl OrchestratorService {
|
||||
pub fn new(
|
||||
orchestrator_url: String,
|
||||
orchestration_model_name: String,
|
||||
) -> Self {
|
||||
pub fn new(orchestrator_url: String, orchestration_model_name: String) -> Self {
|
||||
// Empty agent orchestrations - will be provided via usage_preferences in requests
|
||||
let agent_orchestrations: HashMap<String, Vec<OrchestrationPreference>> = HashMap::new();
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
//! message arrays.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use strsim::jaro_winkler;
|
||||
use std::collections::HashSet;
|
||||
use strsim::jaro_winkler;
|
||||
|
||||
use hermesllm::apis::openai::{Message, Role};
|
||||
|
||||
|
|
@ -41,12 +41,9 @@ impl NormalizedMessage {
|
|||
|
||||
// Normalize unicode punctuation to ASCII equivalents
|
||||
let normalized_unicode = text
|
||||
.replace('\u{2019}', "'") // U+2019 RIGHT SINGLE QUOTATION MARK
|
||||
.replace('\u{2018}', "'") // U+2018 LEFT SINGLE QUOTATION MARK
|
||||
.replace('\u{201C}', "\"") // U+201C LEFT DOUBLE QUOTATION MARK
|
||||
.replace('\u{201D}', "\"") // U+201D RIGHT DOUBLE QUOTATION MARK
|
||||
.replace('\u{2013}', "-") // U+2013 EN DASH
|
||||
.replace('\u{2014}', "-"); // U+2014 EM DASH
|
||||
.replace(['\u{2019}', '\u{2018}'], "'") // U+2019/U+2018 SINGLE QUOTATION MARKs
|
||||
.replace(['\u{201C}', '\u{201D}'], "\"") // U+201C/U+201D DOUBLE QUOTATION MARKs
|
||||
.replace(['\u{2013}', '\u{2014}'], "-"); // U+2013/U+2014 EN/EM DASHes
|
||||
|
||||
// Normalize: lowercase, collapse whitespace
|
||||
let normalized = normalized_unicode
|
||||
|
|
@ -99,12 +96,12 @@ impl NormalizedMessage {
|
|||
}
|
||||
|
||||
// Multi-word phrase: check for sequence in tokens
|
||||
self.tokens
|
||||
.windows(phrase_tokens.len())
|
||||
.any(|window| {
|
||||
window.iter().zip(phrase_tokens.iter())
|
||||
.all(|(token, phrase_token)| token == phrase_token)
|
||||
})
|
||||
self.tokens.windows(phrase_tokens.len()).any(|window| {
|
||||
window
|
||||
.iter()
|
||||
.zip(phrase_tokens.iter())
|
||||
.all(|(token, phrase_token)| token == phrase_token)
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if phrase exists using fuzzy matching (for typo tolerance)
|
||||
|
|
@ -419,9 +416,8 @@ impl SignalAnalyzer {
|
|||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, msg)| {
|
||||
Self::extract_text(&msg.content).map(|text| {
|
||||
(i, msg.role.clone(), NormalizedMessage::from_text(&text))
|
||||
})
|
||||
Self::extract_text(&msg.content)
|
||||
.map(|text| (i, msg.role.clone(), NormalizedMessage::from_text(&text)))
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
@ -485,9 +481,7 @@ impl SignalAnalyzer {
|
|||
let is_excessive = total_turns > 12;
|
||||
|
||||
// Calculate efficiency score (exponential decay after baseline)
|
||||
let efficiency_score = if total_turns == 0 {
|
||||
1.0
|
||||
} else if total_turns <= self.baseline_turns {
|
||||
let efficiency_score = if total_turns == 0 || total_turns <= self.baseline_turns {
|
||||
1.0
|
||||
} else {
|
||||
let excess = total_turns - self.baseline_turns;
|
||||
|
|
@ -505,7 +499,10 @@ impl SignalAnalyzer {
|
|||
}
|
||||
|
||||
/// Analyze follow-up and repair frequency
|
||||
fn analyze_follow_up(&self, normalized_messages: &[(usize, Role, NormalizedMessage)]) -> FollowUpSignal {
|
||||
fn analyze_follow_up(
|
||||
&self,
|
||||
normalized_messages: &[(usize, Role, NormalizedMessage)],
|
||||
) -> FollowUpSignal {
|
||||
let repair_patterns = [
|
||||
// Explicit corrections
|
||||
"i meant",
|
||||
|
|
@ -577,7 +574,9 @@ impl SignalAnalyzer {
|
|||
repair_phrases.push(format!("Turn {}: '{}'", i + 1, pattern));
|
||||
found_in_turn = true;
|
||||
break;
|
||||
} else if Self::should_use_fuzzy_matching(pattern) && norm_msg.fuzzy_contains_phrase(pattern, self.fuzzy_threshold) {
|
||||
} else if Self::should_use_fuzzy_matching(pattern)
|
||||
&& norm_msg.fuzzy_contains_phrase(pattern, self.fuzzy_threshold)
|
||||
{
|
||||
repair_count += 1;
|
||||
repair_phrases.push(format!("Turn {}: '{}' (fuzzy)", i + 1, pattern));
|
||||
found_in_turn = true;
|
||||
|
|
@ -593,7 +592,8 @@ impl SignalAnalyzer {
|
|||
if *prev_role == Role::User {
|
||||
if self.is_similar_rephrase(norm_msg, prev_norm_msg) {
|
||||
repair_count += 1;
|
||||
repair_phrases.push(format!("Turn {}: Similar rephrase detected", i + 1));
|
||||
repair_phrases
|
||||
.push(format!("Turn {}: Similar rephrase detected", i + 1));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -618,7 +618,10 @@ impl SignalAnalyzer {
|
|||
}
|
||||
|
||||
/// Analyze user frustration indicators
|
||||
fn analyze_frustration(&self, normalized_messages: &[(usize, Role, NormalizedMessage)]) -> FrustrationSignal {
|
||||
fn analyze_frustration(
|
||||
&self,
|
||||
normalized_messages: &[(usize, Role, NormalizedMessage)],
|
||||
) -> FrustrationSignal {
|
||||
let mut indicators = Vec::new();
|
||||
|
||||
// Complaint phrases - removed ultra-generic single words that cause false positives
|
||||
|
|
@ -721,15 +724,7 @@ impl SignalAnalyzer {
|
|||
|
||||
// Profanity list - only as standalone tokens, not substrings
|
||||
let profanity_tokens = [
|
||||
"damn",
|
||||
"damnit",
|
||||
"crap",
|
||||
"wtf",
|
||||
"ffs",
|
||||
"bullshit",
|
||||
"shit",
|
||||
"fuck",
|
||||
"fucking",
|
||||
"damn", "damnit", "crap", "wtf", "ffs", "bullshit", "shit", "fuck", "fucking",
|
||||
];
|
||||
|
||||
for (i, role, norm_msg) in normalized_messages {
|
||||
|
|
@ -773,7 +768,9 @@ impl SignalAnalyzer {
|
|||
snippet: pattern.to_string(),
|
||||
});
|
||||
break;
|
||||
} else if Self::should_use_fuzzy_matching(pattern) && norm_msg.fuzzy_contains_phrase(pattern, self.fuzzy_threshold) {
|
||||
} else if Self::should_use_fuzzy_matching(pattern)
|
||||
&& norm_msg.fuzzy_contains_phrase(pattern, self.fuzzy_threshold)
|
||||
{
|
||||
indicators.push(FrustrationIndicator {
|
||||
indicator_type: FrustrationType::DirectComplaint,
|
||||
message_index: *i,
|
||||
|
|
@ -831,7 +828,10 @@ impl SignalAnalyzer {
|
|||
}
|
||||
|
||||
/// Analyze repetition and looping behavior
|
||||
fn analyze_repetition(&self, normalized_messages: &[(usize, Role, NormalizedMessage)]) -> RepetitionSignal {
|
||||
fn analyze_repetition(
|
||||
&self,
|
||||
normalized_messages: &[(usize, Role, NormalizedMessage)],
|
||||
) -> RepetitionSignal {
|
||||
let mut repetitions = Vec::new();
|
||||
|
||||
// Collect assistant messages with normalized content
|
||||
|
|
@ -896,7 +896,11 @@ impl SignalAnalyzer {
|
|||
}
|
||||
|
||||
/// Calculate bigram similarity using cached bigram sets
|
||||
fn calculate_bigram_similarity(&self, norm_msg1: &NormalizedMessage, norm_msg2: &NormalizedMessage) -> f64 {
|
||||
fn calculate_bigram_similarity(
|
||||
&self,
|
||||
norm_msg1: &NormalizedMessage,
|
||||
norm_msg2: &NormalizedMessage,
|
||||
) -> f64 {
|
||||
// Use pre-cached bigram sets for O(1) lookups
|
||||
let set1 = &norm_msg1.bigram_set;
|
||||
let set2 = &norm_msg2.bigram_set;
|
||||
|
|
@ -920,7 +924,10 @@ impl SignalAnalyzer {
|
|||
}
|
||||
|
||||
/// Analyze positive feedback indicators
|
||||
fn analyze_positive_feedback(&self, normalized_messages: &[(usize, Role, NormalizedMessage)]) -> PositiveFeedbackSignal {
|
||||
fn analyze_positive_feedback(
|
||||
&self,
|
||||
normalized_messages: &[(usize, Role, NormalizedMessage)],
|
||||
) -> PositiveFeedbackSignal {
|
||||
let mut indicators = Vec::new();
|
||||
|
||||
let gratitude_patterns = [
|
||||
|
|
@ -1075,7 +1082,9 @@ impl SignalAnalyzer {
|
|||
});
|
||||
found_in_turn = true;
|
||||
break;
|
||||
} else if Self::should_use_fuzzy_matching(pattern) && norm_msg.fuzzy_contains_phrase(pattern, self.fuzzy_threshold) {
|
||||
} else if Self::should_use_fuzzy_matching(pattern)
|
||||
&& norm_msg.fuzzy_contains_phrase(pattern, self.fuzzy_threshold)
|
||||
{
|
||||
indicators.push(PositiveIndicator {
|
||||
indicator_type: PositiveType::Gratitude,
|
||||
message_index: *i,
|
||||
|
|
@ -1143,7 +1152,10 @@ impl SignalAnalyzer {
|
|||
}
|
||||
|
||||
/// Analyze user escalation requests
|
||||
fn analyze_escalation(&self, normalized_messages: &[(usize, Role, NormalizedMessage)]) -> EscalationSignal {
|
||||
fn analyze_escalation(
|
||||
&self,
|
||||
normalized_messages: &[(usize, Role, NormalizedMessage)],
|
||||
) -> EscalationSignal {
|
||||
let mut requests = Vec::new();
|
||||
|
||||
let human_agent_patterns = [
|
||||
|
|
@ -1262,7 +1274,9 @@ impl SignalAnalyzer {
|
|||
escalation_type: EscalationType::HumanAgent,
|
||||
});
|
||||
break;
|
||||
} else if Self::should_use_fuzzy_matching(pattern) && norm_msg.fuzzy_contains_phrase(pattern, self.fuzzy_threshold) {
|
||||
} else if Self::should_use_fuzzy_matching(pattern)
|
||||
&& norm_msg.fuzzy_contains_phrase(pattern, self.fuzzy_threshold)
|
||||
{
|
||||
requests.push(EscalationRequest {
|
||||
message_index: *i,
|
||||
snippet: format!("{} (fuzzy)", pattern),
|
||||
|
|
@ -1312,7 +1326,11 @@ impl SignalAnalyzer {
|
|||
// ========================================================================
|
||||
|
||||
/// Check if two messages are similar rephrases
|
||||
fn is_similar_rephrase(&self, norm_msg1: &NormalizedMessage, norm_msg2: &NormalizedMessage) -> bool {
|
||||
fn is_similar_rephrase(
|
||||
&self,
|
||||
norm_msg1: &NormalizedMessage,
|
||||
norm_msg2: &NormalizedMessage,
|
||||
) -> bool {
|
||||
// Skip if too short
|
||||
if norm_msg1.tokens.len() < 3 || norm_msg2.tokens.len() < 3 {
|
||||
return false;
|
||||
|
|
@ -1320,16 +1338,23 @@ impl SignalAnalyzer {
|
|||
|
||||
// Common stopwords to downweight
|
||||
let stopwords: HashSet<&str> = [
|
||||
"i", "me", "my", "you", "the", "a", "an", "is", "are", "was", "were",
|
||||
"to", "with", "for", "of", "at", "by", "in", "on", "it", "this", "that",
|
||||
"can", "could", "do", "does", "did", "will", "would", "should", "be",
|
||||
].iter().cloned().collect();
|
||||
"i", "me", "my", "you", "the", "a", "an", "is", "are", "was", "were", "to", "with",
|
||||
"for", "of", "at", "by", "in", "on", "it", "this", "that", "can", "could", "do",
|
||||
"does", "did", "will", "would", "should", "be",
|
||||
]
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// Filter out stopwords for meaningful overlap
|
||||
let tokens1: HashSet<_> = norm_msg1.tokens.iter()
|
||||
let tokens1: HashSet<_> = norm_msg1
|
||||
.tokens
|
||||
.iter()
|
||||
.filter(|t| !stopwords.contains(t.as_str()))
|
||||
.collect();
|
||||
let tokens2: HashSet<_> = norm_msg2.tokens.iter()
|
||||
let tokens2: HashSet<_> = norm_msg2
|
||||
.tokens
|
||||
.iter()
|
||||
.filter(|t| !stopwords.contains(t.as_str()))
|
||||
.collect();
|
||||
|
||||
|
|
@ -1403,6 +1428,7 @@ impl SignalAnalyzer {
|
|||
}
|
||||
|
||||
/// Generate human-readable summary
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn generate_summary(
|
||||
&self,
|
||||
turn_count: &TurnCountSignal,
|
||||
|
|
@ -1415,10 +1441,7 @@ impl SignalAnalyzer {
|
|||
) -> String {
|
||||
let mut summary_parts = Vec::new();
|
||||
|
||||
summary_parts.push(format!(
|
||||
"Overall Quality: {:?}",
|
||||
quality
|
||||
));
|
||||
summary_parts.push(format!("Overall Quality: {:?}", quality));
|
||||
|
||||
summary_parts.push(format!(
|
||||
"Turn Count: {} turns (efficiency: {:.1}%)",
|
||||
|
|
@ -1478,7 +1501,7 @@ impl Default for SignalAnalyzer {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use hermesllm::apis::openai::{MessageContent};
|
||||
use hermesllm::apis::openai::MessageContent;
|
||||
use std::time::Instant;
|
||||
|
||||
fn create_message(role: Role, content: &str) -> Message {
|
||||
|
|
@ -1529,7 +1552,11 @@ mod tests {
|
|||
let mut messages = Vec::new();
|
||||
for i in 0..15 {
|
||||
messages.push(create_message(
|
||||
if i % 2 == 0 { Role::User } else { Role::Assistant },
|
||||
if i % 2 == 0 {
|
||||
Role::User
|
||||
} else {
|
||||
Role::Assistant
|
||||
},
|
||||
&format!("Message {}", i),
|
||||
));
|
||||
}
|
||||
|
|
@ -1593,7 +1620,10 @@ mod tests {
|
|||
assert!(signal.has_positive_feedback);
|
||||
assert!(signal.positive_count >= 1);
|
||||
assert!(signal.confidence > 0.5);
|
||||
println!("test_positive_feedback_detection took: {:?}", start.elapsed());
|
||||
println!(
|
||||
"test_positive_feedback_detection took: {:?}",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1619,7 +1649,10 @@ mod tests {
|
|||
let analyzer = SignalAnalyzer::new();
|
||||
let messages = vec![
|
||||
create_message(Role::User, "What's the weather?"),
|
||||
create_message(Role::Assistant, "I can help you with the weather information"),
|
||||
create_message(
|
||||
Role::Assistant,
|
||||
"I can help you with the weather information",
|
||||
),
|
||||
create_message(Role::User, "Show me the forecast"),
|
||||
create_message(Role::Assistant, "Sure, I can help you with the forecast"),
|
||||
create_message(Role::User, "Stop repeating yourself"),
|
||||
|
|
@ -1631,8 +1664,10 @@ mod tests {
|
|||
// Debug output to see what was detected
|
||||
println!("Detected {} repetitions:", signal.repetition_count);
|
||||
for rep in &signal.repetitions {
|
||||
println!(" - Messages {:?}, similarity: {:.3}, type: {:?}",
|
||||
rep.message_indices, rep.similarity, rep.repetition_type);
|
||||
println!(
|
||||
" - Messages {:?}, similarity: {:.3}, type: {:?}",
|
||||
rep.message_indices, rep.similarity, rep.repetition_type
|
||||
);
|
||||
}
|
||||
|
||||
assert!(signal.repetition_count > 0,
|
||||
|
|
@ -1762,21 +1797,22 @@ mod tests {
|
|||
|
||||
// Very strict threshold should not match heavily garbled text
|
||||
let messages = vec![
|
||||
create_message(Role::User, "xyz abc"), // Completely unrelated to any gratitude pattern
|
||||
create_message(Role::User, "xyz abc"), // Completely unrelated to any gratitude pattern
|
||||
];
|
||||
let normalized_messages = preprocess_messages(&messages);
|
||||
let signal = analyzer.analyze_positive_feedback(&normalized_messages);
|
||||
assert_eq!(signal.positive_count, 0);
|
||||
println!("test_fuzzy_threshold_configuration took: {:?}", start.elapsed());
|
||||
println!(
|
||||
"test_fuzzy_threshold_configuration took: {:?}",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exact_match_priority() {
|
||||
let start = Instant::now();
|
||||
let analyzer = SignalAnalyzer::new();
|
||||
let messages = vec![
|
||||
create_message(Role::User, "thank you so much"),
|
||||
];
|
||||
let messages = vec![create_message(Role::User, "thank you so much")];
|
||||
|
||||
let normalized_messages = preprocess_messages(&messages);
|
||||
let signal = analyzer.analyze_positive_feedback(&normalized_messages);
|
||||
|
|
@ -1794,63 +1830,79 @@ mod tests {
|
|||
#[test]
|
||||
fn test_hello_not_profanity() {
|
||||
let analyzer = SignalAnalyzer::new();
|
||||
let messages = vec![
|
||||
create_message(Role::User, "hello there"),
|
||||
];
|
||||
let messages = vec![create_message(Role::User, "hello there")];
|
||||
|
||||
let normalized_messages = preprocess_messages(&messages);
|
||||
let signal = analyzer.analyze_frustration(&normalized_messages);
|
||||
assert!(!signal.has_frustration, "\"hello\" should not trigger profanity detection");
|
||||
assert!(
|
||||
!signal.has_frustration,
|
||||
"\"hello\" should not trigger profanity detection"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prepare_not_escalation() {
|
||||
let analyzer = SignalAnalyzer::new();
|
||||
let messages = vec![
|
||||
create_message(Role::User, "Can you help me prepare for the meeting?"),
|
||||
];
|
||||
let messages = vec![create_message(
|
||||
Role::User,
|
||||
"Can you help me prepare for the meeting?",
|
||||
)];
|
||||
|
||||
let normalized_messages = preprocess_messages(&messages);
|
||||
let signal = analyzer.analyze_escalation(&normalized_messages);
|
||||
assert!(!signal.escalation_requested, "\"prepare\" should not trigger escalation (rep pattern removed)");
|
||||
assert!(
|
||||
!signal.escalation_requested,
|
||||
"\"prepare\" should not trigger escalation (rep pattern removed)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unicode_apostrophe_confusion() {
|
||||
let analyzer = SignalAnalyzer::new();
|
||||
let messages = vec![
|
||||
create_message(Role::User, "I'm confused"), // Unicode apostrophe
|
||||
create_message(Role::User, "I'm confused"), // Unicode apostrophe
|
||||
];
|
||||
|
||||
let normalized_messages = preprocess_messages(&messages);
|
||||
let signal = analyzer.analyze_frustration(&normalized_messages);
|
||||
assert!(signal.has_frustration, "Unicode apostrophe 'I'm confused' should trigger confusion");
|
||||
assert!(
|
||||
signal.has_frustration,
|
||||
"Unicode apostrophe 'I'm confused' should trigger confusion"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unicode_quotes_work() {
|
||||
let analyzer = SignalAnalyzer::new();
|
||||
let messages = vec![
|
||||
create_message(Role::User, "\u{201C}doesn\u{2019}t work\u{201D} with unicode quotes"),
|
||||
];
|
||||
let messages = vec![create_message(
|
||||
Role::User,
|
||||
"\u{201C}doesn\u{2019}t work\u{201D} with unicode quotes",
|
||||
)];
|
||||
|
||||
let normalized_messages = preprocess_messages(&messages);
|
||||
let signal = analyzer.analyze_frustration(&normalized_messages);
|
||||
assert!(signal.has_frustration, "Unicode quotes should be normalized and match patterns");
|
||||
assert!(
|
||||
signal.has_frustration,
|
||||
"Unicode quotes should be normalized and match patterns"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_absolute_not_profanity() {
|
||||
let analyzer = SignalAnalyzer::new();
|
||||
let messages = vec![
|
||||
create_message(Role::User, "That's absolute nonsense"),
|
||||
];
|
||||
let messages = vec![create_message(Role::User, "That's absolute nonsense")];
|
||||
|
||||
let normalized_messages = preprocess_messages(&messages);
|
||||
let signal = analyzer.analyze_frustration(&normalized_messages);
|
||||
// Should match on "nonsense" logic, not on "bs" substring
|
||||
let has_bs_match = signal.indicators.iter().any(|ind| ind.snippet.contains("bs"));
|
||||
assert!(!has_bs_match, "\"absolute\" should not trigger 'bs' profanity match");
|
||||
let has_bs_match = signal
|
||||
.indicators
|
||||
.iter()
|
||||
.any(|ind| ind.snippet.contains("bs"));
|
||||
assert!(
|
||||
!has_bs_match,
|
||||
"\"absolute\" should not trigger 'bs' profanity match"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1865,7 +1917,10 @@ mod tests {
|
|||
let normalized_messages = preprocess_messages(&messages);
|
||||
let signal = analyzer.analyze_follow_up(&normalized_messages);
|
||||
// Should not detect as rephrase since only stopwords overlap
|
||||
assert_eq!(signal.repair_count, 0, "Messages with only stopword overlap should not be rephrases");
|
||||
assert_eq!(
|
||||
signal.repair_count, 0,
|
||||
"Messages with only stopword overlap should not be rephrases"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1873,25 +1928,26 @@ mod tests {
|
|||
let start = Instant::now();
|
||||
let analyzer = SignalAnalyzer::new();
|
||||
|
||||
use hermesllm::apis::openai::{ToolCall, FunctionCall};
|
||||
use hermesllm::apis::openai::{FunctionCall, ToolCall};
|
||||
|
||||
// Helper to create a message with tool calls
|
||||
let create_assistant_with_tools = |content: &str, tool_id: &str, tool_name: &str, args: &str| -> Message {
|
||||
Message {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Text(content.to_string()),
|
||||
name: None,
|
||||
tool_calls: Some(vec![ToolCall {
|
||||
id: tool_id.to_string(),
|
||||
call_type: "function".to_string(),
|
||||
function: FunctionCall {
|
||||
name: tool_name.to_string(),
|
||||
arguments: args.to_string(),
|
||||
},
|
||||
}]),
|
||||
tool_call_id: None,
|
||||
}
|
||||
};
|
||||
let create_assistant_with_tools =
|
||||
|content: &str, tool_id: &str, tool_name: &str, args: &str| -> Message {
|
||||
Message {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Text(content.to_string()),
|
||||
name: None,
|
||||
tool_calls: Some(vec![ToolCall {
|
||||
id: tool_id.to_string(),
|
||||
call_type: "function".to_string(),
|
||||
function: FunctionCall {
|
||||
name: tool_name.to_string(),
|
||||
arguments: args.to_string(),
|
||||
},
|
||||
}]),
|
||||
tool_call_id: None,
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to create a tool response message
|
||||
let create_tool_message = |tool_call_id: &str, content: &str| -> Message {
|
||||
|
|
@ -1906,44 +1962,83 @@ mod tests {
|
|||
|
||||
// Scenario: User DOES mention New York in first message, making "I already told you" legitimate
|
||||
let messages = vec![
|
||||
create_message(Role::User, "I need to book a flight from New York to Paris for December 20th"),
|
||||
create_message(
|
||||
Role::User,
|
||||
"I need to book a flight from New York to Paris for December 20th",
|
||||
),
|
||||
create_assistant_with_tools(
|
||||
"I'll help you search for flights to Paris.",
|
||||
"call_123",
|
||||
"search_flights",
|
||||
r#"{"origin": "NYC", "destination": "Paris", "date": "2025-12-20"}"#
|
||||
r#"{"origin": "NYC", "destination": "Paris", "date": "2025-12-20"}"#,
|
||||
),
|
||||
create_tool_message("call_123", r#"{"flights": []}"#),
|
||||
create_message(Role::Assistant, "I couldn't find any flights. Could you provide your departure city?"),
|
||||
create_message(
|
||||
Role::Assistant,
|
||||
"I couldn't find any flights. Could you provide your departure city?",
|
||||
),
|
||||
create_message(Role::User, "I already told you, from New York!"),
|
||||
create_assistant_with_tools(
|
||||
"Let me try again.",
|
||||
"call_456",
|
||||
"search_flights",
|
||||
r#"{"origin": "New York", "destination": "Paris", "date": "2025-12-20"}"#
|
||||
r#"{"origin": "New York", "destination": "Paris", "date": "2025-12-20"}"#,
|
||||
),
|
||||
create_tool_message("call_456", r#"{"flights": []}"#),
|
||||
create_message(Role::Assistant, "I'm still not finding results. Let me check the system."),
|
||||
create_message(Role::User, "THIS IS RIDICULOUS!!! The tool doesn't work at all. Why do you keep calling it?"),
|
||||
create_message(Role::Assistant, "I sincerely apologize for the frustration with the search tool."),
|
||||
create_message(Role::User, "Forget it. I need to speak to a human agent. This is a waste of time."),
|
||||
create_message(
|
||||
Role::Assistant,
|
||||
"I'm still not finding results. Let me check the system.",
|
||||
),
|
||||
create_message(
|
||||
Role::User,
|
||||
"THIS IS RIDICULOUS!!! The tool doesn't work at all. Why do you keep calling it?",
|
||||
),
|
||||
create_message(
|
||||
Role::Assistant,
|
||||
"I sincerely apologize for the frustration with the search tool.",
|
||||
),
|
||||
create_message(
|
||||
Role::User,
|
||||
"Forget it. I need to speak to a human agent. This is a waste of time.",
|
||||
),
|
||||
];
|
||||
|
||||
let report = analyzer.analyze(&messages);
|
||||
|
||||
// Tool messages should be filtered out, so we should only analyze text messages
|
||||
// That's 4 user messages + 5 assistant text messages = 9 turns
|
||||
assert_eq!(report.turn_count.total_turns, 9, "Should count 9 text messages (tool messages filtered out)");
|
||||
assert!(report.turn_count.is_concerning, "Should flag concerning turn count");
|
||||
assert_eq!(
|
||||
report.turn_count.total_turns, 9,
|
||||
"Should count 9 text messages (tool messages filtered out)"
|
||||
);
|
||||
assert!(
|
||||
report.turn_count.is_concerning,
|
||||
"Should flag concerning turn count"
|
||||
);
|
||||
|
||||
// Should detect frustration (all caps, complaints)
|
||||
assert!(report.frustration.has_frustration, "Should detect frustration");
|
||||
assert!(report.frustration.frustration_count >= 2, "Should detect multiple frustration indicators");
|
||||
assert!(report.frustration.severity >= 2, "Should have moderate or higher frustration severity");
|
||||
assert!(
|
||||
report.frustration.has_frustration,
|
||||
"Should detect frustration"
|
||||
);
|
||||
assert!(
|
||||
report.frustration.frustration_count >= 2,
|
||||
"Should detect multiple frustration indicators"
|
||||
);
|
||||
assert!(
|
||||
report.frustration.severity >= 2,
|
||||
"Should have moderate or higher frustration severity"
|
||||
);
|
||||
|
||||
// Should detect escalation request
|
||||
assert!(report.escalation.escalation_requested, "Should detect escalation to human agent");
|
||||
assert!(report.escalation.escalation_count >= 1, "Should detect at least one escalation");
|
||||
assert!(
|
||||
report.escalation.escalation_requested,
|
||||
"Should detect escalation to human agent"
|
||||
);
|
||||
assert!(
|
||||
report.escalation.escalation_count >= 1,
|
||||
"Should detect at least one escalation"
|
||||
);
|
||||
|
||||
// Overall quality should be Poor or Severe
|
||||
assert!(
|
||||
|
|
@ -1955,7 +2050,10 @@ mod tests {
|
|||
report.overall_quality
|
||||
);
|
||||
|
||||
println!("test_frustrated_user_with_legitimate_repair took: {:?}", start.elapsed());
|
||||
println!(
|
||||
"test_frustrated_user_with_legitimate_repair took: {:?}",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1963,25 +2061,26 @@ mod tests {
|
|||
let start = Instant::now();
|
||||
let analyzer = SignalAnalyzer::new();
|
||||
|
||||
use hermesllm::apis::openai::{ToolCall, FunctionCall};
|
||||
use hermesllm::apis::openai::{FunctionCall, ToolCall};
|
||||
|
||||
// Helper to create a message with tool calls
|
||||
let create_assistant_with_tools = |content: &str, tool_id: &str, tool_name: &str, args: &str| -> Message {
|
||||
Message {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Text(content.to_string()),
|
||||
name: None,
|
||||
tool_calls: Some(vec![ToolCall {
|
||||
id: tool_id.to_string(),
|
||||
call_type: "function".to_string(),
|
||||
function: FunctionCall {
|
||||
name: tool_name.to_string(),
|
||||
arguments: args.to_string(),
|
||||
},
|
||||
}]),
|
||||
tool_call_id: None,
|
||||
}
|
||||
};
|
||||
let create_assistant_with_tools =
|
||||
|content: &str, tool_id: &str, tool_name: &str, args: &str| -> Message {
|
||||
Message {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Text(content.to_string()),
|
||||
name: None,
|
||||
tool_calls: Some(vec![ToolCall {
|
||||
id: tool_id.to_string(),
|
||||
call_type: "function".to_string(),
|
||||
function: FunctionCall {
|
||||
name: tool_name.to_string(),
|
||||
arguments: args.to_string(),
|
||||
},
|
||||
}]),
|
||||
tool_call_id: None,
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to create a tool response message
|
||||
let create_tool_message = |tool_call_id: &str, content: &str| -> Message {
|
||||
|
|
@ -1997,44 +2096,83 @@ mod tests {
|
|||
// Scenario: User NEVER mentions New York in first message but claims "I already told you"
|
||||
// This represents realistic frustrated user behavior - exaggeration/misremembering
|
||||
let messages = vec![
|
||||
create_message(Role::User, "I need to book a flight to Paris for December 20th"),
|
||||
create_message(
|
||||
Role::User,
|
||||
"I need to book a flight to Paris for December 20th",
|
||||
),
|
||||
create_assistant_with_tools(
|
||||
"I'll help you search for flights to Paris.",
|
||||
"call_123",
|
||||
"search_flights",
|
||||
r#"{"destination": "Paris", "date": "2025-12-20"}"#
|
||||
r#"{"destination": "Paris", "date": "2025-12-20"}"#,
|
||||
),
|
||||
create_tool_message("call_123", r#"{"error": "origin required"}"#),
|
||||
create_message(Role::Assistant, "I couldn't find any flights. Could you provide your departure city?"),
|
||||
create_message(Role::User, "I already told you, from New York!"), // False claim - never mentioned it
|
||||
create_message(
|
||||
Role::Assistant,
|
||||
"I couldn't find any flights. Could you provide your departure city?",
|
||||
),
|
||||
create_message(Role::User, "I already told you, from New York!"), // False claim - never mentioned it
|
||||
create_assistant_with_tools(
|
||||
"Let me try again.",
|
||||
"call_456",
|
||||
"search_flights",
|
||||
r#"{"origin": "New York", "destination": "Paris", "date": "2025-12-20"}"#
|
||||
r#"{"origin": "New York", "destination": "Paris", "date": "2025-12-20"}"#,
|
||||
),
|
||||
create_tool_message("call_456", r#"{"flights": []}"#),
|
||||
create_message(Role::Assistant, "I'm still not finding results. Let me check the system."),
|
||||
create_message(Role::User, "THIS IS RIDICULOUS!!! The tool doesn't work at all. Why do you keep calling it?"),
|
||||
create_message(Role::Assistant, "I sincerely apologize for the frustration with the search tool."),
|
||||
create_message(Role::User, "Forget it. I need to speak to a human agent. This is a waste of time."),
|
||||
create_message(
|
||||
Role::Assistant,
|
||||
"I'm still not finding results. Let me check the system.",
|
||||
),
|
||||
create_message(
|
||||
Role::User,
|
||||
"THIS IS RIDICULOUS!!! The tool doesn't work at all. Why do you keep calling it?",
|
||||
),
|
||||
create_message(
|
||||
Role::Assistant,
|
||||
"I sincerely apologize for the frustration with the search tool.",
|
||||
),
|
||||
create_message(
|
||||
Role::User,
|
||||
"Forget it. I need to speak to a human agent. This is a waste of time.",
|
||||
),
|
||||
];
|
||||
|
||||
let report = analyzer.analyze(&messages);
|
||||
|
||||
// Tool messages should be filtered out, so we should only analyze text messages
|
||||
// That's 4 user messages + 5 assistant text messages = 9 turns
|
||||
assert_eq!(report.turn_count.total_turns, 9, "Should count 9 text messages (tool messages filtered out)");
|
||||
assert!(report.turn_count.is_concerning, "Should flag concerning turn count");
|
||||
assert_eq!(
|
||||
report.turn_count.total_turns, 9,
|
||||
"Should count 9 text messages (tool messages filtered out)"
|
||||
);
|
||||
assert!(
|
||||
report.turn_count.is_concerning,
|
||||
"Should flag concerning turn count"
|
||||
);
|
||||
|
||||
// Should detect frustration (all caps, complaints, false claims)
|
||||
assert!(report.frustration.has_frustration, "Should detect frustration");
|
||||
assert!(report.frustration.frustration_count >= 2, "Should detect multiple frustration indicators");
|
||||
assert!(report.frustration.severity >= 2, "Should have moderate or higher frustration severity");
|
||||
assert!(
|
||||
report.frustration.has_frustration,
|
||||
"Should detect frustration"
|
||||
);
|
||||
assert!(
|
||||
report.frustration.frustration_count >= 2,
|
||||
"Should detect multiple frustration indicators"
|
||||
);
|
||||
assert!(
|
||||
report.frustration.severity >= 2,
|
||||
"Should have moderate or higher frustration severity"
|
||||
);
|
||||
|
||||
// Should detect escalation request
|
||||
assert!(report.escalation.escalation_requested, "Should detect escalation to human agent");
|
||||
assert!(report.escalation.escalation_count >= 1, "Should detect at least one escalation");
|
||||
assert!(
|
||||
report.escalation.escalation_requested,
|
||||
"Should detect escalation to human agent"
|
||||
);
|
||||
assert!(
|
||||
report.escalation.escalation_count >= 1,
|
||||
"Should detect at least one escalation"
|
||||
);
|
||||
|
||||
// Note: May detect false positive "positive feedback" due to fuzzy matching
|
||||
// e.g., "I already told YOU" matches "you rock", "THIS is RIDICULOUS" matches "this helps"
|
||||
|
|
@ -2050,7 +2188,10 @@ mod tests {
|
|||
report.overall_quality
|
||||
);
|
||||
|
||||
println!("test_frustrated_user_false_claim took: {:?}", start.elapsed());
|
||||
println!(
|
||||
"test_frustrated_user_false_claim took: {:?}",
|
||||
start.elapsed()
|
||||
);
|
||||
println!("Full signal analysis completed in {:?}", start.elapsed());
|
||||
}
|
||||
|
||||
|
|
@ -2064,30 +2205,40 @@ mod tests {
|
|||
];
|
||||
let normalized = preprocess_messages(&messages);
|
||||
let signal = analyzer.analyze_frustration(&normalized);
|
||||
assert!(signal.has_frustration, "Polite dissatisfaction should be detected");
|
||||
assert!(
|
||||
signal.has_frustration,
|
||||
"Polite dissatisfaction should be detected"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn test_dissatisfaction_giving_up_without_escalation() {
|
||||
let analyzer = SignalAnalyzer::new();
|
||||
let messages = vec![
|
||||
create_message(Role::User, "Never mind, I'll figure it out myself."),
|
||||
];
|
||||
let messages = vec![create_message(
|
||||
Role::User,
|
||||
"Never mind, I'll figure it out myself.",
|
||||
)];
|
||||
let normalized = preprocess_messages(&messages);
|
||||
let signal = analyzer.analyze_escalation(&normalized);
|
||||
assert!(signal.escalation_requested, "Giving up should count as escalation/quit intent");
|
||||
assert!(
|
||||
signal.escalation_requested,
|
||||
"Giving up should count as escalation/quit intent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dissatisfaction_same_problem_again() {
|
||||
let analyzer = SignalAnalyzer::new();
|
||||
let messages = vec![
|
||||
create_message(Role::User, "I'm running into the same issue again."),
|
||||
];
|
||||
let messages = vec![create_message(
|
||||
Role::User,
|
||||
"I'm running into the same issue again.",
|
||||
)];
|
||||
let normalized = preprocess_messages(&messages);
|
||||
let signal = analyzer.analyze_frustration(&normalized);
|
||||
assert!(signal.has_frustration, "'same issue again' should be detected");
|
||||
assert!(
|
||||
signal.has_frustration,
|
||||
"'same issue again' should be detected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -2096,13 +2247,19 @@ mod tests {
|
|||
let messages = vec![create_message(Role::User, "This feels incomplete.")];
|
||||
let normalized = preprocess_messages(&messages);
|
||||
let signal = analyzer.analyze_frustration(&normalized);
|
||||
assert!(signal.has_frustration, "Should detect 'incomplete' dissatisfaction");
|
||||
assert!(
|
||||
signal.has_frustration,
|
||||
"Should detect 'incomplete' dissatisfaction"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_low_mood_overwhelming() {
|
||||
let analyzer = SignalAnalyzer::new();
|
||||
let messages = vec![create_message(Role::User, "This is overwhelming and I'm not sure what to do.")];
|
||||
let messages = vec![create_message(
|
||||
Role::User,
|
||||
"This is overwhelming and I'm not sure what to do.",
|
||||
)];
|
||||
let normalized = preprocess_messages(&messages);
|
||||
let signal = analyzer.analyze_frustration(&normalized);
|
||||
assert!(signal.has_frustration, "Should detect overwhelmed language");
|
||||
|
|
@ -2111,10 +2268,16 @@ mod tests {
|
|||
#[test]
|
||||
fn test_low_mood_exhausted_trying() {
|
||||
let analyzer = SignalAnalyzer::new();
|
||||
let messages = vec![create_message(Role::User, "I'm exhausted trying to get this working.")];
|
||||
let messages = vec![create_message(
|
||||
Role::User,
|
||||
"I'm exhausted trying to get this working.",
|
||||
)];
|
||||
let normalized = preprocess_messages(&messages);
|
||||
let signal = analyzer.analyze_frustration(&normalized);
|
||||
assert!(signal.has_frustration, "Should detect exhaustion/struggle language");
|
||||
assert!(
|
||||
signal.has_frustration,
|
||||
"Should detect exhaustion/struggle language"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -2122,12 +2285,24 @@ mod tests {
|
|||
let analyzer = SignalAnalyzer::new();
|
||||
let messages = vec![
|
||||
create_message(Role::User, "I'm trying to set up SSH keys for GitHub."),
|
||||
create_message(Role::Assistant, "Sure. First generate a key using ssh-keygen."),
|
||||
create_message(
|
||||
Role::Assistant,
|
||||
"Sure. First generate a key using ssh-keygen.",
|
||||
),
|
||||
create_message(Role::User, "I did that already."),
|
||||
create_message(Role::Assistant, "Then add the key to your GitHub account settings."),
|
||||
create_message(
|
||||
Role::Assistant,
|
||||
"Then add the key to your GitHub account settings.",
|
||||
),
|
||||
create_message(Role::User, "I've done that too."),
|
||||
create_message(Role::Assistant, "After that, make sure your SSH agent is running."),
|
||||
create_message(Role::User, "Okay, but this still doesn't seem to fix the issue."),
|
||||
create_message(
|
||||
Role::Assistant,
|
||||
"After that, make sure your SSH agent is running.",
|
||||
),
|
||||
create_message(
|
||||
Role::User,
|
||||
"Okay, but this still doesn't seem to fix the issue.",
|
||||
),
|
||||
create_message(Role::Assistant, "What error message are you seeing?"),
|
||||
create_message(Role::User, "It's just not connecting the way I expected."),
|
||||
];
|
||||
|
|
@ -2151,13 +2326,22 @@ mod tests {
|
|||
fn test_common_resigned_giving_up_quietly() {
|
||||
let analyzer = SignalAnalyzer::new();
|
||||
let messages = vec![
|
||||
create_message(Role::User, "Can you explain how to deploy this with Docker?"),
|
||||
create_message(Role::Assistant, "You need to write a Dockerfile and build an image."),
|
||||
create_message(
|
||||
Role::User,
|
||||
"Can you explain how to deploy this with Docker?",
|
||||
),
|
||||
create_message(
|
||||
Role::Assistant,
|
||||
"You need to write a Dockerfile and build an image.",
|
||||
),
|
||||
create_message(Role::User, "I tried that."),
|
||||
create_message(Role::Assistant, "Then you can run docker-compose up."),
|
||||
create_message(Role::User, "I did, but it didn’t really help."),
|
||||
create_message(Role::Assistant, "What error are you getting?"),
|
||||
create_message(Role::User, "Honestly, never mind. I’ll just try something else."),
|
||||
create_message(
|
||||
Role::User,
|
||||
"Honestly, never mind. I’ll just try something else.",
|
||||
),
|
||||
];
|
||||
|
||||
let report = analyzer.analyze(&messages);
|
||||
|
|
@ -2169,8 +2353,10 @@ mod tests {
|
|||
);
|
||||
|
||||
assert!(
|
||||
matches!(report.overall_quality, InteractionQuality::Poor | InteractionQuality::Severe)
|
||||
|| report.escalation.escalation_requested
|
||||
matches!(
|
||||
report.overall_quality,
|
||||
InteractionQuality::Poor | InteractionQuality::Severe
|
||||
) || report.escalation.escalation_requested
|
||||
|| report.frustration.has_frustration,
|
||||
"Giving up should not be classified as a high-quality interaction"
|
||||
);
|
||||
|
|
@ -2181,14 +2367,23 @@ mod tests {
|
|||
let analyzer = SignalAnalyzer::new();
|
||||
let messages = vec![
|
||||
create_message(Role::User, "I'm trying to understand backpropagation."),
|
||||
create_message(Role::Assistant, "It's a way to compute gradients efficiently."),
|
||||
create_message(
|
||||
Role::Assistant,
|
||||
"It's a way to compute gradients efficiently.",
|
||||
),
|
||||
create_message(Role::User, "I’ve read that explanation already."),
|
||||
create_message(Role::Assistant, "Would you like a mathematical derivation?"),
|
||||
create_message(Role::User, "Maybe, but I’m still having trouble following."),
|
||||
create_message(Role::Assistant, "I can walk through a simple example."),
|
||||
create_message(Role::User, "That might help, but honestly this is pretty overwhelming."),
|
||||
create_message(
|
||||
Role::User,
|
||||
"That might help, but honestly this is pretty overwhelming.",
|
||||
),
|
||||
create_message(Role::Assistant, "Let’s slow it down step by step."),
|
||||
create_message(Role::User, "Yeah… I’m just feeling kind of discouraged right now."),
|
||||
create_message(
|
||||
Role::User,
|
||||
"Yeah… I’m just feeling kind of discouraged right now.",
|
||||
),
|
||||
];
|
||||
|
||||
let report = analyzer.analyze(&messages);
|
||||
|
|
@ -2210,12 +2405,21 @@ mod tests {
|
|||
let analyzer = SignalAnalyzer::new();
|
||||
let messages = vec![
|
||||
create_message(Role::User, "How do I optimize this SQL query?"),
|
||||
create_message(Role::Assistant, "You can add indexes to improve performance."),
|
||||
create_message(
|
||||
Role::Assistant,
|
||||
"You can add indexes to improve performance.",
|
||||
),
|
||||
create_message(Role::User, "I already have indexes."),
|
||||
create_message(Role::Assistant, "Then you could consider query caching."),
|
||||
create_message(Role::User, "That’s not really what I was asking about."),
|
||||
create_message(Role::Assistant, "What specifically are you trying to optimize?"),
|
||||
create_message(Role::User, "The execution plan — this answer doesn’t address that."),
|
||||
create_message(
|
||||
Role::Assistant,
|
||||
"What specifically are you trying to optimize?",
|
||||
),
|
||||
create_message(
|
||||
Role::User,
|
||||
"The execution plan — this answer doesn’t address that.",
|
||||
),
|
||||
];
|
||||
|
||||
let report = analyzer.analyze(&messages);
|
||||
|
|
@ -2242,7 +2446,10 @@ mod tests {
|
|||
create_message(Role::Assistant, "Did it work?"),
|
||||
create_message(Role::User, "Not quite — it matches more than it should."),
|
||||
create_message(Role::Assistant, "You can refine it with a lookahead."),
|
||||
create_message(Role::User, "I see… this is more complicated than I expected."),
|
||||
create_message(
|
||||
Role::User,
|
||||
"I see… this is more complicated than I expected.",
|
||||
),
|
||||
];
|
||||
|
||||
let report = analyzer.analyze(&messages);
|
||||
|
|
@ -2258,5 +2465,4 @@ mod tests {
|
|||
"Polite disappointment should not be classified as Excellent"
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1 +1,3 @@
|
|||
pub mod signals;
|
||||
mod analyzer;
|
||||
|
||||
pub use analyzer::*;
|
||||
|
|
|
|||
|
|
@ -85,13 +85,19 @@ impl StateStorage for MemoryConversationalStorage {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use hermesllm::apis::openai_responses::{InputItem, InputMessage, MessageRole, InputContent, MessageContent};
|
||||
use hermesllm::apis::openai_responses::{
|
||||
InputContent, InputItem, InputMessage, MessageContent, MessageRole,
|
||||
};
|
||||
|
||||
fn create_test_state(response_id: &str, num_messages: usize) -> OpenAIConversationState {
|
||||
let mut input_items = Vec::new();
|
||||
for i in 0..num_messages {
|
||||
input_items.push(InputItem::Message(InputMessage {
|
||||
role: if i % 2 == 0 { MessageRole::User } else { MessageRole::Assistant },
|
||||
role: if i % 2 == 0 {
|
||||
MessageRole::User
|
||||
} else {
|
||||
MessageRole::Assistant
|
||||
},
|
||||
content: MessageContent::Items(vec![InputContent::InputText {
|
||||
text: format!("Message {}", i),
|
||||
}]),
|
||||
|
|
@ -252,7 +258,9 @@ mod tests {
|
|||
let merged = storage.merge(&prev_state, current_input);
|
||||
|
||||
// Verify order: prev messages first, then current
|
||||
let InputItem::Message(msg) = &merged[0] else { panic!("Expected Message") };
|
||||
let InputItem::Message(msg) = &merged[0] else {
|
||||
panic!("Expected Message")
|
||||
};
|
||||
match &msg.content {
|
||||
MessageContent::Items(items) => match &items[0] {
|
||||
InputContent::InputText { text } => assert_eq!(text, "Message 0"),
|
||||
|
|
@ -261,7 +269,9 @@ mod tests {
|
|||
_ => panic!("Expected MessageContent::Items"),
|
||||
}
|
||||
|
||||
let InputItem::Message(msg) = &merged[2] else { panic!("Expected Message") };
|
||||
let InputItem::Message(msg) = &merged[2] else {
|
||||
panic!("Expected Message")
|
||||
};
|
||||
match &msg.content {
|
||||
MessageContent::Items(items) => match &items[0] {
|
||||
InputContent::InputText { text } => assert_eq!(text, "Message 2"),
|
||||
|
|
@ -404,7 +414,8 @@ mod tests {
|
|||
let current_input = vec![InputItem::Message(InputMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Items(vec![InputContent::InputText {
|
||||
text: "Function result: {\"temperature\": 72, \"condition\": \"sunny\"}".to_string(),
|
||||
text: "Function result: {\"temperature\": 72, \"condition\": \"sunny\"}"
|
||||
.to_string(),
|
||||
}]),
|
||||
})];
|
||||
|
||||
|
|
@ -415,7 +426,9 @@ mod tests {
|
|||
assert_eq!(merged.len(), 3);
|
||||
|
||||
// Verify the order and content
|
||||
let InputItem::Message(msg1) = &merged[0] else { panic!("Expected Message") };
|
||||
let InputItem::Message(msg1) = &merged[0] else {
|
||||
panic!("Expected Message")
|
||||
};
|
||||
assert!(matches!(msg1.role, MessageRole::User));
|
||||
match &msg1.content {
|
||||
MessageContent::Items(items) => match &items[0] {
|
||||
|
|
@ -427,7 +440,9 @@ mod tests {
|
|||
_ => panic!("Expected MessageContent::Items"),
|
||||
}
|
||||
|
||||
let InputItem::Message(msg2) = &merged[1] else { panic!("Expected Message") };
|
||||
let InputItem::Message(msg2) = &merged[1] else {
|
||||
panic!("Expected Message")
|
||||
};
|
||||
assert!(matches!(msg2.role, MessageRole::Assistant));
|
||||
match &msg2.content {
|
||||
MessageContent::Items(items) => match &items[0] {
|
||||
|
|
@ -439,7 +454,9 @@ mod tests {
|
|||
_ => panic!("Expected MessageContent::Items"),
|
||||
}
|
||||
|
||||
let InputItem::Message(msg3) = &merged[2] else { panic!("Expected Message") };
|
||||
let InputItem::Message(msg3) = &merged[2] else {
|
||||
panic!("Expected Message")
|
||||
};
|
||||
assert!(matches!(msg3.role, MessageRole::User));
|
||||
match &msg3.content {
|
||||
MessageContent::Items(items) => match &items[0] {
|
||||
|
|
@ -508,11 +525,15 @@ mod tests {
|
|||
assert_eq!(merged.len(), 5);
|
||||
|
||||
// Verify first item is original user message
|
||||
let InputItem::Message(first) = &merged[0] else { panic!("Expected Message") };
|
||||
let InputItem::Message(first) = &merged[0] else {
|
||||
panic!("Expected Message")
|
||||
};
|
||||
assert!(matches!(first.role, MessageRole::User));
|
||||
|
||||
// Verify last two are function outputs
|
||||
let InputItem::Message(second_last) = &merged[3] else { panic!("Expected Message") };
|
||||
let InputItem::Message(second_last) = &merged[3] else {
|
||||
panic!("Expected Message")
|
||||
};
|
||||
assert!(matches!(second_last.role, MessageRole::User));
|
||||
match &second_last.content {
|
||||
MessageContent::Items(items) => match &items[0] {
|
||||
|
|
@ -522,7 +543,9 @@ mod tests {
|
|||
_ => panic!("Expected MessageContent::Items"),
|
||||
}
|
||||
|
||||
let InputItem::Message(last) = &merged[4] else { panic!("Expected Message") };
|
||||
let InputItem::Message(last) = &merged[4] else {
|
||||
panic!("Expected Message")
|
||||
};
|
||||
assert!(matches!(last.role, MessageRole::User));
|
||||
match &last.content {
|
||||
MessageContent::Items(items) => match &items[0] {
|
||||
|
|
@ -590,7 +613,9 @@ mod tests {
|
|||
assert_eq!(merged.len(), 5);
|
||||
|
||||
// Verify the entire conversation flow is preserved
|
||||
let InputItem::Message(first) = &merged[0] else { panic!("Expected Message") };
|
||||
let InputItem::Message(first) = &merged[0] else {
|
||||
panic!("Expected Message")
|
||||
};
|
||||
match &first.content {
|
||||
MessageContent::Items(items) => match &items[0] {
|
||||
InputContent::InputText { text } => assert!(text.contains("What's the weather")),
|
||||
|
|
@ -599,7 +624,9 @@ mod tests {
|
|||
_ => panic!("Expected MessageContent::Items"),
|
||||
}
|
||||
|
||||
let InputItem::Message(last) = &merged[4] else { panic!("Expected Message") };
|
||||
let InputItem::Message(last) = &merged[4] else {
|
||||
panic!("Expected Message")
|
||||
};
|
||||
match &last.content {
|
||||
MessageContent::Items(items) => match &items[0] {
|
||||
InputContent::InputText { text } => assert!(text.contains("umbrella")),
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
use async_trait::async_trait;
|
||||
use hermesllm::apis::openai_responses::{InputItem, InputMessage, InputContent, MessageContent, MessageRole, InputParam};
|
||||
use hermesllm::apis::openai_responses::{
|
||||
InputContent, InputItem, InputMessage, InputParam, MessageContent, MessageRole,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug};
|
||||
use tracing::debug;
|
||||
|
||||
pub mod memory;
|
||||
pub mod response_state_processor;
|
||||
pub mod postgresql;
|
||||
pub mod response_state_processor;
|
||||
|
||||
/// Represents the conversational state for a v1/responses request
|
||||
/// Contains the complete input/output history that can be restored
|
||||
|
|
@ -47,7 +49,9 @@ pub enum StateStorageError {
|
|||
impl fmt::Display for StateStorageError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
StateStorageError::NotFound(id) => write!(f, "Conversation state not found for response_id: {}", id),
|
||||
StateStorageError::NotFound(id) => {
|
||||
write!(f, "Conversation state not found for response_id: {}", id)
|
||||
}
|
||||
StateStorageError::StorageError(msg) => write!(f, "Storage error: {}", msg),
|
||||
StateStorageError::SerializationError(msg) => write!(f, "Serialization error: {}", msg),
|
||||
}
|
||||
|
|
@ -96,8 +100,6 @@ pub trait StateStorage: Send + Sync {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Storage backend type enum
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StorageBackend {
|
||||
|
|
@ -106,7 +108,7 @@ pub enum StorageBackend {
|
|||
}
|
||||
|
||||
impl StorageBackend {
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
pub fn parse_backend(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"memory" => Some(StorageBackend::Memory),
|
||||
"supabase" => Some(StorageBackend::Supabase),
|
||||
|
|
@ -139,7 +141,6 @@ pub async fn retrieve_and_combine_input(
|
|||
previous_response_id: &str,
|
||||
current_input: Vec<InputItem>,
|
||||
) -> Result<Vec<InputItem>, StateStorageError> {
|
||||
|
||||
// First get the previous state
|
||||
let prev_state = storage.get(previous_response_id).await?;
|
||||
let combined_input = storage.merge(&prev_state, current_input);
|
||||
|
|
|
|||
|
|
@ -149,13 +149,12 @@ impl StateStorage for PostgreSQLConversationStorage {
|
|||
let provider: String = row.get("provider");
|
||||
|
||||
// Deserialize input_items from JSONB
|
||||
let input_items =
|
||||
serde_json::from_value(input_items_json).map_err(|e| {
|
||||
StateStorageError::StorageError(format!(
|
||||
"Failed to deserialize input_items: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
let input_items = serde_json::from_value(input_items_json).map_err(|e| {
|
||||
StateStorageError::StorageError(format!(
|
||||
"Failed to deserialize input_items: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(OpenAIConversationState {
|
||||
response_id,
|
||||
|
|
@ -230,7 +229,9 @@ Run that SQL file against your database before using this storage backend.
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use hermesllm::apis::openai_responses::{InputContent, InputItem, InputMessage, MessageContent, MessageRole};
|
||||
use hermesllm::apis::openai_responses::{
|
||||
InputContent, InputItem, InputMessage, MessageContent, MessageRole,
|
||||
};
|
||||
|
||||
fn create_test_state(response_id: &str) -> OpenAIConversationState {
|
||||
OpenAIConversationState {
|
||||
|
|
@ -320,7 +321,10 @@ mod tests {
|
|||
|
||||
let result = storage.get("nonexistent_id").await;
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), StateStorageError::NotFound(_)));
|
||||
assert!(matches!(
|
||||
result.unwrap_err(),
|
||||
StateStorageError::NotFound(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -372,7 +376,10 @@ mod tests {
|
|||
|
||||
let result = storage.delete("nonexistent_id").await;
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), StateStorageError::NotFound(_)));
|
||||
assert!(matches!(
|
||||
result.unwrap_err(),
|
||||
StateStorageError::NotFound(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -423,9 +430,13 @@ mod tests {
|
|||
|
||||
println!("✅ Data written to Supabase!");
|
||||
println!("Check your Supabase dashboard:");
|
||||
println!(" SELECT * FROM conversation_states WHERE response_id = 'manual_test_verification';");
|
||||
println!(
|
||||
" SELECT * FROM conversation_states WHERE response_id = 'manual_test_verification';"
|
||||
);
|
||||
println!("\nTo cleanup, run:");
|
||||
println!(" DELETE FROM conversation_states WHERE response_id = 'manual_test_verification';");
|
||||
println!(
|
||||
" DELETE FROM conversation_states WHERE response_id = 'manual_test_verification';"
|
||||
);
|
||||
|
||||
// DON'T cleanup - leave it for manual verification
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
use bytes::Bytes;
|
||||
use flate2::read::GzDecoder;
|
||||
use hermesllm::apis::openai_responses::{
|
||||
InputItem, OutputItem, ResponsesAPIStreamEvent,
|
||||
};
|
||||
use hermesllm::apis::openai_responses::{InputItem, OutputItem, ResponsesAPIStreamEvent};
|
||||
use hermesllm::apis::streaming_shapes::sse::SseStreamIter;
|
||||
use hermesllm::transforms::response::output_to_input::outputs_to_inputs;
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
use tracing::{info, debug, warn};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::handlers::utils::StreamProcessor;
|
||||
use crate::state::{OpenAIConversationState, StateStorage};
|
||||
|
|
@ -53,6 +51,7 @@ pub struct ResponsesStateProcessor<P: StreamProcessor> {
|
|||
}
|
||||
|
||||
impl<P: StreamProcessor> ResponsesStateProcessor<P> {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
inner: P,
|
||||
storage: Arc<dyn StateStorage>,
|
||||
|
|
@ -139,20 +138,19 @@ impl<P: StreamProcessor> ResponsesStateProcessor<P> {
|
|||
for event in sse_iter {
|
||||
// Only process data lines (skip event-only lines)
|
||||
if let Some(data_str) = &event.data {
|
||||
// Try to parse as ResponsesAPIStreamEvent
|
||||
if let Ok(stream_event) = serde_json::from_str::<ResponsesAPIStreamEvent>(data_str) {
|
||||
// Check if this is a ResponseCompleted event
|
||||
if let ResponsesAPIStreamEvent::ResponseCompleted { response, .. } = stream_event {
|
||||
info!(
|
||||
"[PLANO_REQ_ID:{}] | STATE_PROCESSOR | Captured streaming response.completed: response_id={}, output_items={}",
|
||||
self.request_id,
|
||||
response.id,
|
||||
response.output.len()
|
||||
);
|
||||
self.response_id = Some(response.id.clone());
|
||||
self.output_items = Some(response.output.clone());
|
||||
return; // Found what we need, exit early
|
||||
}
|
||||
// Try to parse as ResponsesAPIStreamEvent and check if it's a ResponseCompleted event
|
||||
if let Ok(ResponsesAPIStreamEvent::ResponseCompleted { response, .. }) =
|
||||
serde_json::from_str::<ResponsesAPIStreamEvent>(data_str)
|
||||
{
|
||||
info!(
|
||||
"[PLANO_REQ_ID:{}] | STATE_PROCESSOR | Captured streaming response.completed: response_id={}, output_items={}",
|
||||
self.request_id,
|
||||
response.id,
|
||||
response.output.len()
|
||||
);
|
||||
self.response_id = Some(response.id.clone());
|
||||
self.output_items = Some(response.output.clone());
|
||||
return; // Found what we need, exit early
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -172,7 +170,9 @@ impl<P: StreamProcessor> ResponsesStateProcessor<P> {
|
|||
let decompressed = self.decompress_buffer();
|
||||
|
||||
// Parse complete JSON response
|
||||
match serde_json::from_slice::<hermesllm::apis::openai_responses::ResponsesAPIResponse>(&decompressed) {
|
||||
match serde_json::from_slice::<hermesllm::apis::openai_responses::ResponsesAPIResponse>(
|
||||
&decompressed,
|
||||
) {
|
||||
Ok(response) => {
|
||||
info!(
|
||||
"[PLANO_REQ_ID:{}] | STATE_PROCESSOR | Captured non-streaming response: response_id={}, output_items={}",
|
||||
|
|
|
|||
|
|
@ -2,11 +2,9 @@
|
|||
///
|
||||
/// This module defines standard attribute keys following OTEL semantic conventions.
|
||||
/// See: https://opentelemetry.io/docs/specs/semconv/
|
||||
|
||||
// =============================================================================
|
||||
// Span Attributes - HTTP
|
||||
// =============================================================================
|
||||
|
||||
/// Semantic conventions for HTTP-related span attributes
|
||||
pub mod http {
|
||||
/// HTTP request method
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
mod constants;
|
||||
|
||||
pub use constants::{OperationNameBuilder, operation_component, http, llm, error, routing, signals};
|
||||
pub use constants::{
|
||||
error, http, llm, operation_component, routing, signals, OperationNameBuilder,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -295,11 +295,14 @@ impl serde::Serialize for OrchestrationPreference {
|
|||
let mut state = serializer.serialize_struct("OrchestrationPreference", 3)?;
|
||||
state.serialize_field("name", &self.name)?;
|
||||
state.serialize_field("description", &self.description)?;
|
||||
state.serialize_field("parameters", &serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}))?;
|
||||
state.serialize_field(
|
||||
"parameters",
|
||||
&serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}),
|
||||
)?;
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
|
|
@ -489,7 +492,10 @@ mod test {
|
|||
assert_eq!(config.version, "v0.3.0");
|
||||
|
||||
if let Some(prompt_targets) = &config.prompt_targets {
|
||||
assert!(!prompt_targets.is_empty(), "prompt_targets should not be empty if present");
|
||||
assert!(
|
||||
!prompt_targets.is_empty(),
|
||||
"prompt_targets should not be empty if present"
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(tracing) = config.tracing.as_ref() {
|
||||
|
|
@ -510,19 +516,48 @@ mod test {
|
|||
.expect("reference config file not found");
|
||||
let config: super::Configuration = serde_yaml::from_str(&ref_config).unwrap();
|
||||
if let Some(prompt_targets) = &config.prompt_targets {
|
||||
if let Some(prompt_target) = prompt_targets.iter().find(|p| p.name == "reboot_network_device") {
|
||||
if let Some(prompt_target) = prompt_targets
|
||||
.iter()
|
||||
.find(|p| p.name == "reboot_network_device")
|
||||
{
|
||||
let chat_completion_tool: super::ChatCompletionTool = prompt_target.into();
|
||||
assert_eq!(chat_completion_tool.tool_type, ToolType::Function);
|
||||
assert_eq!(chat_completion_tool.function.name, "reboot_network_device");
|
||||
assert_eq!(chat_completion_tool.function.description, "Reboot a specific network device");
|
||||
assert_eq!(
|
||||
chat_completion_tool.function.description,
|
||||
"Reboot a specific network device"
|
||||
);
|
||||
assert_eq!(chat_completion_tool.function.parameters.properties.len(), 2);
|
||||
assert!(chat_completion_tool.function.parameters.properties.contains_key("device_id"));
|
||||
let device_id_param = chat_completion_tool.function.parameters.properties.get("device_id").unwrap();
|
||||
assert_eq!(device_id_param.parameter_type, crate::api::open_ai::ParameterType::String);
|
||||
assert_eq!(device_id_param.description, "Identifier of the network device to reboot.".to_string());
|
||||
assert!(chat_completion_tool
|
||||
.function
|
||||
.parameters
|
||||
.properties
|
||||
.contains_key("device_id"));
|
||||
let device_id_param = chat_completion_tool
|
||||
.function
|
||||
.parameters
|
||||
.properties
|
||||
.get("device_id")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
device_id_param.parameter_type,
|
||||
crate::api::open_ai::ParameterType::String
|
||||
);
|
||||
assert_eq!(
|
||||
device_id_param.description,
|
||||
"Identifier of the network device to reboot.".to_string()
|
||||
);
|
||||
assert_eq!(device_id_param.required, Some(true));
|
||||
let confirmation_param = chat_completion_tool.function.parameters.properties.get("confirmation").unwrap();
|
||||
assert_eq!(confirmation_param.parameter_type, crate::api::open_ai::ParameterType::Bool);
|
||||
let confirmation_param = chat_completion_tool
|
||||
.function
|
||||
.parameters
|
||||
.properties
|
||||
.get("confirmation")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
confirmation_param.parameter_type,
|
||||
crate::api::open_ai::ParameterType::Bool
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,6 @@ pub const OTEL_COLLECTOR_HTTP: &str = "opentelemetry_collector_http";
|
|||
pub const OTEL_POST_PATH: &str = "/v1/traces";
|
||||
pub const LLM_ROUTE_HEADER: &str = "x-arch-llm-route";
|
||||
pub const ENVOY_RETRY_HEADER: &str = "x-envoy-max-retries";
|
||||
pub const BRIGHT_STAFF_SERVICE_NAME : &str = "brightstaff";
|
||||
pub const BRIGHT_STAFF_SERVICE_NAME: &str = "brightstaff";
|
||||
pub const PLANO_ORCHESTRATOR_MODEL_NAME: &str = "Plano-Orchestrator";
|
||||
pub const ARCH_FC_CLUSTER: &str = "arch";
|
||||
|
|
|
|||
|
|
@ -10,6 +10,6 @@ pub mod ratelimit;
|
|||
pub mod routing;
|
||||
pub mod stats;
|
||||
pub mod tokenizer;
|
||||
pub mod tracing;
|
||||
pub mod traces;
|
||||
pub mod tracing;
|
||||
pub mod utils;
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ pub fn get_llm_provider(
|
|||
llm_providers
|
||||
.iter()
|
||||
.filter(|(_, provider)| {
|
||||
provider.model
|
||||
provider
|
||||
.model
|
||||
.as_ref()
|
||||
.map(|m| !m.starts_with("Arch"))
|
||||
.unwrap_or(true)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use super::shapes::Span;
|
||||
use super::resource_span_builder::ResourceSpanBuilder;
|
||||
use super::shapes::Span;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
|
@ -160,7 +160,11 @@ impl TraceCollector {
|
|||
}
|
||||
|
||||
let total_spans: usize = service_batches.iter().map(|(_, spans)| spans.len()).sum();
|
||||
debug!("Flushing {} spans across {} services to OTEL collector", total_spans, service_batches.len());
|
||||
debug!(
|
||||
"Flushing {} spans across {} services to OTEL collector",
|
||||
total_spans,
|
||||
service_batches.len()
|
||||
);
|
||||
|
||||
// Build canonical OTEL payload structure - one ResourceSpan per service
|
||||
let resource_spans = self.build_resource_spans(service_batches);
|
||||
|
|
@ -178,7 +182,10 @@ impl TraceCollector {
|
|||
}
|
||||
|
||||
/// Build OTEL-compliant resource spans from collected spans, one ResourceSpan per service
|
||||
fn build_resource_spans(&self, service_batches: Vec<(String, Vec<Span>)>) -> Vec<super::shapes::ResourceSpan> {
|
||||
fn build_resource_spans(
|
||||
&self,
|
||||
service_batches: Vec<(String, Vec<Span>)>,
|
||||
) -> Vec<super::shapes::ResourceSpan> {
|
||||
service_batches
|
||||
.into_iter()
|
||||
.map(|(service_name, spans)| {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
/// OpenTelemetry semantic convention constants for tracing
|
||||
///
|
||||
/// These constants ensure consistency across the codebase and prevent typos
|
||||
|
||||
/// Resource attribute keys following OTEL semantic conventions
|
||||
pub mod resource {
|
||||
/// Logical name of the service
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
// Original tracing types (OTEL structures)
|
||||
mod shapes;
|
||||
// New tracing utilities
|
||||
mod span_builder;
|
||||
mod resource_span_builder;
|
||||
mod constants;
|
||||
mod resource_span_builder;
|
||||
mod span_builder;
|
||||
|
||||
#[cfg(feature = "trace-collection")]
|
||||
mod collector;
|
||||
|
|
@ -13,14 +13,14 @@ mod tests;
|
|||
|
||||
// Re-export original types
|
||||
pub use shapes::{
|
||||
Span, Event, Traceparent, TraceparentNewError,
|
||||
ResourceSpan, Resource, ScopeSpan, Scope, Attribute, AttributeValue,
|
||||
Attribute, AttributeValue, Event, Resource, ResourceSpan, Scope, ScopeSpan, Span, Traceparent,
|
||||
TraceparentNewError,
|
||||
};
|
||||
|
||||
// Re-export new utilities
|
||||
pub use span_builder::{SpanBuilder, SpanKind, generate_random_span_id};
|
||||
pub use resource_span_builder::ResourceSpanBuilder;
|
||||
pub use constants::*;
|
||||
pub use resource_span_builder::ResourceSpanBuilder;
|
||||
pub use span_builder::{generate_random_span_id, SpanBuilder, SpanKind};
|
||||
|
||||
#[cfg(feature = "trace-collection")]
|
||||
pub use collector::{TraceCollector, parse_traceparent};
|
||||
pub use collector::{parse_traceparent, TraceCollector};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use super::shapes::{ResourceSpan, Resource, ScopeSpan, Scope, Span, Attribute, AttributeValue};
|
||||
use super::constants::{resource, scope};
|
||||
use super::shapes::{Attribute, AttributeValue, Resource, ResourceSpan, Scope, ScopeSpan, Span};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Builder for creating OTEL ResourceSpan structures
|
||||
|
|
@ -26,7 +26,11 @@ impl ResourceSpanBuilder {
|
|||
}
|
||||
|
||||
/// Add a resource attribute (e.g., deployment.environment, host.name)
|
||||
pub fn with_resource_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
pub fn with_resource_attribute(
|
||||
mut self,
|
||||
key: impl Into<String>,
|
||||
value: impl Into<String>,
|
||||
) -> Self {
|
||||
self.resource_attributes.insert(key.into(), value.into());
|
||||
self
|
||||
}
|
||||
|
|
@ -58,14 +62,12 @@ impl ResourceSpanBuilder {
|
|||
/// Build the ResourceSpan
|
||||
pub fn build(self) -> ResourceSpan {
|
||||
// Build resource attributes
|
||||
let mut attributes = vec![
|
||||
Attribute {
|
||||
key: resource::SERVICE_NAME.to_string(),
|
||||
value: AttributeValue {
|
||||
string_value: Some(self.service_name),
|
||||
},
|
||||
}
|
||||
];
|
||||
let mut attributes = vec![Attribute {
|
||||
key: resource::SERVICE_NAME.to_string(),
|
||||
value: AttributeValue {
|
||||
string_value: Some(self.service_name),
|
||||
},
|
||||
}];
|
||||
|
||||
// Add custom resource attributes
|
||||
for (key, value) in self.resource_attributes {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use super::shapes::{Span, Attribute, AttributeValue};
|
||||
use super::shapes::{Attribute, AttributeValue, Span};
|
||||
use std::collections::HashMap;
|
||||
use std::time::SystemTime;
|
||||
|
||||
|
|
@ -116,10 +116,11 @@ impl SpanBuilder {
|
|||
let end_nanos = system_time_to_nanos(end_time);
|
||||
|
||||
// Generate trace_id if not provided
|
||||
let trace_id = self.trace_id.unwrap_or_else(|| generate_random_trace_id());
|
||||
let trace_id = self.trace_id.unwrap_or_else(generate_random_trace_id);
|
||||
|
||||
// Create attributes in OTEL format
|
||||
let attributes: Vec<Attribute> = self.attributes
|
||||
let attributes: Vec<Attribute> = self
|
||||
.attributes
|
||||
.into_iter()
|
||||
.map(|(key, value)| Attribute {
|
||||
key,
|
||||
|
|
@ -132,7 +133,7 @@ impl SpanBuilder {
|
|||
// Build span directly without going through Span::new()
|
||||
Span {
|
||||
trace_id,
|
||||
span_id: self.span_id.unwrap_or_else(|| generate_random_span_id()),
|
||||
span_id: self.span_id.unwrap_or_else(generate_random_span_id),
|
||||
parent_span_id: self.parent_span_id,
|
||||
name: self.name,
|
||||
start_time_unix_nano: format!("{}", start_nanos),
|
||||
|
|
|
|||
|
|
@ -21,10 +21,7 @@ use tokio::sync::RwLock;
|
|||
type SharedTraces = Arc<RwLock<Vec<Value>>>;
|
||||
|
||||
/// POST /v1/traces - capture incoming OTLP payload
|
||||
async fn post_traces(
|
||||
State(traces): State<SharedTraces>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> StatusCode {
|
||||
async fn post_traces(State(traces): State<SharedTraces>, Json(payload): Json<Value>) -> StatusCode {
|
||||
traces.write().await.push(payload);
|
||||
StatusCode::OK
|
||||
}
|
||||
|
|
@ -67,9 +64,7 @@ impl MockOtelCollector {
|
|||
let address = format!("http://127.0.0.1:{}", addr.port());
|
||||
|
||||
let server_handle = tokio::spawn(async move {
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("Server failed");
|
||||
axum::serve(listener, app).await.expect("Server failed");
|
||||
});
|
||||
|
||||
// Give server a moment to start
|
||||
|
|
|
|||
|
|
@ -36,9 +36,12 @@ fn extract_spans(payloads: &[Value]) -> Vec<&Value> {
|
|||
for payload in payloads {
|
||||
if let Some(resource_spans) = payload.get("resourceSpans").and_then(|v| v.as_array()) {
|
||||
for resource_span in resource_spans {
|
||||
if let Some(scope_spans) = resource_span.get("scopeSpans").and_then(|v| v.as_array()) {
|
||||
if let Some(scope_spans) =
|
||||
resource_span.get("scopeSpans").and_then(|v| v.as_array())
|
||||
{
|
||||
for scope_span in scope_spans {
|
||||
if let Some(span_list) = scope_span.get("spans").and_then(|v| v.as_array()) {
|
||||
if let Some(span_list) = scope_span.get("spans").and_then(|v| v.as_array())
|
||||
{
|
||||
spans.extend(span_list.iter());
|
||||
}
|
||||
}
|
||||
|
|
@ -54,9 +57,9 @@ fn get_string_attr<'a>(span: &'a Value, key: &str) -> Option<&'a str> {
|
|||
span.get("attributes")
|
||||
.and_then(|attrs| attrs.as_array())
|
||||
.and_then(|attrs| {
|
||||
attrs.iter().find(|attr| {
|
||||
attr.get("key").and_then(|k| k.as_str()) == Some(key)
|
||||
})
|
||||
attrs
|
||||
.iter()
|
||||
.find(|attr| attr.get("key").and_then(|k| k.as_str()) == Some(key))
|
||||
})
|
||||
.and_then(|attr| attr.get("value"))
|
||||
.and_then(|v| v.get("stringValue"))
|
||||
|
|
@ -70,7 +73,10 @@ async fn test_llm_span_contains_basic_attributes() {
|
|||
let mock_collector = MockOtelCollector::start().await;
|
||||
|
||||
// Create TraceCollector pointing to mock with 500ms flush intervalc
|
||||
std::env::set_var("OTEL_COLLECTOR_URL", format!("{}/v1/traces", mock_collector.address()));
|
||||
std::env::set_var(
|
||||
"OTEL_COLLECTOR_URL",
|
||||
format!("{}/v1/traces", mock_collector.address()),
|
||||
);
|
||||
std::env::set_var("OTEL_TRACING_ENABLED", "true");
|
||||
std::env::set_var("TRACE_FLUSH_INTERVAL_MS", "500");
|
||||
let trace_collector = Arc::new(TraceCollector::new(Some(true)));
|
||||
|
|
@ -102,7 +108,10 @@ async fn test_llm_span_contains_basic_attributes() {
|
|||
let span = spans[0];
|
||||
// Validate HTTP attributes
|
||||
assert_eq!(get_string_attr(span, "http.method"), Some("POST"));
|
||||
assert_eq!(get_string_attr(span, "http.target"), Some("/v1/chat/completions"));
|
||||
assert_eq!(
|
||||
get_string_attr(span, "http.target"),
|
||||
Some("/v1/chat/completions")
|
||||
);
|
||||
|
||||
// Validate LLM attributes
|
||||
assert_eq!(get_string_attr(span, "llm.model"), Some("gpt-4o"));
|
||||
|
|
@ -115,7 +124,10 @@ async fn test_llm_span_contains_basic_attributes() {
|
|||
#[serial]
|
||||
async fn test_llm_span_contains_tool_information() {
|
||||
let mock_collector = MockOtelCollector::start().await;
|
||||
std::env::set_var("OTEL_COLLECTOR_URL", format!("{}/v1/traces", mock_collector.address()));
|
||||
std::env::set_var(
|
||||
"OTEL_COLLECTOR_URL",
|
||||
format!("{}/v1/traces", mock_collector.address()),
|
||||
);
|
||||
std::env::set_var("OTEL_TRACING_ENABLED", "true");
|
||||
std::env::set_var("TRACE_FLUSH_INTERVAL_MS", "500");
|
||||
let trace_collector = Arc::new(TraceCollector::new(Some(true)));
|
||||
|
|
@ -144,19 +156,26 @@ async fn test_llm_span_contains_tool_information() {
|
|||
assert!(tools.unwrap().contains("get_weather(...)"));
|
||||
assert!(tools.unwrap().contains("search_web(...)"));
|
||||
assert!(tools.unwrap().contains("calculate(...)"));
|
||||
assert!(tools.unwrap().contains('\n'), "Tools should be newline-separated");
|
||||
assert!(
|
||||
tools.unwrap().contains('\n'),
|
||||
"Tools should be newline-separated"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_llm_span_contains_user_message_preview() {
|
||||
let mock_collector = MockOtelCollector::start().await;
|
||||
std::env::set_var("OTEL_COLLECTOR_URL", format!("{}/v1/traces", mock_collector.address()));
|
||||
std::env::set_var(
|
||||
"OTEL_COLLECTOR_URL",
|
||||
format!("{}/v1/traces", mock_collector.address()),
|
||||
);
|
||||
std::env::set_var("OTEL_TRACING_ENABLED", "true");
|
||||
std::env::set_var("TRACE_FLUSH_INTERVAL_MS", "500");
|
||||
let trace_collector = Arc::new(TraceCollector::new(Some(true)));
|
||||
|
||||
let long_message = "This is a very long user message that should be truncated to 50 characters in the span";
|
||||
let long_message =
|
||||
"This is a very long user message that should be truncated to 50 characters in the span";
|
||||
let preview = if long_message.len() > 50 {
|
||||
format!("{}...", &long_message[..50])
|
||||
} else {
|
||||
|
|
@ -187,7 +206,10 @@ async fn test_llm_span_contains_user_message_preview() {
|
|||
#[serial]
|
||||
async fn test_llm_span_contains_time_to_first_token() {
|
||||
let mock_collector = MockOtelCollector::start().await;
|
||||
std::env::set_var("OTEL_COLLECTOR_URL", format!("{}/v1/traces", mock_collector.address()));
|
||||
std::env::set_var(
|
||||
"OTEL_COLLECTOR_URL",
|
||||
format!("{}/v1/traces", mock_collector.address()),
|
||||
);
|
||||
std::env::set_var("OTEL_TRACING_ENABLED", "true");
|
||||
std::env::set_var("TRACE_FLUSH_INTERVAL_MS", "500");
|
||||
let trace_collector = Arc::new(TraceCollector::new(Some(true)));
|
||||
|
|
@ -217,7 +239,10 @@ async fn test_llm_span_contains_time_to_first_token() {
|
|||
#[serial]
|
||||
async fn test_llm_span_contains_upstream_path() {
|
||||
let mock_collector = MockOtelCollector::start().await;
|
||||
std::env::set_var("OTEL_COLLECTOR_URL", format!("{}/v1/traces", mock_collector.address()));
|
||||
std::env::set_var(
|
||||
"OTEL_COLLECTOR_URL",
|
||||
format!("{}/v1/traces", mock_collector.address()),
|
||||
);
|
||||
std::env::set_var("OTEL_TRACING_ENABLED", "true");
|
||||
std::env::set_var("TRACE_FLUSH_INTERVAL_MS", "500");
|
||||
let trace_collector = Arc::new(TraceCollector::new(Some(true)));
|
||||
|
|
@ -241,7 +266,10 @@ async fn test_llm_span_contains_upstream_path() {
|
|||
// Operation name should show the transformation
|
||||
let name = span.get("name").and_then(|v| v.as_str());
|
||||
assert!(name.is_some());
|
||||
assert!(name.unwrap().contains(">>"), "Operation name should show path transformation");
|
||||
assert!(
|
||||
name.unwrap().contains(">>"),
|
||||
"Operation name should show path transformation"
|
||||
);
|
||||
|
||||
// Check upstream target attribute
|
||||
let upstream = get_string_attr(span, "http.upstream_target");
|
||||
|
|
@ -252,7 +280,10 @@ async fn test_llm_span_contains_upstream_path() {
|
|||
#[serial]
|
||||
async fn test_llm_span_multiple_services() {
|
||||
let mock_collector = MockOtelCollector::start().await;
|
||||
std::env::set_var("OTEL_COLLECTOR_URL", format!("{}/v1/traces", mock_collector.address()));
|
||||
std::env::set_var(
|
||||
"OTEL_COLLECTOR_URL",
|
||||
format!("{}/v1/traces", mock_collector.address()),
|
||||
);
|
||||
std::env::set_var("OTEL_TRACING_ENABLED", "true");
|
||||
std::env::set_var("TRACE_FLUSH_INTERVAL_MS", "500");
|
||||
let trace_collector = Arc::new(TraceCollector::new(Some(true)));
|
||||
|
|
@ -285,7 +316,10 @@ async fn test_tracing_disabled_produces_no_spans() {
|
|||
let mock_collector = MockOtelCollector::start().await;
|
||||
|
||||
// Create TraceCollector with tracing DISABLED
|
||||
std::env::set_var("OTEL_COLLECTOR_URL", format!("{}/v1/traces", mock_collector.address()));
|
||||
std::env::set_var(
|
||||
"OTEL_COLLECTOR_URL",
|
||||
format!("{}/v1/traces", mock_collector.address()),
|
||||
);
|
||||
std::env::set_var("OTEL_TRACING_ENABLED", "false");
|
||||
std::env::set_var("TRACE_FLUSH_INTERVAL_MS", "500");
|
||||
let trace_collector = Arc::new(TraceCollector::new(Some(false)));
|
||||
|
|
@ -300,5 +334,9 @@ async fn test_tracing_disabled_produces_no_spans() {
|
|||
|
||||
let payloads = mock_collector.get_traces().await;
|
||||
let all_spans = extract_spans(&payloads);
|
||||
assert_eq!(all_spans.len(), 0, "No spans should be captured when tracing is disabled");
|
||||
assert_eq!(
|
||||
all_spans.len(),
|
||||
0,
|
||||
"No spans should be captured when tracing is disabled"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,13 +161,12 @@ impl TraceData {
|
|||
}
|
||||
|
||||
pub fn new_with_service_name(service_name: String) -> Self {
|
||||
let mut resource_attributes = Vec::new();
|
||||
resource_attributes.push(Attribute {
|
||||
let resource_attributes = vec![Attribute {
|
||||
key: "service.name".to_string(),
|
||||
value: AttributeValue {
|
||||
string_value: Some(service_name),
|
||||
},
|
||||
});
|
||||
}];
|
||||
|
||||
let resource = Resource {
|
||||
attributes: resource_attributes,
|
||||
|
|
@ -194,7 +193,9 @@ impl TraceData {
|
|||
|
||||
pub fn add_span(&mut self, span: Span) {
|
||||
if self.resource_spans.is_empty() {
|
||||
let resource = Resource { attributes: Vec::new() };
|
||||
let resource = Resource {
|
||||
attributes: Vec::new(),
|
||||
};
|
||||
let scope_span = ScopeSpan {
|
||||
scope: Scope {
|
||||
name: "default".to_string(),
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ impl ApiDefinition for AmazonBedrockApi {
|
|||
|
||||
/// Amazon Bedrock Converse request
|
||||
#[skip_serializing_none]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct ConverseRequest {
|
||||
/// The model ID or ARN to invoke
|
||||
pub model_id: String,
|
||||
|
|
@ -91,7 +91,7 @@ pub struct ConverseRequest {
|
|||
pub additional_model_response_field_paths: Option<Vec<String>>,
|
||||
/// Performance configuration
|
||||
#[serde(rename = "performanceConfig")]
|
||||
pub performance_config: Option<PerformanceConfiguration>,
|
||||
pub performance_config: Option<InferenceConfiguration>,
|
||||
/// Prompt variables for Prompt management
|
||||
#[serde(rename = "promptVariables")]
|
||||
pub prompt_variables: Option<HashMap<String, PromptVariableValues>>,
|
||||
|
|
@ -105,26 +105,6 @@ pub struct ConverseRequest {
|
|||
pub stream: bool,
|
||||
}
|
||||
|
||||
impl Default for ConverseRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model_id: String::new(),
|
||||
messages: None,
|
||||
system: None,
|
||||
inference_config: None,
|
||||
tool_config: None,
|
||||
guardrail_config: None,
|
||||
additional_model_request_fields: None,
|
||||
additional_model_response_field_paths: None,
|
||||
performance_config: None,
|
||||
prompt_variables: None,
|
||||
request_metadata: None,
|
||||
metadata: None,
|
||||
stream: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Amazon Bedrock ConverseStream request (same structure as Converse)
|
||||
pub type ConverseStreamRequest = ConverseRequest;
|
||||
|
||||
|
|
@ -204,8 +184,8 @@ impl ProviderRequest for ConverseRequest {
|
|||
self.tool_config.as_ref()?.tools.as_ref().map(|tools| {
|
||||
tools
|
||||
.iter()
|
||||
.filter_map(|tool| match tool {
|
||||
Tool::ToolSpec { tool_spec } => Some(tool_spec.name.clone()),
|
||||
.map(|tool| match tool {
|
||||
Tool::ToolSpec { tool_spec } => tool_spec.name.clone(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
|
|
@ -242,17 +222,14 @@ impl ProviderRequest for ConverseRequest {
|
|||
// Add system messages if present
|
||||
if let Some(system) = &self.system {
|
||||
for sys_block in system {
|
||||
match sys_block {
|
||||
SystemContentBlock::Text { text } => {
|
||||
openai_messages.push(Message {
|
||||
role: Role::System,
|
||||
content: MessageContent::Text(text.clone()),
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
}
|
||||
_ => {} // Skip other system content types
|
||||
if let SystemContentBlock::Text { text } = sys_block {
|
||||
openai_messages.push(Message {
|
||||
role: Role::System,
|
||||
content: MessageContent::Text(text.clone()),
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -266,7 +243,9 @@ impl ProviderRequest for ConverseRequest {
|
|||
};
|
||||
|
||||
// Extract text from content blocks
|
||||
let content = msg.content.iter()
|
||||
let content = msg
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|block| {
|
||||
if let ContentBlock::Text { text } = block {
|
||||
Some(text.clone())
|
||||
|
|
@ -311,16 +290,14 @@ impl ProviderRequest for ConverseRequest {
|
|||
_ => continue,
|
||||
};
|
||||
|
||||
let content = if let crate::apis::openai::MessageContent::Text(text) = &msg.content {
|
||||
vec![ContentBlock::Text { text: text.clone() }]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let content =
|
||||
if let crate::apis::openai::MessageContent::Text(text) = &msg.content {
|
||||
vec![ContentBlock::Text { text: text.clone() }]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
bedrock_messages.push(crate::apis::amazon_bedrock::Message {
|
||||
role,
|
||||
content,
|
||||
});
|
||||
bedrock_messages.push(crate::apis::amazon_bedrock::Message { role, content });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
@ -369,7 +346,7 @@ pub enum ConverseStreamEvent {
|
|||
ContentBlockDelta(ContentBlockDeltaEvent),
|
||||
ContentBlockStop(ContentBlockStopEvent),
|
||||
MessageStop(MessageStopEvent),
|
||||
Metadata(ConverseStreamMetadataEvent),
|
||||
Metadata(Box<ConverseStreamMetadataEvent>),
|
||||
// Error events
|
||||
InternalServerException(BedrockException),
|
||||
ModelStreamErrorException(BedrockException),
|
||||
|
|
@ -1063,7 +1040,7 @@ impl TryFrom<&aws_smithy_eventstream::frame::DecodedFrame> for ConverseStreamEve
|
|||
"metadata" => {
|
||||
let event: ConverseStreamMetadataEvent =
|
||||
serde_json::from_slice(payload).map_err(BedrockError::Serialization)?;
|
||||
Ok(ConverseStreamEvent::Metadata(event))
|
||||
Ok(ConverseStreamEvent::Metadata(Box::new(event)))
|
||||
}
|
||||
unknown => Err(BedrockError::Validation {
|
||||
message: format!("Unknown event type: {}", unknown),
|
||||
|
|
@ -1106,10 +1083,10 @@ impl TryFrom<&aws_smithy_eventstream::frame::DecodedFrame> for ConverseStreamEve
|
|||
}
|
||||
}
|
||||
|
||||
impl Into<String> for ConverseStreamEvent {
|
||||
fn into(self) -> String {
|
||||
let transformed_json = serde_json::to_string(&self).unwrap_or_default();
|
||||
let event_type = match &self {
|
||||
impl From<ConverseStreamEvent> for String {
|
||||
fn from(val: ConverseStreamEvent) -> String {
|
||||
let transformed_json = serde_json::to_string(&val).unwrap_or_default();
|
||||
let event_type = match &val {
|
||||
ConverseStreamEvent::MessageStart { .. } => "message_start",
|
||||
ConverseStreamEvent::ContentBlockStart { .. } => "content_block_start",
|
||||
ConverseStreamEvent::ContentBlockDelta { .. } => "content_block_delta",
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -286,7 +286,6 @@ pub struct ImageUrl {
|
|||
}
|
||||
|
||||
/// A single message in a chat conversation
|
||||
|
||||
/// A tool call made by the assistant
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct ToolCall {
|
||||
|
|
@ -388,7 +387,7 @@ pub enum StaticContentType {
|
|||
|
||||
/// Chat completions API response
|
||||
#[skip_serializing_none]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct ChatCompletionsResponse {
|
||||
pub id: String,
|
||||
pub object: Option<String>,
|
||||
|
|
@ -402,22 +401,6 @@ pub struct ChatCompletionsResponse {
|
|||
pub metadata: Option<HashMap<String, Value>>,
|
||||
}
|
||||
|
||||
impl Default for ChatCompletionsResponse {
|
||||
fn default() -> Self {
|
||||
ChatCompletionsResponse {
|
||||
id: String::new(),
|
||||
object: None,
|
||||
created: 0,
|
||||
model: String::new(),
|
||||
choices: vec![],
|
||||
usage: Usage::default(),
|
||||
system_fingerprint: None,
|
||||
service_tier: None,
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish reason for completion
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
|
|
@ -431,7 +414,7 @@ pub enum FinishReason {
|
|||
|
||||
/// Token usage information
|
||||
#[skip_serializing_none]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct Usage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
|
|
@ -440,18 +423,6 @@ pub struct Usage {
|
|||
pub completion_tokens_details: Option<CompletionTokensDetails>,
|
||||
}
|
||||
|
||||
impl Default for Usage {
|
||||
fn default() -> Self {
|
||||
Usage {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
prompt_tokens_details: None,
|
||||
completion_tokens_details: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detailed breakdown of prompt tokens
|
||||
#[skip_serializing_none]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
|
|
@ -472,7 +443,7 @@ pub struct CompletionTokensDetails {
|
|||
|
||||
/// A single choice in the response
|
||||
#[skip_serializing_none]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct Choice {
|
||||
pub index: u32,
|
||||
pub message: ResponseMessage,
|
||||
|
|
@ -480,17 +451,6 @@ pub struct Choice {
|
|||
pub logprobs: Option<Value>,
|
||||
}
|
||||
|
||||
impl Default for Choice {
|
||||
fn default() -> Self {
|
||||
Choice {
|
||||
index: 0,
|
||||
message: ResponseMessage::default(),
|
||||
finish_reason: None,
|
||||
logprobs: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STREAMING API TYPES
|
||||
// ============================================================================
|
||||
|
|
@ -608,7 +568,6 @@ pub enum OpenAIError {
|
|||
// ============================================================================
|
||||
/// Trait Implementations
|
||||
/// ===========================================================================
|
||||
|
||||
/// Parameterized conversion for ChatCompletionsRequest
|
||||
impl TryFrom<&[u8]> for ChatCompletionsRequest {
|
||||
type Error = OpenAIStreamError;
|
||||
|
|
@ -721,7 +680,7 @@ impl ProviderRequest for ChatCompletionsRequest {
|
|||
}
|
||||
|
||||
fn metadata(&self) -> &Option<HashMap<String, Value>> {
|
||||
return &self.metadata;
|
||||
&self.metadata
|
||||
}
|
||||
|
||||
fn remove_metadata_key(&mut self, key: &str) -> bool {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use std::collections::HashMap;
|
||||
use crate::providers::request::{ProviderRequest, ProviderRequestError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::skip_serializing_none;
|
||||
use crate::providers::request::{ProviderRequest, ProviderRequestError};
|
||||
use std::collections::HashMap;
|
||||
|
||||
impl TryFrom<&[u8]> for ResponsesAPIRequest {
|
||||
type Error = serde_json::Error;
|
||||
|
|
@ -172,18 +172,14 @@ pub enum MessageRole {
|
|||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum InputContent {
|
||||
/// Text input
|
||||
InputText {
|
||||
text: String,
|
||||
},
|
||||
InputText { text: String },
|
||||
/// Image input via URL
|
||||
InputImage {
|
||||
image_url: String,
|
||||
detail: Option<String>,
|
||||
},
|
||||
/// File input via URL
|
||||
InputFile {
|
||||
file_url: String,
|
||||
},
|
||||
InputFile { file_url: String },
|
||||
/// Audio input
|
||||
InputAudio {
|
||||
data: Option<String>,
|
||||
|
|
@ -222,9 +218,7 @@ pub struct TextConfig {
|
|||
pub enum TextFormat {
|
||||
Text,
|
||||
JsonObject,
|
||||
JsonSchema {
|
||||
json_schema: serde_json::Value,
|
||||
},
|
||||
JsonSchema { json_schema: serde_json::Value },
|
||||
}
|
||||
|
||||
/// Reasoning effort levels
|
||||
|
|
@ -608,9 +602,7 @@ pub enum OutputContent {
|
|||
transcript: Option<String>,
|
||||
},
|
||||
/// Refusal output
|
||||
Refusal {
|
||||
refusal: String,
|
||||
},
|
||||
Refusal { refusal: String },
|
||||
}
|
||||
|
||||
/// Annotations for output text
|
||||
|
|
@ -663,13 +655,9 @@ pub struct FileSearchResult {
|
|||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum CodeInterpreterOutput {
|
||||
/// Text output
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
Text { text: String },
|
||||
/// Image output
|
||||
Image {
|
||||
image: String,
|
||||
},
|
||||
Image { image: String },
|
||||
}
|
||||
|
||||
/// Response usage statistics
|
||||
|
|
@ -951,9 +939,7 @@ pub enum ResponsesAPIStreamEvent {
|
|||
},
|
||||
|
||||
/// Done event (end of stream)
|
||||
Done {
|
||||
sequence_number: i32,
|
||||
},
|
||||
Done { sequence_number: i32 },
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -1052,12 +1038,19 @@ impl ProviderRequest for ResponsesAPIRequest {
|
|||
MessageContent::Text(text) => text.clone(),
|
||||
MessageContent::Items(content_items) => {
|
||||
content_items.iter().fold(String::new(), |acc, content| {
|
||||
acc + " " + &match content {
|
||||
InputContent::InputText { text } => text.clone(),
|
||||
InputContent::InputImage { .. } => "[Image]".to_string(),
|
||||
InputContent::InputFile { .. } => "[File]".to_string(),
|
||||
InputContent::InputAudio { .. } => "[Audio]".to_string(),
|
||||
}
|
||||
acc + " "
|
||||
+ &match content {
|
||||
InputContent::InputText { text } => text.clone(),
|
||||
InputContent::InputImage { .. } => {
|
||||
"[Image]".to_string()
|
||||
}
|
||||
InputContent::InputFile { .. } => {
|
||||
"[File]".to_string()
|
||||
}
|
||||
InputContent::InputAudio { .. } => {
|
||||
"[Audio]".to_string()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
|
|
@ -1082,11 +1075,9 @@ impl ProviderRequest for ResponsesAPIRequest {
|
|||
match &msg.content {
|
||||
MessageContent::Text(text) => Some(text.clone()),
|
||||
MessageContent::Items(content_items) => {
|
||||
content_items.iter().find_map(|content| {
|
||||
match content {
|
||||
InputContent::InputText { text } => Some(text.clone()),
|
||||
_ => None,
|
||||
}
|
||||
content_items.iter().find_map(|content| match content {
|
||||
InputContent::InputText { text } => Some(text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1176,9 +1167,12 @@ impl ProviderRequest for ResponsesAPIRequest {
|
|||
|
||||
// Extract text from message content
|
||||
let content = match &msg.content {
|
||||
crate::apis::openai_responses::MessageContent::Text(text) => text.clone(),
|
||||
crate::apis::openai_responses::MessageContent::Text(text) => {
|
||||
text.clone()
|
||||
}
|
||||
crate::apis::openai_responses::MessageContent::Items(items) => {
|
||||
items.iter()
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
if let InputContent::InputText { text } = c {
|
||||
Some(text.clone())
|
||||
|
|
@ -1214,7 +1208,8 @@ impl ProviderRequest for ResponsesAPIRequest {
|
|||
fn set_messages(&mut self, messages: &[crate::apis::openai::Message]) {
|
||||
// For ResponsesAPI, we need to convert messages back to input format
|
||||
// Extract system messages as instructions
|
||||
let system_text = messages.iter()
|
||||
let system_text = messages
|
||||
.iter()
|
||||
.filter(|msg| msg.role == crate::apis::openai::Role::System)
|
||||
.filter_map(|msg| {
|
||||
if let crate::apis::openai::MessageContent::Text(text) = &msg.content {
|
||||
|
|
@ -1233,23 +1228,27 @@ impl ProviderRequest for ResponsesAPIRequest {
|
|||
// Convert user/assistant messages to InputParam
|
||||
// For simplicity, we'll use the last user message as the input
|
||||
// or combine all non-system messages
|
||||
let input_messages: Vec<_> = messages.iter()
|
||||
let input_messages: Vec<_> = messages
|
||||
.iter()
|
||||
.filter(|msg| msg.role != crate::apis::openai::Role::System)
|
||||
.collect();
|
||||
|
||||
if !input_messages.is_empty() {
|
||||
// If there's only one message, use Text format
|
||||
if input_messages.len() == 1 {
|
||||
if let crate::apis::openai::MessageContent::Text(text) = &input_messages[0].content {
|
||||
if let crate::apis::openai::MessageContent::Text(text) = &input_messages[0].content
|
||||
{
|
||||
self.input = crate::apis::openai_responses::InputParam::Text(text.clone());
|
||||
}
|
||||
} else {
|
||||
// Multiple messages - combine them as text for now
|
||||
// A more sophisticated approach would use InputParam::Items
|
||||
let combined_text = input_messages.iter()
|
||||
let combined_text = input_messages
|
||||
.iter()
|
||||
.filter_map(|msg| {
|
||||
if let crate::apis::openai::MessageContent::Text(text) = &msg.content {
|
||||
Some(format!("{}: {}",
|
||||
Some(format!(
|
||||
"{}: {}",
|
||||
match msg.role {
|
||||
crate::apis::openai::Role::User => "User",
|
||||
crate::apis::openai::Role::Assistant => "Assistant",
|
||||
|
|
@ -1274,10 +1273,10 @@ impl ProviderRequest for ResponsesAPIRequest {
|
|||
// Into<String> Implementation for SSE Formatting
|
||||
// ============================================================================
|
||||
|
||||
impl Into<String> for ResponsesAPIStreamEvent {
|
||||
fn into(self) -> String {
|
||||
let transformed_json = serde_json::to_string(&self).unwrap_or_default();
|
||||
let event_type = match &self {
|
||||
impl From<ResponsesAPIStreamEvent> for String {
|
||||
fn from(val: ResponsesAPIStreamEvent) -> Self {
|
||||
let transformed_json = serde_json::to_string(&val).unwrap_or_default();
|
||||
let event_type = match &val {
|
||||
ResponsesAPIStreamEvent::ResponseCreated { .. } => "response.created",
|
||||
ResponsesAPIStreamEvent::ResponseInProgress { .. } => "response.in_progress",
|
||||
ResponsesAPIStreamEvent::ResponseCompleted { .. } => "response.completed",
|
||||
|
|
@ -1365,10 +1364,10 @@ impl crate::providers::streaming_response::ProviderStreamResponse for ResponsesA
|
|||
|
||||
fn role(&self) -> Option<&str> {
|
||||
match self {
|
||||
ResponsesAPIStreamEvent::ResponseOutputItemDone { item, .. } => match item {
|
||||
OutputItem::Message { role, .. } => Some(role.as_str()),
|
||||
_ => None,
|
||||
},
|
||||
ResponsesAPIStreamEvent::ResponseOutputItemDone {
|
||||
item: OutputItem::Message { role, .. },
|
||||
..
|
||||
} => Some(role.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,10 +34,7 @@ where
|
|||
}
|
||||
|
||||
pub fn decode_frame(&mut self) -> Option<DecodedFrame> {
|
||||
match self.decoder.decode_frame(&mut self.buffer) {
|
||||
Ok(frame) => Some(frame),
|
||||
Err(_e) => None, // Fatal decode error
|
||||
}
|
||||
self.decoder.decode_frame(&mut self.buffer).ok()
|
||||
}
|
||||
|
||||
pub fn buffer_mut(&mut self) -> &mut B {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use crate::apis::streaming_shapes::sse::{SseEvent, SseStreamBufferTrait};
|
||||
use crate::apis::anthropic::MessagesStreamEvent;
|
||||
use crate::apis::streaming_shapes::sse::{SseEvent, SseStreamBufferTrait};
|
||||
use crate::providers::streaming_response::ProviderStreamResponseType;
|
||||
use std::collections::HashSet;
|
||||
|
||||
|
|
@ -31,6 +31,12 @@ pub struct AnthropicMessagesStreamBuffer {
|
|||
model: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AnthropicMessagesStreamBuffer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AnthropicMessagesStreamBuffer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
|
|
@ -154,7 +160,8 @@ impl SseStreamBufferTrait for AnthropicMessagesStreamBuffer {
|
|||
// Inject message_start if needed
|
||||
if !self.message_started {
|
||||
let model = self.model.as_deref().unwrap_or("unknown");
|
||||
let message_start = AnthropicMessagesStreamBuffer::create_message_start_event(model);
|
||||
let message_start =
|
||||
AnthropicMessagesStreamBuffer::create_message_start_event(model);
|
||||
self.buffered_events.push(message_start);
|
||||
self.message_started = true;
|
||||
}
|
||||
|
|
@ -169,7 +176,8 @@ impl SseStreamBufferTrait for AnthropicMessagesStreamBuffer {
|
|||
// Inject message_start if needed
|
||||
if !self.message_started {
|
||||
let model = self.model.as_deref().unwrap_or("unknown");
|
||||
let message_start = AnthropicMessagesStreamBuffer::create_message_start_event(model);
|
||||
let message_start =
|
||||
AnthropicMessagesStreamBuffer::create_message_start_event(model);
|
||||
self.buffered_events.push(message_start);
|
||||
self.message_started = true;
|
||||
}
|
||||
|
|
@ -177,7 +185,8 @@ impl SseStreamBufferTrait for AnthropicMessagesStreamBuffer {
|
|||
// Check if ContentBlockStart was sent for this index
|
||||
if !self.has_content_block_start_been_sent(index) {
|
||||
// Inject ContentBlockStart before delta
|
||||
let content_block_start = AnthropicMessagesStreamBuffer::create_content_block_start_event();
|
||||
let content_block_start =
|
||||
AnthropicMessagesStreamBuffer::create_content_block_start_event();
|
||||
self.buffered_events.push(content_block_start);
|
||||
self.set_content_block_start_sent(index);
|
||||
self.needs_content_block_stop = true;
|
||||
|
|
@ -189,7 +198,8 @@ impl SseStreamBufferTrait for AnthropicMessagesStreamBuffer {
|
|||
MessagesStreamEvent::MessageDelta { usage, .. } => {
|
||||
// Inject ContentBlockStop before message_delta
|
||||
if self.needs_content_block_stop {
|
||||
let content_block_stop = AnthropicMessagesStreamBuffer::create_content_block_stop_event();
|
||||
let content_block_stop =
|
||||
AnthropicMessagesStreamBuffer::create_content_block_stop_event();
|
||||
self.buffered_events.push(content_block_stop);
|
||||
self.needs_content_block_stop = false;
|
||||
}
|
||||
|
|
@ -199,10 +209,10 @@ impl SseStreamBufferTrait for AnthropicMessagesStreamBuffer {
|
|||
if let Some(last_event) = self.buffered_events.last_mut() {
|
||||
if let Some(ProviderStreamResponseType::MessagesStreamEvent(
|
||||
MessagesStreamEvent::MessageDelta {
|
||||
usage: last_usage,
|
||||
..
|
||||
}
|
||||
)) = &mut last_event.provider_stream_response {
|
||||
usage: last_usage, ..
|
||||
},
|
||||
)) = &mut last_event.provider_stream_response
|
||||
{
|
||||
// Merge: take stop_reason from first, usage from second (if non-zero)
|
||||
if usage.input_tokens > 0 || usage.output_tokens > 0 {
|
||||
*last_usage = usage.clone();
|
||||
|
|
@ -243,7 +253,7 @@ impl SseStreamBufferTrait for AnthropicMessagesStreamBuffer {
|
|||
}
|
||||
}
|
||||
|
||||
fn into_bytes(&mut self) -> Vec<u8> {
|
||||
fn to_bytes(&mut self) -> Vec<u8> {
|
||||
// Convert all accumulated events to bytes and clear buffer
|
||||
// NOTE: We do NOT inject ContentBlockStop here because it's injected when we see MessageDelta
|
||||
// or MessageStop. Injecting it here causes premature ContentBlockStop in the middle of streaming.
|
||||
|
|
@ -276,10 +286,10 @@ impl SseStreamBufferTrait for AnthropicMessagesStreamBuffer {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::clients::{SupportedAPIsFromClient, SupportedUpstreamAPIs};
|
||||
use crate::apis::anthropic::AnthropicApi;
|
||||
use crate::apis::openai::OpenAIApi;
|
||||
use crate::apis::streaming_shapes::sse::SseStreamIter;
|
||||
use crate::clients::{SupportedAPIsFromClient, SupportedUpstreamAPIs};
|
||||
|
||||
#[test]
|
||||
fn test_openai_to_anthropic_complete_transformation() {
|
||||
|
|
@ -308,11 +318,12 @@ data: [DONE]"#;
|
|||
let mut buffer = AnthropicMessagesStreamBuffer::new();
|
||||
|
||||
for raw_event in stream_iter {
|
||||
let transformed_event = SseEvent::try_from((raw_event, &client_api, &upstream_api)).unwrap();
|
||||
let transformed_event =
|
||||
SseEvent::try_from((raw_event, &client_api, &upstream_api)).unwrap();
|
||||
buffer.add_transformed_event(transformed_event);
|
||||
}
|
||||
|
||||
let output_bytes = buffer.into_bytes();
|
||||
let output_bytes = buffer.to_bytes();
|
||||
let output = String::from_utf8_lossy(&output_bytes);
|
||||
|
||||
println!("\nTRANSFORMED OUTPUT (Anthropic Messages API):");
|
||||
|
|
@ -321,25 +332,54 @@ data: [DONE]"#;
|
|||
|
||||
// Assertions
|
||||
assert!(!output_bytes.is_empty(), "Should have output");
|
||||
assert!(output.contains("event: message_start"), "Should have message_start");
|
||||
assert!(output.contains("event: content_block_start"), "Should have content_block_start (injected)");
|
||||
assert!(
|
||||
output.contains("event: message_start"),
|
||||
"Should have message_start"
|
||||
);
|
||||
assert!(
|
||||
output.contains("event: content_block_start"),
|
||||
"Should have content_block_start (injected)"
|
||||
);
|
||||
|
||||
let delta_count = output.matches("event: content_block_delta").count();
|
||||
assert_eq!(delta_count, 2, "Should have exactly 2 content_block_delta events");
|
||||
assert_eq!(
|
||||
delta_count, 2,
|
||||
"Should have exactly 2 content_block_delta events"
|
||||
);
|
||||
|
||||
// Verify both pieces of content are present
|
||||
assert!(output.contains("\"text\":\"Hello\""), "Should have first content delta 'Hello'");
|
||||
assert!(output.contains("\"text\":\" world\""), "Should have second content delta ' world'");
|
||||
assert!(
|
||||
output.contains("\"text\":\"Hello\""),
|
||||
"Should have first content delta 'Hello'"
|
||||
);
|
||||
assert!(
|
||||
output.contains("\"text\":\" world\""),
|
||||
"Should have second content delta ' world'"
|
||||
);
|
||||
|
||||
assert!(output.contains("event: content_block_stop"), "Should have content_block_stop (injected)");
|
||||
assert!(output.contains("event: message_delta"), "Should have message_delta");
|
||||
assert!(output.contains("event: message_stop"), "Should have message_stop");
|
||||
assert!(
|
||||
output.contains("event: content_block_stop"),
|
||||
"Should have content_block_stop (injected)"
|
||||
);
|
||||
assert!(
|
||||
output.contains("event: message_delta"),
|
||||
"Should have message_delta"
|
||||
);
|
||||
assert!(
|
||||
output.contains("event: message_stop"),
|
||||
"Should have message_stop"
|
||||
);
|
||||
|
||||
println!("\nVALIDATION SUMMARY:");
|
||||
println!("{}", "-".repeat(80));
|
||||
println!("✓ Complete transformation: OpenAI ChatCompletions → Anthropic Messages API");
|
||||
println!("✓ Injected lifecycle events: message_start, content_block_start, content_block_stop");
|
||||
println!("✓ Content deltas: {} events (BOTH 'Hello' and ' world' preserved!)", delta_count);
|
||||
println!(
|
||||
"✓ Injected lifecycle events: message_start, content_block_start, content_block_stop"
|
||||
);
|
||||
println!(
|
||||
"✓ Content deltas: {} events (BOTH 'Hello' and ' world' preserved!)",
|
||||
delta_count
|
||||
);
|
||||
println!("✓ Complete stream with message_stop");
|
||||
println!("✓ Proper Anthropic protocol sequencing\n");
|
||||
}
|
||||
|
|
@ -369,11 +409,12 @@ data: {"id":"chatcmpl-456","object":"chat.completion.chunk","created":1234567890
|
|||
let mut buffer = AnthropicMessagesStreamBuffer::new();
|
||||
|
||||
for raw_event in stream_iter {
|
||||
let transformed_event = SseEvent::try_from((raw_event, &client_api, &upstream_api)).unwrap();
|
||||
let transformed_event =
|
||||
SseEvent::try_from((raw_event, &client_api, &upstream_api)).unwrap();
|
||||
buffer.add_transformed_event(transformed_event);
|
||||
}
|
||||
|
||||
let output_bytes = buffer.into_bytes();
|
||||
let output_bytes = buffer.to_bytes();
|
||||
let output = String::from_utf8_lossy(&output_bytes);
|
||||
|
||||
println!("\nTRANSFORMED OUTPUT (Anthropic Messages API):");
|
||||
|
|
@ -382,31 +423,61 @@ data: {"id":"chatcmpl-456","object":"chat.completion.chunk","created":1234567890
|
|||
|
||||
// Assertions
|
||||
assert!(!output_bytes.is_empty(), "Should have output");
|
||||
assert!(output.contains("event: message_start"), "Should have message_start");
|
||||
assert!(output.contains("event: content_block_start"), "Should have content_block_start (injected)");
|
||||
assert!(
|
||||
output.contains("event: message_start"),
|
||||
"Should have message_start"
|
||||
);
|
||||
assert!(
|
||||
output.contains("event: content_block_start"),
|
||||
"Should have content_block_start (injected)"
|
||||
);
|
||||
|
||||
let delta_count = output.matches("event: content_block_delta").count();
|
||||
assert_eq!(delta_count, 3, "Should have exactly 3 content_block_delta events");
|
||||
assert_eq!(
|
||||
delta_count, 3,
|
||||
"Should have exactly 3 content_block_delta events"
|
||||
);
|
||||
|
||||
// Verify all three pieces of content are present
|
||||
assert!(output.contains("\"text\":\"The weather\""), "Should have first content delta");
|
||||
assert!(output.contains("\"text\":\" in San Francisco\""), "Should have second content delta");
|
||||
assert!(output.contains("\"text\":\" is\""), "Should have third content delta");
|
||||
assert!(
|
||||
output.contains("\"text\":\"The weather\""),
|
||||
"Should have first content delta"
|
||||
);
|
||||
assert!(
|
||||
output.contains("\"text\":\" in San Francisco\""),
|
||||
"Should have second content delta"
|
||||
);
|
||||
assert!(
|
||||
output.contains("\"text\":\" is\""),
|
||||
"Should have third content delta"
|
||||
);
|
||||
|
||||
// For partial streams (no finish_reason, no [DONE]), we do NOT inject content_block_stop
|
||||
// because the stream may continue. This is correct behavior - only inject lifecycle events
|
||||
// when we have explicit signals from upstream (finish_reason, [DONE], etc.)
|
||||
assert!(!output.contains("event: content_block_stop"), "Should NOT have content_block_stop for partial stream");
|
||||
assert!(
|
||||
!output.contains("event: content_block_stop"),
|
||||
"Should NOT have content_block_stop for partial stream"
|
||||
);
|
||||
|
||||
// Should NOT have completion events
|
||||
assert!(!output.contains("event: message_delta"), "Should NOT have message_delta");
|
||||
assert!(!output.contains("event: message_stop"), "Should NOT have message_stop");
|
||||
assert!(
|
||||
!output.contains("event: message_delta"),
|
||||
"Should NOT have message_delta"
|
||||
);
|
||||
assert!(
|
||||
!output.contains("event: message_stop"),
|
||||
"Should NOT have message_stop"
|
||||
);
|
||||
|
||||
println!("\nVALIDATION SUMMARY:");
|
||||
println!("{}", "-".repeat(80));
|
||||
println!("✓ Partial transformation: OpenAI → Anthropic (stream interrupted)");
|
||||
println!("✓ Injected: message_start, content_block_start at beginning");
|
||||
println!("✓ Incremental deltas: {} events (ALL content preserved!)", delta_count);
|
||||
println!(
|
||||
"✓ Incremental deltas: {} events (ALL content preserved!)",
|
||||
delta_count
|
||||
);
|
||||
println!("✓ NO completion events (partial stream, no [DONE])");
|
||||
println!("✓ Buffer maintains Anthropic protocol for active streams\n");
|
||||
}
|
||||
|
|
@ -452,11 +523,12 @@ data: [DONE]"#;
|
|||
let mut buffer = AnthropicMessagesStreamBuffer::new();
|
||||
|
||||
for raw_event in stream_iter {
|
||||
let transformed_event = SseEvent::try_from((raw_event, &client_api, &upstream_api)).unwrap();
|
||||
let transformed_event =
|
||||
SseEvent::try_from((raw_event, &client_api, &upstream_api)).unwrap();
|
||||
buffer.add_transformed_event(transformed_event);
|
||||
}
|
||||
|
||||
let output_bytes = buffer.into_bytes();
|
||||
let output_bytes = buffer.to_bytes();
|
||||
let output = String::from_utf8_lossy(&output_bytes);
|
||||
|
||||
println!("\nTRANSFORMED OUTPUT (Anthropic Messages API):");
|
||||
|
|
@ -467,32 +539,71 @@ data: [DONE]"#;
|
|||
assert!(!output_bytes.is_empty(), "Should have output");
|
||||
|
||||
// Should have lifecycle events (injected by buffer)
|
||||
assert!(output.contains("event: message_start"), "Should have message_start (injected)");
|
||||
assert!(output.contains("event: content_block_start"), "Should have content_block_start");
|
||||
assert!(output.contains("event: content_block_stop"), "Should have content_block_stop (injected)");
|
||||
assert!(output.contains("event: message_delta"), "Should have message_delta");
|
||||
assert!(output.contains("event: message_stop"), "Should have message_stop");
|
||||
assert!(
|
||||
output.contains("event: message_start"),
|
||||
"Should have message_start (injected)"
|
||||
);
|
||||
assert!(
|
||||
output.contains("event: content_block_start"),
|
||||
"Should have content_block_start"
|
||||
);
|
||||
assert!(
|
||||
output.contains("event: content_block_stop"),
|
||||
"Should have content_block_stop (injected)"
|
||||
);
|
||||
assert!(
|
||||
output.contains("event: message_delta"),
|
||||
"Should have message_delta"
|
||||
);
|
||||
assert!(
|
||||
output.contains("event: message_stop"),
|
||||
"Should have message_stop"
|
||||
);
|
||||
|
||||
// Should have tool_use content block
|
||||
assert!(output.contains("\"type\":\"tool_use\""), "Should have tool_use type");
|
||||
assert!(output.contains("\"name\":\"get_weather\""), "Should have correct function name");
|
||||
assert!(output.contains("\"id\":\"call_2Uzw0AEZQeOex2CP2TKjcLKc\""), "Should have correct tool call ID");
|
||||
assert!(
|
||||
output.contains("\"type\":\"tool_use\""),
|
||||
"Should have tool_use type"
|
||||
);
|
||||
assert!(
|
||||
output.contains("\"name\":\"get_weather\""),
|
||||
"Should have correct function name"
|
||||
);
|
||||
assert!(
|
||||
output.contains("\"id\":\"call_2Uzw0AEZQeOex2CP2TKjcLKc\""),
|
||||
"Should have correct tool call ID"
|
||||
);
|
||||
|
||||
// Count input_json_delta events - should match the number of argument chunks
|
||||
let delta_count = output.matches("event: content_block_delta").count();
|
||||
assert!(delta_count >= 8, "Should have at least 8 input_json_delta events");
|
||||
assert!(
|
||||
delta_count >= 8,
|
||||
"Should have at least 8 input_json_delta events"
|
||||
);
|
||||
|
||||
// Verify argument deltas are present
|
||||
assert!(output.contains("\"type\":\"input_json_delta\""), "Should have input_json_delta type");
|
||||
assert!(output.contains("\"partial_json\":"), "Should have partial_json field");
|
||||
assert!(
|
||||
output.contains("\"type\":\"input_json_delta\""),
|
||||
"Should have input_json_delta type"
|
||||
);
|
||||
assert!(
|
||||
output.contains("\"partial_json\":"),
|
||||
"Should have partial_json field"
|
||||
);
|
||||
|
||||
// Verify the accumulated arguments contain the location
|
||||
assert!(output.contains("San"), "Arguments should contain 'San'");
|
||||
assert!(output.contains("Francisco"), "Arguments should contain 'Francisco'");
|
||||
assert!(
|
||||
output.contains("Francisco"),
|
||||
"Arguments should contain 'Francisco'"
|
||||
);
|
||||
assert!(output.contains("CA"), "Arguments should contain 'CA'");
|
||||
|
||||
// Verify stop reason is tool_use
|
||||
assert!(output.contains("\"stop_reason\":\"tool_use\""), "Should have stop_reason as tool_use");
|
||||
assert!(
|
||||
output.contains("\"stop_reason\":\"tool_use\""),
|
||||
"Should have stop_reason as tool_use"
|
||||
);
|
||||
|
||||
println!("\nVALIDATION SUMMARY:");
|
||||
println!("{}", "-".repeat(80));
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ pub struct OpenAIChatCompletionsStreamBuffer {
|
|||
buffered_events: Vec<SseEvent>,
|
||||
}
|
||||
|
||||
impl Default for OpenAIChatCompletionsStreamBuffer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenAIChatCompletionsStreamBuffer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
|
|
@ -26,7 +32,7 @@ impl SseStreamBufferTrait for OpenAIChatCompletionsStreamBuffer {
|
|||
self.buffered_events.push(event);
|
||||
}
|
||||
|
||||
fn into_bytes(&mut self) -> Vec<u8> {
|
||||
fn to_bytes(&mut self) -> Vec<u8> {
|
||||
// No finalization needed for OpenAI Chat Completions
|
||||
// The [DONE] marker is already handled by the transformation layer
|
||||
let mut buffer = Vec::new();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
pub mod sse;
|
||||
pub mod sse_chunk_processor;
|
||||
pub mod amazon_bedrock_binary_frame;
|
||||
pub mod anthropic_streaming_buffer;
|
||||
pub mod chat_completions_streaming_buffer;
|
||||
pub mod passthrough_streaming_buffer;
|
||||
pub mod responses_api_streaming_buffer;
|
||||
pub mod sse;
|
||||
pub mod sse_chunk_processor;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ pub struct PassthroughStreamBuffer {
|
|||
buffered_events: Vec<SseEvent>,
|
||||
}
|
||||
|
||||
impl Default for PassthroughStreamBuffer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PassthroughStreamBuffer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
|
|
@ -30,7 +36,7 @@ impl SseStreamBufferTrait for PassthroughStreamBuffer {
|
|||
self.buffered_events.push(event);
|
||||
}
|
||||
|
||||
fn into_bytes(&mut self) -> Vec<u8> {
|
||||
fn to_bytes(&mut self) -> Vec<u8> {
|
||||
// No finalization needed for passthrough - just convert accumulated events to bytes
|
||||
let mut buffer = Vec::new();
|
||||
for event in self.buffered_events.drain(..) {
|
||||
|
|
@ -44,7 +50,7 @@ impl SseStreamBufferTrait for PassthroughStreamBuffer {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::apis::streaming_shapes::passthrough_streaming_buffer::PassthroughStreamBuffer;
|
||||
use crate::apis::streaming_shapes::sse::{SseStreamIter, SseStreamBufferTrait};
|
||||
use crate::apis::streaming_shapes::sse::{SseStreamBufferTrait, SseStreamIter};
|
||||
|
||||
#[test]
|
||||
fn test_chat_completions_passthrough_buffer() {
|
||||
|
|
@ -73,7 +79,7 @@ mod tests {
|
|||
buffer.add_transformed_event(event);
|
||||
}
|
||||
|
||||
let output_bytes = buffer.into_bytes();
|
||||
let output_bytes = buffer.to_bytes();
|
||||
let output = String::from_utf8_lossy(&output_bytes);
|
||||
|
||||
println!("\nTRANSFORMED OUTPUT (ChatCompletions - Passthrough):");
|
||||
|
|
@ -84,7 +90,11 @@ mod tests {
|
|||
assert!(!output_bytes.is_empty());
|
||||
assert!(output.contains("chatcmpl-123"));
|
||||
assert!(output.contains("[DONE]"));
|
||||
assert_eq!(raw_input.trim(), output.trim(), "Passthrough should preserve input");
|
||||
assert_eq!(
|
||||
raw_input.trim(),
|
||||
output.trim(),
|
||||
"Passthrough should preserve input"
|
||||
);
|
||||
|
||||
println!("\nVALIDATION SUMMARY:");
|
||||
println!("{}", "-".repeat(80));
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
use std::collections::HashMap;
|
||||
use log::debug;
|
||||
use crate::apis::openai_responses::{
|
||||
ResponsesAPIStreamEvent, ResponsesAPIResponse, OutputItem, OutputItemStatus,
|
||||
ResponseStatus, TextConfig, TextFormat, Reasoning,
|
||||
OutputItem, OutputItemStatus, Reasoning, ResponseStatus, ResponsesAPIResponse,
|
||||
ResponsesAPIStreamEvent, TextConfig, TextFormat,
|
||||
};
|
||||
use crate::apis::streaming_shapes::sse::{SseEvent, SseStreamBufferTrait};
|
||||
use log::debug;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Helper to convert ResponseAPIStreamEvent to SseEvent
|
||||
fn event_to_sse(event: ResponsesAPIStreamEvent) -> SseEvent {
|
||||
|
|
@ -16,10 +16,17 @@ fn event_to_sse(event: ResponsesAPIStreamEvent) -> SseEvent {
|
|||
ResponsesAPIStreamEvent::ResponseOutputItemDone { .. } => "response.output_item.done",
|
||||
ResponsesAPIStreamEvent::ResponseOutputTextDelta { .. } => "response.output_text.delta",
|
||||
ResponsesAPIStreamEvent::ResponseOutputTextDone { .. } => "response.output_text.done",
|
||||
ResponsesAPIStreamEvent::ResponseFunctionCallArgumentsDelta { .. } => "response.function_call_arguments.delta",
|
||||
ResponsesAPIStreamEvent::ResponseFunctionCallArgumentsDone { .. } => "response.function_call_arguments.done",
|
||||
ResponsesAPIStreamEvent::ResponseFunctionCallArgumentsDelta { .. } => {
|
||||
"response.function_call_arguments.delta"
|
||||
}
|
||||
ResponsesAPIStreamEvent::ResponseFunctionCallArgumentsDone { .. } => {
|
||||
"response.function_call_arguments.done"
|
||||
}
|
||||
unknown => {
|
||||
debug!("Unknown ResponsesAPIStreamEvent type encountered: {:?}", unknown);
|
||||
debug!(
|
||||
"Unknown ResponsesAPIStreamEvent type encountered: {:?}",
|
||||
unknown
|
||||
);
|
||||
"unknown"
|
||||
}
|
||||
};
|
||||
|
|
@ -85,6 +92,12 @@ pub struct ResponsesAPIStreamBuffer {
|
|||
buffered_events: Vec<SseEvent>,
|
||||
}
|
||||
|
||||
impl Default for ResponsesAPIStreamBuffer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ResponsesAPIStreamBuffer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
|
|
@ -112,7 +125,11 @@ impl ResponsesAPIStreamBuffer {
|
|||
}
|
||||
|
||||
fn generate_item_id(prefix: &str) -> String {
|
||||
format!("{}_{}", prefix, uuid::Uuid::new_v4().to_string().replace("-", ""))
|
||||
format!(
|
||||
"{}_{}",
|
||||
prefix,
|
||||
uuid::Uuid::new_v4().to_string().replace("-", "")
|
||||
)
|
||||
}
|
||||
|
||||
fn get_or_create_item_id(&mut self, output_index: i32, prefix: &str) -> String {
|
||||
|
|
@ -160,7 +177,13 @@ impl ResponsesAPIStreamBuffer {
|
|||
}
|
||||
|
||||
/// Create output_item.added event for tool call
|
||||
fn create_tool_call_added_event(&mut self, output_index: i32, item_id: &str, call_id: &str, name: &str) -> SseEvent {
|
||||
fn create_tool_call_added_event(
|
||||
&mut self,
|
||||
output_index: i32,
|
||||
item_id: &str,
|
||||
call_id: &str,
|
||||
name: &str,
|
||||
) -> SseEvent {
|
||||
let event = ResponsesAPIStreamEvent::ResponseOutputItemAdded {
|
||||
output_index,
|
||||
item: OutputItem::FunctionCall {
|
||||
|
|
@ -237,9 +260,15 @@ impl ResponsesAPIStreamBuffer {
|
|||
// Emit done events for all accumulated content
|
||||
|
||||
// Text content done events
|
||||
let text_items: Vec<_> = self.text_content.iter().map(|(id, content)| (id.clone(), content.clone())).collect();
|
||||
let text_items: Vec<_> = self
|
||||
.text_content
|
||||
.iter()
|
||||
.map(|(id, content)| (id.clone(), content.clone()))
|
||||
.collect();
|
||||
for (item_id, content) in text_items {
|
||||
let output_index = self.output_items_added.iter()
|
||||
let output_index = self
|
||||
.output_items_added
|
||||
.iter()
|
||||
.find(|(_, id)| **id == item_id)
|
||||
.map(|(idx, _)| *idx)
|
||||
.unwrap_or(0);
|
||||
|
|
@ -270,9 +299,15 @@ impl ResponsesAPIStreamBuffer {
|
|||
}
|
||||
|
||||
// Function call done events
|
||||
let func_items: Vec<_> = self.function_arguments.iter().map(|(id, args)| (id.clone(), args.clone())).collect();
|
||||
let func_items: Vec<_> = self
|
||||
.function_arguments
|
||||
.iter()
|
||||
.map(|(id, args)| (id.clone(), args.clone()))
|
||||
.collect();
|
||||
for (item_id, arguments) in func_items {
|
||||
let output_index = self.output_items_added.iter()
|
||||
let output_index = self
|
||||
.output_items_added
|
||||
.iter()
|
||||
.find(|(_, id)| **id == item_id)
|
||||
.map(|(idx, _)| *idx)
|
||||
.unwrap_or(0);
|
||||
|
|
@ -286,9 +321,16 @@ impl ResponsesAPIStreamBuffer {
|
|||
};
|
||||
events.push(event_to_sse(args_done_event));
|
||||
|
||||
let (call_id, name) = self.tool_call_metadata.get(&output_index)
|
||||
let (call_id, name) = self
|
||||
.tool_call_metadata
|
||||
.get(&output_index)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| (format!("call_{}", uuid::Uuid::new_v4()), "unknown".to_string()));
|
||||
.unwrap_or_else(|| {
|
||||
(
|
||||
format!("call_{}", uuid::Uuid::new_v4()),
|
||||
"unknown".to_string(),
|
||||
)
|
||||
});
|
||||
|
||||
let seq2 = self.next_sequence_number();
|
||||
let item_done_event = ResponsesAPIStreamEvent::ResponseOutputItemDone {
|
||||
|
|
@ -315,9 +357,16 @@ impl ResponsesAPIStreamBuffer {
|
|||
if let Some(item_id) = self.output_items_added.get(&output_index) {
|
||||
// Check if this is a function call
|
||||
if let Some(arguments) = self.function_arguments.get(item_id) {
|
||||
let (call_id, name) = self.tool_call_metadata.get(&output_index)
|
||||
let (call_id, name) = self
|
||||
.tool_call_metadata
|
||||
.get(&output_index)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| (format!("call_{}", uuid::Uuid::new_v4()), "unknown".to_string()));
|
||||
.unwrap_or_else(|| {
|
||||
(
|
||||
format!("call_{}", uuid::Uuid::new_v4()),
|
||||
"unknown".to_string(),
|
||||
)
|
||||
});
|
||||
|
||||
output_items.push(OutputItem::FunctionCall {
|
||||
id: item_id.clone(),
|
||||
|
|
@ -397,9 +446,9 @@ impl SseStreamBufferTrait for ResponsesAPIStreamBuffer {
|
|||
let mut events = Vec::new();
|
||||
|
||||
// Capture upstream metadata from ResponseCreated or ResponseInProgress if present
|
||||
match stream_event {
|
||||
ResponsesAPIStreamEvent::ResponseCreated { response, .. } |
|
||||
ResponsesAPIStreamEvent::ResponseInProgress { response, .. } => {
|
||||
match stream_event.as_ref() {
|
||||
ResponsesAPIStreamEvent::ResponseCreated { response, .. }
|
||||
| ResponsesAPIStreamEvent::ResponseInProgress { response, .. } => {
|
||||
if self.upstream_response_metadata.is_none() {
|
||||
// Store the full upstream response as our metadata template
|
||||
self.upstream_response_metadata = Some(response.clone());
|
||||
|
|
@ -418,11 +467,16 @@ impl SseStreamBufferTrait for ResponsesAPIStreamBuffer {
|
|||
if !self.created_emitted {
|
||||
// Initialize metadata from first event if needed
|
||||
if self.response_id.is_none() {
|
||||
self.response_id = Some(format!("resp_{}", uuid::Uuid::new_v4().to_string().replace("-", "")));
|
||||
self.created_at = Some(std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64);
|
||||
self.response_id = Some(format!(
|
||||
"resp_{}",
|
||||
uuid::Uuid::new_v4().to_string().replace("-", "")
|
||||
));
|
||||
self.created_at = Some(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64,
|
||||
);
|
||||
self.model = Some("unknown".to_string()); // Will be set by caller if available
|
||||
}
|
||||
|
||||
|
|
@ -436,58 +490,95 @@ impl SseStreamBufferTrait for ResponsesAPIStreamBuffer {
|
|||
}
|
||||
|
||||
// Process the delta event
|
||||
match stream_event {
|
||||
ResponsesAPIStreamEvent::ResponseOutputTextDelta { output_index, delta, .. } => {
|
||||
match stream_event.as_ref() {
|
||||
ResponsesAPIStreamEvent::ResponseOutputTextDelta {
|
||||
output_index,
|
||||
delta,
|
||||
..
|
||||
} => {
|
||||
let item_id = self.get_or_create_item_id(*output_index, "msg");
|
||||
|
||||
// Emit output_item.added if this is the first time we see this output index
|
||||
if !self.output_items_added.contains_key(output_index) {
|
||||
self.output_items_added.insert(*output_index, item_id.clone());
|
||||
self.output_items_added
|
||||
.insert(*output_index, item_id.clone());
|
||||
events.push(self.create_output_item_added_event(*output_index, &item_id));
|
||||
}
|
||||
|
||||
// Accumulate text content
|
||||
self.text_content.entry(item_id.clone())
|
||||
self.text_content
|
||||
.entry(item_id.clone())
|
||||
.and_modify(|content| content.push_str(delta))
|
||||
.or_insert_with(|| delta.clone());
|
||||
|
||||
// Emit text delta with filled-in item_id and sequence_number
|
||||
let mut delta_event = stream_event.clone();
|
||||
if let ResponsesAPIStreamEvent::ResponseOutputTextDelta { item_id: ref mut id, sequence_number: ref mut seq, .. } = delta_event {
|
||||
let mut delta_event = stream_event.as_ref().clone();
|
||||
if let ResponsesAPIStreamEvent::ResponseOutputTextDelta {
|
||||
item_id: ref mut id,
|
||||
sequence_number: ref mut seq,
|
||||
..
|
||||
} = &mut delta_event
|
||||
{
|
||||
*id = item_id;
|
||||
*seq = self.next_sequence_number();
|
||||
}
|
||||
events.push(event_to_sse(delta_event));
|
||||
}
|
||||
ResponsesAPIStreamEvent::ResponseFunctionCallArgumentsDelta { output_index, delta, call_id, name, .. } => {
|
||||
ResponsesAPIStreamEvent::ResponseFunctionCallArgumentsDelta {
|
||||
output_index,
|
||||
delta,
|
||||
call_id,
|
||||
name,
|
||||
..
|
||||
} => {
|
||||
let item_id = self.get_or_create_item_id(*output_index, "fc");
|
||||
|
||||
// Store metadata if provided (from initial tool call event)
|
||||
if let (Some(cid), Some(n)) = (call_id, name) {
|
||||
self.tool_call_metadata.insert(*output_index, (cid.clone(), n.clone()));
|
||||
self.tool_call_metadata
|
||||
.insert(*output_index, (cid.clone(), n.clone()));
|
||||
}
|
||||
|
||||
// Emit output_item.added if this is the first time we see this tool call
|
||||
if !self.output_items_added.contains_key(output_index) {
|
||||
self.output_items_added.insert(*output_index, item_id.clone());
|
||||
self.output_items_added
|
||||
.insert(*output_index, item_id.clone());
|
||||
|
||||
// For tool calls, we need call_id and name from metadata
|
||||
// These should now be populated from the event itself
|
||||
let (call_id, name) = self.tool_call_metadata.get(output_index)
|
||||
let (call_id, name) = self
|
||||
.tool_call_metadata
|
||||
.get(output_index)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| (format!("call_{}", uuid::Uuid::new_v4()), "unknown".to_string()));
|
||||
.unwrap_or_else(|| {
|
||||
(
|
||||
format!("call_{}", uuid::Uuid::new_v4()),
|
||||
"unknown".to_string(),
|
||||
)
|
||||
});
|
||||
|
||||
events.push(self.create_tool_call_added_event(*output_index, &item_id, &call_id, &name));
|
||||
events.push(self.create_tool_call_added_event(
|
||||
*output_index,
|
||||
&item_id,
|
||||
&call_id,
|
||||
&name,
|
||||
));
|
||||
}
|
||||
|
||||
// Accumulate function arguments
|
||||
self.function_arguments.entry(item_id.clone())
|
||||
self.function_arguments
|
||||
.entry(item_id.clone())
|
||||
.and_modify(|args| args.push_str(delta))
|
||||
.or_insert_with(|| delta.clone());
|
||||
|
||||
// Emit function call arguments delta with filled-in item_id and sequence_number
|
||||
let mut delta_event = stream_event.clone();
|
||||
if let ResponsesAPIStreamEvent::ResponseFunctionCallArgumentsDelta { item_id: ref mut id, sequence_number: ref mut seq, .. } = delta_event {
|
||||
let mut delta_event = stream_event.as_ref().clone();
|
||||
if let ResponsesAPIStreamEvent::ResponseFunctionCallArgumentsDelta {
|
||||
item_id: ref mut id,
|
||||
sequence_number: ref mut seq,
|
||||
..
|
||||
} = &mut delta_event
|
||||
{
|
||||
*id = item_id;
|
||||
*seq = self.next_sequence_number();
|
||||
}
|
||||
|
|
@ -495,7 +586,7 @@ impl SseStreamBufferTrait for ResponsesAPIStreamBuffer {
|
|||
}
|
||||
_ => {
|
||||
// For other event types, just pass through with sequence number
|
||||
let other_event = stream_event.clone();
|
||||
let other_event = stream_event.as_ref().clone();
|
||||
// TODO: Add sequence number to other event types if needed
|
||||
events.push(event_to_sse(other_event));
|
||||
}
|
||||
|
|
@ -505,8 +596,7 @@ impl SseStreamBufferTrait for ResponsesAPIStreamBuffer {
|
|||
self.buffered_events.extend(events);
|
||||
}
|
||||
|
||||
|
||||
fn into_bytes(&mut self) -> Vec<u8> {
|
||||
fn to_bytes(&mut self) -> Vec<u8> {
|
||||
// For Responses API, we need special handling:
|
||||
// - Most events are already in buffered_events from add_transformed_event
|
||||
// - We should NOT finalize here - finalization happens when we detect [DONE] or end of stream
|
||||
|
|
@ -525,9 +615,9 @@ impl SseStreamBufferTrait for ResponsesAPIStreamBuffer {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::clients::{SupportedAPIsFromClient, SupportedUpstreamAPIs};
|
||||
use crate::apis::openai::OpenAIApi;
|
||||
use crate::apis::streaming_shapes::sse::SseStreamIter;
|
||||
use crate::clients::{SupportedAPIsFromClient, SupportedUpstreamAPIs};
|
||||
|
||||
#[test]
|
||||
fn test_chat_completions_to_responses_api_transformation() {
|
||||
|
|
@ -557,11 +647,12 @@ mod tests {
|
|||
|
||||
for raw_event in stream_iter {
|
||||
// Transform the event using the client/upstream APIs
|
||||
let transformed_event = SseEvent::try_from((raw_event, &client_api, &upstream_api)).unwrap();
|
||||
let transformed_event =
|
||||
SseEvent::try_from((raw_event, &client_api, &upstream_api)).unwrap();
|
||||
buffer.add_transformed_event(transformed_event);
|
||||
}
|
||||
|
||||
let output_bytes = buffer.into_bytes();
|
||||
let output_bytes = buffer.to_bytes();
|
||||
let output = String::from_utf8_lossy(&output_bytes);
|
||||
|
||||
println!("\nTRANSFORMED OUTPUT (ResponsesAPI):");
|
||||
|
|
@ -570,13 +661,34 @@ mod tests {
|
|||
|
||||
// Assertions
|
||||
assert!(!output_bytes.is_empty(), "Should have output");
|
||||
assert!(output.contains("response.created"), "Should have response.created");
|
||||
assert!(output.contains("response.in_progress"), "Should have response.in_progress");
|
||||
assert!(output.contains("response.output_item.added"), "Should have output_item.added");
|
||||
assert!(output.contains("response.output_text.delta"), "Should have text deltas");
|
||||
assert!(output.contains("response.output_text.done"), "Should have text.done");
|
||||
assert!(output.contains("response.output_item.done"), "Should have output_item.done");
|
||||
assert!(output.contains("response.completed"), "Should have response.completed");
|
||||
assert!(
|
||||
output.contains("response.created"),
|
||||
"Should have response.created"
|
||||
);
|
||||
assert!(
|
||||
output.contains("response.in_progress"),
|
||||
"Should have response.in_progress"
|
||||
);
|
||||
assert!(
|
||||
output.contains("response.output_item.added"),
|
||||
"Should have output_item.added"
|
||||
);
|
||||
assert!(
|
||||
output.contains("response.output_text.delta"),
|
||||
"Should have text deltas"
|
||||
);
|
||||
assert!(
|
||||
output.contains("response.output_text.done"),
|
||||
"Should have text.done"
|
||||
);
|
||||
assert!(
|
||||
output.contains("response.output_item.done"),
|
||||
"Should have output_item.done"
|
||||
);
|
||||
assert!(
|
||||
output.contains("response.completed"),
|
||||
"Should have response.completed"
|
||||
);
|
||||
|
||||
println!("\nVALIDATION SUMMARY:");
|
||||
println!("{}", "-".repeat(80));
|
||||
|
|
@ -616,7 +728,7 @@ mod tests {
|
|||
buffer.add_transformed_event(transformed);
|
||||
}
|
||||
|
||||
let output_bytes = buffer.into_bytes();
|
||||
let output_bytes = buffer.to_bytes();
|
||||
let output = String::from_utf8_lossy(&output_bytes);
|
||||
|
||||
println!("\nTRANSFORMED OUTPUT (ResponsesAPI):");
|
||||
|
|
@ -624,24 +736,55 @@ mod tests {
|
|||
println!("{}", output);
|
||||
|
||||
// Assertions
|
||||
assert!(output.contains("response.created"), "Should have response.created");
|
||||
assert!(output.contains("response.in_progress"), "Should have response.in_progress");
|
||||
assert!(output.contains("response.output_item.added"), "Should have output_item.added");
|
||||
assert!(output.contains("\"type\":\"function_call\""), "Should be function_call type");
|
||||
assert!(output.contains("\"name\":\"get_weather\""), "Should have function name");
|
||||
assert!(output.contains("\"call_id\":\"call_mD5ggLKk3SMKGPFqFdcpKg6q\""), "Should have correct call_id");
|
||||
assert!(
|
||||
output.contains("response.created"),
|
||||
"Should have response.created"
|
||||
);
|
||||
assert!(
|
||||
output.contains("response.in_progress"),
|
||||
"Should have response.in_progress"
|
||||
);
|
||||
assert!(
|
||||
output.contains("response.output_item.added"),
|
||||
"Should have output_item.added"
|
||||
);
|
||||
assert!(
|
||||
output.contains("\"type\":\"function_call\""),
|
||||
"Should be function_call type"
|
||||
);
|
||||
assert!(
|
||||
output.contains("\"name\":\"get_weather\""),
|
||||
"Should have function name"
|
||||
);
|
||||
assert!(
|
||||
output.contains("\"call_id\":\"call_mD5ggLKk3SMKGPFqFdcpKg6q\""),
|
||||
"Should have correct call_id"
|
||||
);
|
||||
|
||||
let delta_count = output.matches("event: response.function_call_arguments.delta").count();
|
||||
let delta_count = output
|
||||
.matches("event: response.function_call_arguments.delta")
|
||||
.count();
|
||||
assert_eq!(delta_count, 4, "Should have 4 delta events");
|
||||
|
||||
assert!(!output.contains("response.function_call_arguments.done"), "Should NOT have arguments.done");
|
||||
assert!(!output.contains("response.output_item.done"), "Should NOT have output_item.done");
|
||||
assert!(!output.contains("response.completed"), "Should NOT have response.completed");
|
||||
assert!(
|
||||
!output.contains("response.function_call_arguments.done"),
|
||||
"Should NOT have arguments.done"
|
||||
);
|
||||
assert!(
|
||||
!output.contains("response.output_item.done"),
|
||||
"Should NOT have output_item.done"
|
||||
);
|
||||
assert!(
|
||||
!output.contains("response.completed"),
|
||||
"Should NOT have response.completed"
|
||||
);
|
||||
|
||||
println!("\nVALIDATION SUMMARY:");
|
||||
println!("{}", "-".repeat(80));
|
||||
println!("✓ Lifecycle events: response.created, response.in_progress");
|
||||
println!("✓ Function call metadata: name='get_weather', call_id='call_mD5ggLKk3SMKGPFqFdcpKg6q'");
|
||||
println!(
|
||||
"✓ Function call metadata: name='get_weather', call_id='call_mD5ggLKk3SMKGPFqFdcpKg6q'"
|
||||
);
|
||||
println!("✓ Incremental deltas: 4 events (1 initial + 3 argument chunks)");
|
||||
println!("✓ NO completion events (partial stream, no [DONE])");
|
||||
println!("✓ Arguments accumulated: '{{\"location\":\"'\n");
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
use crate::providers::streaming_response::ProviderStreamResponse;
|
||||
use crate::providers::streaming_response::ProviderStreamResponseType;
|
||||
use crate::apis::streaming_shapes::chat_completions_streaming_buffer::OpenAIChatCompletionsStreamBuffer;
|
||||
use crate::apis::streaming_shapes::anthropic_streaming_buffer::AnthropicMessagesStreamBuffer;
|
||||
use crate::apis::streaming_shapes::chat_completions_streaming_buffer::OpenAIChatCompletionsStreamBuffer;
|
||||
use crate::apis::streaming_shapes::passthrough_streaming_buffer::PassthroughStreamBuffer;
|
||||
use crate::apis::streaming_shapes::responses_api_streaming_buffer::ResponsesAPIStreamBuffer;
|
||||
use crate::providers::streaming_response::ProviderStreamResponse;
|
||||
use crate::providers::streaming_response::ProviderStreamResponseType;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
|
|
@ -37,7 +37,7 @@ pub trait SseStreamBufferTrait: Send + Sync {
|
|||
///
|
||||
/// # Returns
|
||||
/// Bytes ready for wire transmission (may be empty if no events were accumulated)
|
||||
fn into_bytes(&mut self) -> Vec<u8>;
|
||||
fn to_bytes(&mut self) -> Vec<u8>;
|
||||
}
|
||||
|
||||
/// Unified SSE Stream Buffer enum that provides a zero-cost abstraction
|
||||
|
|
@ -45,7 +45,7 @@ pub enum SseStreamBuffer {
|
|||
Passthrough(PassthroughStreamBuffer),
|
||||
OpenAIChatCompletions(OpenAIChatCompletionsStreamBuffer),
|
||||
AnthropicMessages(AnthropicMessagesStreamBuffer),
|
||||
OpenAIResponses(ResponsesAPIStreamBuffer),
|
||||
OpenAIResponses(Box<ResponsesAPIStreamBuffer>),
|
||||
}
|
||||
|
||||
impl SseStreamBufferTrait for SseStreamBuffer {
|
||||
|
|
@ -58,12 +58,12 @@ impl SseStreamBufferTrait for SseStreamBuffer {
|
|||
}
|
||||
}
|
||||
|
||||
fn into_bytes(&mut self) -> Vec<u8> {
|
||||
fn to_bytes(&mut self) -> Vec<u8> {
|
||||
match self {
|
||||
Self::Passthrough(buffer) => buffer.into_bytes(),
|
||||
Self::OpenAIChatCompletions(buffer) => buffer.into_bytes(),
|
||||
Self::AnthropicMessages(buffer) => buffer.into_bytes(),
|
||||
Self::OpenAIResponses(buffer) => buffer.into_bytes(),
|
||||
Self::Passthrough(buffer) => buffer.to_bytes(),
|
||||
Self::OpenAIChatCompletions(buffer) => buffer.to_bytes(),
|
||||
Self::AnthropicMessages(buffer) => buffer.to_bytes(),
|
||||
Self::OpenAIResponses(buffer) => buffer.to_bytes(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -99,7 +99,7 @@ impl SseEvent {
|
|||
let sse_string: String = response.clone().into();
|
||||
|
||||
SseEvent {
|
||||
data: None, // Data is embedded in sse_transformed_lines
|
||||
data: None, // Data is embedded in sse_transformed_lines
|
||||
event: None, // Event type is embedded in sse_transformed_lines
|
||||
raw_line: sse_string.clone(),
|
||||
sse_transformed_lines: sse_string,
|
||||
|
|
@ -149,10 +149,8 @@ impl FromStr for SseEvent {
|
|||
});
|
||||
}
|
||||
|
||||
if trimmed_line.starts_with("data: ") {
|
||||
let data: String = trimmed_line[6..].to_string(); // Remove "data: " prefix
|
||||
// Allow empty data content after "data: " prefix
|
||||
// This handles cases like "data: " followed by newline
|
||||
if let Some(stripped) = trimmed_line.strip_prefix("data: ") {
|
||||
let data: String = stripped.to_string();
|
||||
if data.trim().is_empty() {
|
||||
return Err(SseParseError {
|
||||
message: "Empty data field after 'data: ' prefix".to_string(),
|
||||
|
|
@ -166,8 +164,8 @@ impl FromStr for SseEvent {
|
|||
sse_transformed_lines: line.to_string(),
|
||||
provider_stream_response: None,
|
||||
})
|
||||
} else if trimmed_line.starts_with("event: ") {
|
||||
let event_type = trimmed_line[7..].to_string();
|
||||
} else if let Some(stripped) = trimmed_line.strip_prefix("event: ") {
|
||||
let event_type = stripped.to_string();
|
||||
if event_type.is_empty() {
|
||||
return Err(SseParseError {
|
||||
message: "Empty event field is not a valid SSE event".to_string(),
|
||||
|
|
@ -183,7 +181,10 @@ impl FromStr for SseEvent {
|
|||
})
|
||||
} else {
|
||||
Err(SseParseError {
|
||||
message: format!("Line does not start with 'data: ' or 'event: ': {}", trimmed_line),
|
||||
message: format!(
|
||||
"Line does not start with 'data: ' or 'event: ': {}",
|
||||
trimmed_line
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -196,16 +197,16 @@ impl fmt::Display for SseEvent {
|
|||
}
|
||||
|
||||
// Into implementation to convert SseEvent to bytes for response buffer
|
||||
impl Into<Vec<u8>> for SseEvent {
|
||||
fn into(self) -> Vec<u8> {
|
||||
impl From<SseEvent> for Vec<u8> {
|
||||
fn from(val: SseEvent) -> Self {
|
||||
// For generated events (like ResponsesAPI), sse_transformed_lines already includes trailing \n\n
|
||||
// For parsed events (like passthrough), we need to add the \n\n separator
|
||||
if self.sse_transformed_lines.ends_with("\n\n") {
|
||||
if val.sse_transformed_lines.ends_with("\n\n") {
|
||||
// Already properly formatted with trailing newlines
|
||||
self.sse_transformed_lines.into_bytes()
|
||||
val.sse_transformed_lines.into_bytes()
|
||||
} else {
|
||||
// Add SSE event separator
|
||||
format!("{}\n\n", self.sse_transformed_lines).into_bytes()
|
||||
format!("{}\n\n", val.sse_transformed_lines).into_bytes()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ pub struct SseChunkProcessor {
|
|||
incomplete_event_buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Default for SseChunkProcessor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SseChunkProcessor {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
|
|
@ -93,8 +99,8 @@ impl SseChunkProcessor {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::clients::endpoints::{SupportedAPIsFromClient, SupportedUpstreamAPIs};
|
||||
use crate::apis::openai::OpenAIApi;
|
||||
use crate::clients::endpoints::{SupportedAPIsFromClient, SupportedUpstreamAPIs};
|
||||
|
||||
#[test]
|
||||
fn test_complete_events_process_immediately() {
|
||||
|
|
@ -104,7 +110,9 @@ mod tests {
|
|||
|
||||
let chunk1 = b"data: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chunk\",\"created\":1234567890,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}\n\n";
|
||||
|
||||
let events = processor.process_chunk(chunk1, &client_api, &upstream_api).unwrap();
|
||||
let events = processor
|
||||
.process_chunk(chunk1, &client_api, &upstream_api)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(!processor.has_buffered_data());
|
||||
|
|
@ -119,18 +127,28 @@ mod tests {
|
|||
// First chunk with incomplete JSON
|
||||
let chunk1 = b"data: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chu";
|
||||
|
||||
let events1 = processor.process_chunk(chunk1, &client_api, &upstream_api).unwrap();
|
||||
let events1 = processor
|
||||
.process_chunk(chunk1, &client_api, &upstream_api)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(events1.len(), 0, "Incomplete event should not be processed");
|
||||
assert!(processor.has_buffered_data(), "Incomplete data should be buffered");
|
||||
assert!(
|
||||
processor.has_buffered_data(),
|
||||
"Incomplete data should be buffered"
|
||||
);
|
||||
|
||||
// Second chunk completes the JSON
|
||||
let chunk2 = b"nk\",\"created\":1234567890,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}\n\n";
|
||||
|
||||
let events2 = processor.process_chunk(chunk2, &client_api, &upstream_api).unwrap();
|
||||
let events2 = processor
|
||||
.process_chunk(chunk2, &client_api, &upstream_api)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(events2.len(), 1, "Complete event should be processed");
|
||||
assert!(!processor.has_buffered_data(), "Buffer should be cleared after completion");
|
||||
assert!(
|
||||
!processor.has_buffered_data(),
|
||||
"Buffer should be cleared after completion"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -142,10 +160,15 @@ mod tests {
|
|||
// Chunk with 2 complete events and 1 incomplete
|
||||
let chunk = b"data: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chunk\",\"created\":1234567890,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"A\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-124\",\"object\":\"chat.completion.chunk\",\"created\":1234567890,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"B\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-125\",\"object\":\"chat.completion.chu";
|
||||
|
||||
let events = processor.process_chunk(chunk, &client_api, &upstream_api).unwrap();
|
||||
let events = processor
|
||||
.process_chunk(chunk, &client_api, &upstream_api)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(events.len(), 2, "Two complete events should be processed");
|
||||
assert!(processor.has_buffered_data(), "Incomplete third event should be buffered");
|
||||
assert!(
|
||||
processor.has_buffered_data(),
|
||||
"Incomplete third event should be buffered"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -171,11 +194,23 @@ data: {"type":"content_block_stop","index":0}
|
|||
Ok(events) => {
|
||||
println!("Successfully processed {} events", events.len());
|
||||
for (i, event) in events.iter().enumerate() {
|
||||
println!("Event {}: event={:?}, has_data={}", i, event.event, event.data.is_some());
|
||||
println!(
|
||||
"Event {}: event={:?}, has_data={}",
|
||||
i,
|
||||
event.event,
|
||||
event.data.is_some()
|
||||
);
|
||||
}
|
||||
// Should successfully process both events (signature_delta + content_block_stop)
|
||||
assert!(events.len() >= 2, "Should process at least 2 complete events (signature_delta + stop), got {}", events.len());
|
||||
assert!(!processor.has_buffered_data(), "Complete events should not be buffered");
|
||||
assert!(
|
||||
events.len() >= 2,
|
||||
"Should process at least 2 complete events (signature_delta + stop), got {}",
|
||||
events.len()
|
||||
);
|
||||
assert!(
|
||||
!processor.has_buffered_data(),
|
||||
"Complete events should not be buffered"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
panic!("Failed to process signature_delta chunk - this means SignatureDelta is not properly handled: {}", e);
|
||||
|
|
@ -194,12 +229,21 @@ data: {"type":"content_block_stop","index":0}
|
|||
// Second event is valid and should be processed
|
||||
let chunk = b"data: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chunk\",\"created\":1234567890,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"unsupported_field_causing_validation_error\":true},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-124\",\"object\":\"chat.completion.chunk\",\"created\":1234567890,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}\n\n";
|
||||
|
||||
let events = processor.process_chunk(chunk, &client_api, &upstream_api).unwrap();
|
||||
let events = processor
|
||||
.process_chunk(chunk, &client_api, &upstream_api)
|
||||
.unwrap();
|
||||
|
||||
// Should skip the invalid event and process the valid one
|
||||
// (If we were buffering all errors, we'd get 0 events and have buffered data)
|
||||
assert!(events.len() >= 1, "Should process at least the valid event, got {} events", events.len());
|
||||
assert!(!processor.has_buffered_data(), "Invalid (non-incomplete) events should not be buffered");
|
||||
assert!(
|
||||
!events.is_empty(),
|
||||
"Should process at least the valid event, got {} events",
|
||||
events.len()
|
||||
);
|
||||
assert!(
|
||||
!processor.has_buffered_data(),
|
||||
"Invalid (non-incomplete) events should not be buffered"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -227,14 +271,27 @@ data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text
|
|||
|
||||
match result {
|
||||
Ok(events) => {
|
||||
println!("Processed {} events (unsupported event should be skipped)", events.len());
|
||||
println!(
|
||||
"Processed {} events (unsupported event should be skipped)",
|
||||
events.len()
|
||||
);
|
||||
// Should process the 2 valid text_delta events and skip the unsupported one
|
||||
// We expect at least 2 events (the valid ones), unsupported should be skipped
|
||||
assert!(events.len() >= 2, "Should process at least 2 valid events, got {}", events.len());
|
||||
assert!(!processor.has_buffered_data(), "Unsupported events should be skipped, not buffered");
|
||||
assert!(
|
||||
events.len() >= 2,
|
||||
"Should process at least 2 valid events, got {}",
|
||||
events.len()
|
||||
);
|
||||
assert!(
|
||||
!processor.has_buffered_data(),
|
||||
"Unsupported events should be skipped, not buffered"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
panic!("Should not fail on unsupported delta type, should skip it: {}", e);
|
||||
panic!(
|
||||
"Should not fail on unsupported delta type, should skip it: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,7 +135,10 @@ impl SupportedAPIsFromClient {
|
|||
ProviderId::AzureOpenAI => {
|
||||
if request_path.starts_with("/v1/") {
|
||||
let suffix = endpoint_suffix.trim_start_matches('/');
|
||||
build_endpoint("/openai/deployments", &format!("/{}/{}?api-version=2025-01-01-preview", model_id, suffix))
|
||||
build_endpoint(
|
||||
"/openai/deployments",
|
||||
&format!("/{}/{}?api-version=2025-01-01-preview", model_id, suffix),
|
||||
)
|
||||
} else {
|
||||
build_endpoint("/v1", endpoint_suffix)
|
||||
}
|
||||
|
|
@ -163,19 +166,21 @@ impl SupportedAPIsFromClient {
|
|||
};
|
||||
|
||||
match self {
|
||||
SupportedAPIsFromClient::AnthropicMessagesAPI(AnthropicApi::Messages) => match provider_id {
|
||||
ProviderId::Anthropic => build_endpoint("/v1", "/messages"),
|
||||
ProviderId::AmazonBedrock => {
|
||||
if request_path.starts_with("/v1/") && !is_streaming {
|
||||
build_endpoint("", &format!("/model/{}/converse", model_id))
|
||||
} else if request_path.starts_with("/v1/") && is_streaming {
|
||||
build_endpoint("", &format!("/model/{}/converse-stream", model_id))
|
||||
} else {
|
||||
build_endpoint("/v1", "/chat/completions")
|
||||
SupportedAPIsFromClient::AnthropicMessagesAPI(AnthropicApi::Messages) => {
|
||||
match provider_id {
|
||||
ProviderId::Anthropic => build_endpoint("/v1", "/messages"),
|
||||
ProviderId::AmazonBedrock => {
|
||||
if request_path.starts_with("/v1/") && !is_streaming {
|
||||
build_endpoint("", &format!("/model/{}/converse", model_id))
|
||||
} else if request_path.starts_with("/v1/") && is_streaming {
|
||||
build_endpoint("", &format!("/model/{}/converse-stream", model_id))
|
||||
} else {
|
||||
build_endpoint("/v1", "/chat/completions")
|
||||
}
|
||||
}
|
||||
_ => build_endpoint("/v1", "/chat/completions"),
|
||||
}
|
||||
_ => build_endpoint("/v1", "/chat/completions"),
|
||||
},
|
||||
}
|
||||
SupportedAPIsFromClient::OpenAIResponsesAPI(_) => {
|
||||
// For Responses API, check if provider supports it, otherwise translate to chat/completions
|
||||
match provider_id {
|
||||
|
|
@ -193,7 +198,6 @@ impl SupportedAPIsFromClient {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
impl SupportedUpstreamAPIs {
|
||||
/// Create a SupportedUpstreamApi from an endpoint path
|
||||
pub fn from_endpoint(endpoint: &str) -> Option<Self> {
|
||||
|
|
@ -216,17 +220,17 @@ impl SupportedUpstreamAPIs {
|
|||
return Some(SupportedUpstreamAPIs::AmazonBedrockConverse(bedrock_api))
|
||||
}
|
||||
AmazonBedrockApi::ConverseStream => {
|
||||
return Some(SupportedUpstreamAPIs::AmazonBedrockConverseStream(bedrock_api))
|
||||
return Some(SupportedUpstreamAPIs::AmazonBedrockConverseStream(
|
||||
bedrock_api,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Get all supported endpoint paths
|
||||
pub fn supported_endpoints() -> Vec<&'static str> {
|
||||
let mut endpoints = Vec::new();
|
||||
|
|
@ -269,9 +273,9 @@ mod tests {
|
|||
assert!(SupportedAPIsFromClient::from_endpoint("/v1/messages").is_some());
|
||||
|
||||
// Unsupported endpoints
|
||||
assert!(!SupportedAPIsFromClient::from_endpoint("/v1/unknown").is_some());
|
||||
assert!(!SupportedAPIsFromClient::from_endpoint("/v2/chat").is_some());
|
||||
assert!(!SupportedAPIsFromClient::from_endpoint("").is_some());
|
||||
assert!(SupportedAPIsFromClient::from_endpoint("/v1/unknown").is_none());
|
||||
assert!(SupportedAPIsFromClient::from_endpoint("/v2/chat").is_none());
|
||||
assert!(SupportedAPIsFromClient::from_endpoint("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -12,11 +12,9 @@ pub use aws_smithy_eventstream::frame::DecodedFrame;
|
|||
pub use providers::id::ProviderId;
|
||||
pub use providers::request::{ProviderRequest, ProviderRequestError, ProviderRequestType};
|
||||
pub use providers::response::{
|
||||
ProviderResponse, ProviderResponseType, TokenUsage, ProviderResponseError
|
||||
};
|
||||
pub use providers::streaming_response::{
|
||||
ProviderStreamResponse, ProviderStreamResponseType
|
||||
ProviderResponse, ProviderResponseError, ProviderResponseType, TokenUsage,
|
||||
};
|
||||
pub use providers::streaming_response::{ProviderStreamResponse, ProviderStreamResponseType};
|
||||
|
||||
//TODO: Refactor such that commons doesn't depend on Hermes. For now this will clean up strings
|
||||
pub const CHAT_COMPLETIONS_PATH: &str = "/v1/chat/completions";
|
||||
|
|
@ -87,11 +85,17 @@ mod tests {
|
|||
let done_event = streaming_iter.next();
|
||||
assert!(done_event.is_some(), "Should get [DONE] event");
|
||||
let done_event = done_event.unwrap();
|
||||
assert!(done_event.is_done(), "[DONE] event should be marked as done");
|
||||
assert!(
|
||||
done_event.is_done(),
|
||||
"[DONE] event should be marked as done"
|
||||
);
|
||||
|
||||
// After [DONE], iterator should return None
|
||||
let final_event = streaming_iter.next();
|
||||
assert!(final_event.is_none(), "Iterator should return None after [DONE]");
|
||||
assert!(
|
||||
final_event.is_none(),
|
||||
"Iterator should return None after [DONE]"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test AWS Event Stream decoding for Bedrock ConverseStream responses.
|
||||
|
|
@ -130,7 +134,7 @@ mod tests {
|
|||
let mut content_chunks = Vec::new();
|
||||
|
||||
// Simulate chunked network arrivals - process as data comes in
|
||||
let chunk_sizes = vec![50, 100, 75, 200, 150, 300, 500, 1000];
|
||||
let chunk_sizes = [50, 100, 75, 200, 150, 300, 500, 1000];
|
||||
let mut offset = 0;
|
||||
let mut chunk_num = 0;
|
||||
|
||||
|
|
|
|||
|
|
@ -59,10 +59,9 @@ impl ProviderId {
|
|||
(ProviderId::Anthropic, SupportedAPIsFromClient::AnthropicMessagesAPI(_)) => {
|
||||
SupportedUpstreamAPIs::AnthropicMessagesAPI(AnthropicApi::Messages)
|
||||
}
|
||||
(
|
||||
ProviderId::Anthropic,
|
||||
SupportedAPIsFromClient::OpenAIChatCompletions(_),
|
||||
) => SupportedUpstreamAPIs::OpenAIChatCompletions(OpenAIApi::ChatCompletions),
|
||||
(ProviderId::Anthropic, SupportedAPIsFromClient::OpenAIChatCompletions(_)) => {
|
||||
SupportedUpstreamAPIs::OpenAIChatCompletions(OpenAIApi::ChatCompletions)
|
||||
}
|
||||
|
||||
// Anthropic doesn't support Responses API, fall back to chat completions
|
||||
(ProviderId::Anthropic, SupportedAPIsFromClient::OpenAIResponsesAPI(_)) => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use serde_json::Value;
|
|||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ProviderRequestType {
|
||||
ChatCompletionsRequest(ChatCompletionsRequest),
|
||||
|
|
@ -197,7 +198,9 @@ impl ProviderRequest for ProviderRequestType {
|
|||
impl TryFrom<(&[u8], &SupportedAPIsFromClient)> for ProviderRequestType {
|
||||
type Error = std::io::Error;
|
||||
|
||||
fn try_from((bytes, client_api): (&[u8], &SupportedAPIsFromClient)) -> Result<Self, Self::Error> {
|
||||
fn try_from(
|
||||
(bytes, client_api): (&[u8], &SupportedAPIsFromClient),
|
||||
) -> Result<Self, Self::Error> {
|
||||
// Use SupportedApi to determine the appropriate request type
|
||||
match client_api {
|
||||
SupportedAPIsFromClient::OpenAIChatCompletions(_) => {
|
||||
|
|
@ -882,7 +885,7 @@ mod tests {
|
|||
ProviderRequestType::BedrockConverse(bedrock_req) => {
|
||||
assert_eq!(bedrock_req.model_id, "gpt-4o");
|
||||
// Bedrock receives the converted request through ChatCompletions
|
||||
assert!(!bedrock_req.messages.is_none());
|
||||
assert!(bedrock_req.messages.is_some());
|
||||
}
|
||||
_ => panic!("Expected BedrockConverse variant"),
|
||||
}
|
||||
|
|
@ -913,7 +916,9 @@ mod tests {
|
|||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(err.message.contains("ResponsesAPI can only be used as a client API"));
|
||||
assert!(err
|
||||
.message
|
||||
.contains("ResponsesAPI can only be used as a client API"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -953,7 +958,9 @@ mod tests {
|
|||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(err.message.contains("ResponsesAPI can only be used as a client API"));
|
||||
assert!(err
|
||||
.message
|
||||
.contains("ResponsesAPI can only be used as a client API"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1023,9 +1030,7 @@ mod tests {
|
|||
role: MessagesRole::User,
|
||||
content: MessagesMessageContent::Single("Hello!".to_string()),
|
||||
}],
|
||||
system: Some(MessagesSystemPrompt::Single(
|
||||
"You are helpful".to_string(),
|
||||
)),
|
||||
system: Some(MessagesSystemPrompt::Single("You are helpful".to_string())),
|
||||
max_tokens: 100,
|
||||
container: None,
|
||||
mcp_servers: None,
|
||||
|
|
@ -1046,14 +1051,8 @@ mod tests {
|
|||
|
||||
// Should have system message + user message
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(
|
||||
messages[0].role,
|
||||
crate::apis::openai::Role::System
|
||||
);
|
||||
assert_eq!(
|
||||
messages[1].role,
|
||||
crate::apis::openai::Role::User
|
||||
);
|
||||
assert_eq!(messages[0].role, crate::apis::openai::Role::System);
|
||||
assert_eq!(messages[1].role, crate::apis::openai::Role::User);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1094,13 +1093,7 @@ mod tests {
|
|||
|
||||
// Should have system message (instructions) + user message (input)
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(
|
||||
messages[0].role,
|
||||
crate::apis::openai::Role::System
|
||||
);
|
||||
assert_eq!(
|
||||
messages[1].role,
|
||||
crate::apis::openai::Role::User
|
||||
);
|
||||
assert_eq!(messages[0].role, crate::apis::openai::Role::System);
|
||||
assert_eq!(messages[1].role, crate::apis::openai::Role::User);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
use serde::Serialize;
|
||||
use std::convert::TryFrom;
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use crate::apis::amazon_bedrock::ConverseResponse;
|
||||
use crate::apis::anthropic::MessagesResponse;
|
||||
use crate::apis::openai::ChatCompletionsResponse;
|
||||
|
|
@ -9,14 +5,17 @@ use crate::apis::openai_responses::ResponsesAPIResponse;
|
|||
use crate::clients::endpoints::SupportedAPIsFromClient;
|
||||
use crate::clients::endpoints::SupportedUpstreamAPIs;
|
||||
use crate::providers::id::ProviderId;
|
||||
|
||||
use serde::Serialize;
|
||||
use std::convert::TryFrom;
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum ProviderResponseType {
|
||||
ChatCompletionsResponse(ChatCompletionsResponse),
|
||||
MessagesResponse(MessagesResponse),
|
||||
ResponsesAPIResponse(ResponsesAPIResponse),
|
||||
ResponsesAPIResponse(Box<ResponsesAPIResponse>),
|
||||
}
|
||||
|
||||
/// Trait for token usage information
|
||||
|
|
@ -42,7 +41,9 @@ impl ProviderResponse for ProviderResponseType {
|
|||
match self {
|
||||
ProviderResponseType::ChatCompletionsResponse(resp) => resp.usage(),
|
||||
ProviderResponseType::MessagesResponse(resp) => resp.usage(),
|
||||
ProviderResponseType::ResponsesAPIResponse(resp) => resp.usage.as_ref().map(|u| u as &dyn TokenUsage),
|
||||
ProviderResponseType::ResponsesAPIResponse(resp) => {
|
||||
resp.usage.as_ref().map(|u| u as &dyn TokenUsage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -50,11 +51,13 @@ impl ProviderResponse for ProviderResponseType {
|
|||
match self {
|
||||
ProviderResponseType::ChatCompletionsResponse(resp) => resp.extract_usage_counts(),
|
||||
ProviderResponseType::MessagesResponse(resp) => resp.extract_usage_counts(),
|
||||
ProviderResponseType::ResponsesAPIResponse(resp) => {
|
||||
resp.usage.as_ref().map(|u| {
|
||||
(u.input_tokens as usize, u.output_tokens as usize, u.total_tokens as usize)
|
||||
})
|
||||
}
|
||||
ProviderResponseType::ResponsesAPIResponse(resp) => resp.usage.as_ref().map(|u| {
|
||||
(
|
||||
u.input_tokens as usize,
|
||||
u.output_tokens as usize,
|
||||
u.total_tokens as usize,
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -156,40 +159,44 @@ impl TryFrom<(&[u8], &SupportedAPIsFromClient, &ProviderId)> for ProviderRespons
|
|||
) => {
|
||||
let resp: ResponsesAPIResponse = ResponsesAPIResponse::try_from(bytes)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
Ok(ProviderResponseType::ResponsesAPIResponse(resp))
|
||||
Ok(ProviderResponseType::ResponsesAPIResponse(Box::new(resp)))
|
||||
}
|
||||
(
|
||||
SupportedUpstreamAPIs::OpenAIChatCompletions(_),
|
||||
SupportedAPIsFromClient::OpenAIResponsesAPI(_),
|
||||
) => {
|
||||
let chat_completions_response: ChatCompletionsResponse = ChatCompletionsResponse::try_from(bytes)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
let chat_completions_response: ChatCompletionsResponse =
|
||||
ChatCompletionsResponse::try_from(bytes)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
// Transform to ResponsesAPI format using the transformer
|
||||
let responses_resp: ResponsesAPIResponse = chat_completions_response.try_into().map_err(|e| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("Transformation error: {}", e),
|
||||
)
|
||||
})?;
|
||||
Ok(ProviderResponseType::ResponsesAPIResponse(responses_resp))
|
||||
let responses_resp: ResponsesAPIResponse =
|
||||
chat_completions_response.try_into().map_err(|e| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("Transformation error: {}", e),
|
||||
)
|
||||
})?;
|
||||
Ok(ProviderResponseType::ResponsesAPIResponse(Box::new(
|
||||
responses_resp,
|
||||
)))
|
||||
}
|
||||
(
|
||||
SupportedUpstreamAPIs::AnthropicMessagesAPI(_),
|
||||
SupportedAPIsFromClient::OpenAIResponsesAPI(_),
|
||||
) => {
|
||||
|
||||
//Chain transform: Anthropic Messages -> OpenAI ChatCompletions -> ResponsesAPI
|
||||
let anthropic_resp: MessagesResponse = serde_json::from_slice(bytes)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
// Transform to ChatCompletions format using the transformer
|
||||
let chat_resp: ChatCompletionsResponse = anthropic_resp.try_into().map_err(|e| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("Transformation error: {}", e),
|
||||
)
|
||||
})?;
|
||||
let chat_resp: ChatCompletionsResponse =
|
||||
anthropic_resp.try_into().map_err(|e| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("Transformation error: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let response_api: ResponsesAPIResponse = chat_resp.try_into().map_err(|e| {
|
||||
std::io::Error::new(
|
||||
|
|
@ -197,7 +204,9 @@ impl TryFrom<(&[u8], &SupportedAPIsFromClient, &ProviderId)> for ProviderRespons
|
|||
format!("Transformation error: {}", e),
|
||||
)
|
||||
})?;
|
||||
Ok(ProviderResponseType::ResponsesAPIResponse(response_api))
|
||||
Ok(ProviderResponseType::ResponsesAPIResponse(Box::new(
|
||||
response_api,
|
||||
)))
|
||||
}
|
||||
(
|
||||
SupportedUpstreamAPIs::AmazonBedrockConverse(_),
|
||||
|
|
@ -219,10 +228,15 @@ impl TryFrom<(&[u8], &SupportedAPIsFromClient, &ProviderId)> for ProviderRespons
|
|||
let response_api: ResponsesAPIResponse = chat_resp.try_into().map_err(|e| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("ChatCompletions to ResponsesAPI transformation error: {}", e),
|
||||
format!(
|
||||
"ChatCompletions to ResponsesAPI transformation error: {}",
|
||||
e
|
||||
),
|
||||
)
|
||||
})?;
|
||||
Ok(ProviderResponseType::ResponsesAPIResponse(response_api))
|
||||
Ok(ProviderResponseType::ResponsesAPIResponse(Box::new(
|
||||
response_api,
|
||||
)))
|
||||
}
|
||||
_ => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
|
|
@ -255,8 +269,8 @@ impl Error for ProviderResponseError {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::apis::openai::OpenAIApi;
|
||||
use crate::apis::anthropic::AnthropicApi;
|
||||
use crate::apis::openai::OpenAIApi;
|
||||
use crate::clients::endpoints::SupportedAPIsFromClient;
|
||||
use crate::providers::id::ProviderId;
|
||||
use serde_json::json;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
use serde::Serialize;
|
||||
use std::convert::TryFrom;
|
||||
|
||||
use crate::apis::amazon_bedrock::ConverseStreamEvent;
|
||||
use crate::apis::anthropic::MessagesStreamEvent;
|
||||
use crate::apis::openai::ChatCompletionsStreamResponse;
|
||||
use crate::apis::openai_responses::ResponsesAPIStreamEvent;
|
||||
use crate::apis::streaming_shapes::sse::SseEvent;
|
||||
use crate::apis::amazon_bedrock::ConverseStreamEvent;
|
||||
use crate::apis::anthropic::MessagesStreamEvent;
|
||||
use crate::apis::streaming_shapes::sse::SseStreamBuffer;
|
||||
use crate::apis::streaming_shapes::{
|
||||
anthropic_streaming_buffer::AnthropicMessagesStreamBuffer,
|
||||
chat_completions_streaming_buffer::OpenAIChatCompletionsStreamBuffer,
|
||||
passthrough_streaming_buffer::PassthroughStreamBuffer,
|
||||
responses_api_streaming_buffer::ResponsesAPIStreamBuffer,
|
||||
};
|
||||
anthropic_streaming_buffer::AnthropicMessagesStreamBuffer,
|
||||
chat_completions_streaming_buffer::OpenAIChatCompletionsStreamBuffer,
|
||||
passthrough_streaming_buffer::PassthroughStreamBuffer,
|
||||
};
|
||||
|
||||
use crate::clients::endpoints::SupportedAPIsFromClient;
|
||||
use crate::clients::endpoints::SupportedUpstreamAPIs;
|
||||
|
|
@ -28,9 +27,18 @@ pub fn needs_buffering(
|
|||
) -> bool {
|
||||
match (client_api, upstream_api) {
|
||||
// Same APIs - no buffering needed
|
||||
(SupportedAPIsFromClient::OpenAIChatCompletions(_), SupportedUpstreamAPIs::OpenAIChatCompletions(_)) => false,
|
||||
(SupportedAPIsFromClient::AnthropicMessagesAPI(_), SupportedUpstreamAPIs::AnthropicMessagesAPI(_)) => false,
|
||||
(SupportedAPIsFromClient::OpenAIResponsesAPI(_), SupportedUpstreamAPIs::OpenAIResponsesAPI(_)) => false,
|
||||
(
|
||||
SupportedAPIsFromClient::OpenAIChatCompletions(_),
|
||||
SupportedUpstreamAPIs::OpenAIChatCompletions(_),
|
||||
) => false,
|
||||
(
|
||||
SupportedAPIsFromClient::AnthropicMessagesAPI(_),
|
||||
SupportedUpstreamAPIs::AnthropicMessagesAPI(_),
|
||||
) => false,
|
||||
(
|
||||
SupportedAPIsFromClient::OpenAIResponsesAPI(_),
|
||||
SupportedUpstreamAPIs::OpenAIResponsesAPI(_),
|
||||
) => false,
|
||||
|
||||
// Different APIs - buffering needed
|
||||
_ => true,
|
||||
|
|
@ -53,15 +61,12 @@ pub fn needs_buffering(
|
|||
/// // Flush to wire
|
||||
/// let bytes = buffer.into_bytes();
|
||||
/// ```
|
||||
impl TryFrom<(&SupportedAPIsFromClient, &SupportedUpstreamAPIs)>
|
||||
for SseStreamBuffer
|
||||
{
|
||||
impl TryFrom<(&SupportedAPIsFromClient, &SupportedUpstreamAPIs)> for SseStreamBuffer {
|
||||
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
fn try_from(
|
||||
(client_api, upstream_api): (&SupportedAPIsFromClient, &SupportedUpstreamAPIs),
|
||||
) -> Result<Self, Self::Error> {
|
||||
|
||||
// If APIs match, use passthrough - no buffering/transformation needed
|
||||
if !needs_buffering(client_api, upstream_api) {
|
||||
return Ok(SseStreamBuffer::Passthrough(PassthroughStreamBuffer::new()));
|
||||
|
|
@ -69,14 +74,14 @@ impl TryFrom<(&SupportedAPIsFromClient, &SupportedUpstreamAPIs)>
|
|||
|
||||
// APIs differ - use appropriate buffer for client API
|
||||
match client_api {
|
||||
SupportedAPIsFromClient::OpenAIChatCompletions(_) => {
|
||||
Ok(SseStreamBuffer::OpenAIChatCompletions(OpenAIChatCompletionsStreamBuffer::new()))
|
||||
}
|
||||
SupportedAPIsFromClient::AnthropicMessagesAPI(_) => {
|
||||
Ok(SseStreamBuffer::AnthropicMessages(AnthropicMessagesStreamBuffer::new()))
|
||||
}
|
||||
SupportedAPIsFromClient::OpenAIChatCompletions(_) => Ok(
|
||||
SseStreamBuffer::OpenAIChatCompletions(OpenAIChatCompletionsStreamBuffer::new()),
|
||||
),
|
||||
SupportedAPIsFromClient::AnthropicMessagesAPI(_) => Ok(
|
||||
SseStreamBuffer::AnthropicMessages(AnthropicMessagesStreamBuffer::new()),
|
||||
),
|
||||
SupportedAPIsFromClient::OpenAIResponsesAPI(_) => {
|
||||
Ok(SseStreamBuffer::OpenAIResponses(ResponsesAPIStreamBuffer::new()))
|
||||
Ok(SseStreamBuffer::OpenAIResponses(Box::default()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -88,11 +93,12 @@ impl TryFrom<(&SupportedAPIsFromClient, &SupportedUpstreamAPIs)>
|
|||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
#[serde(untagged)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum ProviderStreamResponseType {
|
||||
ChatCompletionsStreamResponse(ChatCompletionsStreamResponse),
|
||||
MessagesStreamEvent(MessagesStreamEvent),
|
||||
ConverseStreamEvent(ConverseStreamEvent),
|
||||
ResponseAPIStreamEvent(ResponsesAPIStreamEvent)
|
||||
ResponseAPIStreamEvent(Box<ResponsesAPIStreamEvent>),
|
||||
}
|
||||
|
||||
pub trait ProviderStreamResponse: Send + Sync {
|
||||
|
|
@ -145,12 +151,11 @@ impl ProviderStreamResponse for ProviderStreamResponseType {
|
|||
ProviderStreamResponseType::ResponseAPIStreamEvent(resp) => resp.event_type(),
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl Into<String> for ProviderStreamResponseType {
|
||||
fn into(self) -> String {
|
||||
match self {
|
||||
impl From<ProviderStreamResponseType> for String {
|
||||
fn from(val: ProviderStreamResponseType) -> String {
|
||||
match val {
|
||||
ProviderStreamResponseType::MessagesStreamEvent(event) => {
|
||||
// Use the Into<String> implementation for proper SSE formatting with event lines
|
||||
event.into()
|
||||
|
|
@ -161,27 +166,36 @@ impl Into<String> for ProviderStreamResponseType {
|
|||
}
|
||||
ProviderStreamResponseType::ResponseAPIStreamEvent(event) => {
|
||||
// Use the Into<String> implementation for proper SSE formatting with event lines
|
||||
event.into()
|
||||
// Clone to work around Box<T> ownership
|
||||
let cloned = (*event).clone();
|
||||
cloned.into()
|
||||
}
|
||||
ProviderStreamResponseType::ChatCompletionsStreamResponse(_) => {
|
||||
// For OpenAI, use simple data line format
|
||||
let json = serde_json::to_string(&self).unwrap_or_default();
|
||||
let json = serde_json::to_string(&val).unwrap_or_default();
|
||||
format!("data: {}\n\n", json)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Stream response transformation logic for client API compatibility
|
||||
impl TryFrom<(&[u8], &SupportedAPIsFromClient, &SupportedUpstreamAPIs)> for ProviderStreamResponseType {
|
||||
impl TryFrom<(&[u8], &SupportedAPIsFromClient, &SupportedUpstreamAPIs)>
|
||||
for ProviderStreamResponseType
|
||||
{
|
||||
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
fn try_from(
|
||||
(bytes, client_api, upstream_api): (&[u8], &SupportedAPIsFromClient, &SupportedUpstreamAPIs),
|
||||
(bytes, client_api, upstream_api): (
|
||||
&[u8],
|
||||
&SupportedAPIsFromClient,
|
||||
&SupportedUpstreamAPIs,
|
||||
),
|
||||
) -> Result<Self, Self::Error> {
|
||||
// Special case: Handle [DONE] marker for OpenAI -> Anthropic conversion
|
||||
if bytes == b"[DONE]" && matches!(client_api, SupportedAPIsFromClient::AnthropicMessagesAPI(_)) {
|
||||
if bytes == b"[DONE]"
|
||||
&& matches!(client_api, SupportedAPIsFromClient::AnthropicMessagesAPI(_))
|
||||
{
|
||||
return Ok(ProviderStreamResponseType::MessagesStreamEvent(
|
||||
crate::apis::anthropic::MessagesStreamEvent::MessageStop,
|
||||
));
|
||||
|
|
@ -214,9 +228,9 @@ impl TryFrom<(&[u8], &SupportedAPIsFromClient, &SupportedUpstreamAPIs)> for Prov
|
|||
) => {
|
||||
let openai_resp: crate::apis::openai::ChatCompletionsStreamResponse =
|
||||
serde_json::from_slice(bytes)?;
|
||||
let responses_resp = openai_resp.try_into()?;
|
||||
let responses_resp: ResponsesAPIStreamEvent = openai_resp.try_into()?;
|
||||
Ok(ProviderStreamResponseType::ResponseAPIStreamEvent(
|
||||
responses_resp,
|
||||
Box::new(responses_resp),
|
||||
))
|
||||
}
|
||||
|
||||
|
|
@ -267,10 +281,11 @@ impl TryFrom<(&[u8], &SupportedAPIsFromClient, &SupportedUpstreamAPIs)> for Prov
|
|||
// Chain: Bedrock -> ChatCompletions -> ResponsesAPI
|
||||
let bedrock_resp: crate::apis::amazon_bedrock::ConverseStreamEvent =
|
||||
serde_json::from_slice(bytes)?;
|
||||
let chat_resp: crate::apis::openai::ChatCompletionsStreamResponse = bedrock_resp.try_into()?;
|
||||
let responses_resp = chat_resp.try_into()?;
|
||||
let chat_resp: crate::apis::openai::ChatCompletionsStreamResponse =
|
||||
bedrock_resp.try_into()?;
|
||||
let responses_resp: ResponsesAPIStreamEvent = chat_resp.try_into()?;
|
||||
Ok(ProviderStreamResponseType::ResponseAPIStreamEvent(
|
||||
responses_resp,
|
||||
Box::new(responses_resp),
|
||||
))
|
||||
}
|
||||
_ => Err(std::io::Error::new(
|
||||
|
|
@ -287,7 +302,11 @@ impl TryFrom<(SseEvent, &SupportedAPIsFromClient, &SupportedUpstreamAPIs)> for S
|
|||
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
fn try_from(
|
||||
(sse_event, client_api, upstream_api): (SseEvent, &SupportedAPIsFromClient, &SupportedUpstreamAPIs),
|
||||
(sse_event, client_api, upstream_api): (
|
||||
SseEvent,
|
||||
&SupportedAPIsFromClient,
|
||||
&SupportedUpstreamAPIs,
|
||||
),
|
||||
) -> Result<Self, Self::Error> {
|
||||
// Create a new transformed event based on the original
|
||||
let mut transformed_event = sse_event;
|
||||
|
|
@ -296,7 +315,11 @@ impl TryFrom<(SseEvent, &SupportedAPIsFromClient, &SupportedUpstreamAPIs)> for S
|
|||
if transformed_event.is_done() {
|
||||
// For OpenAI client APIs (ChatCompletions and ResponsesAPI), keep [DONE] as-is
|
||||
// For Anthropic client API, it will be transformed via ProviderStreamResponseType
|
||||
if matches!(client_api, SupportedAPIsFromClient::OpenAIChatCompletions(_) | SupportedAPIsFromClient::OpenAIResponsesAPI(_)) {
|
||||
if matches!(
|
||||
client_api,
|
||||
SupportedAPIsFromClient::OpenAIChatCompletions(_)
|
||||
| SupportedAPIsFromClient::OpenAIResponsesAPI(_)
|
||||
) {
|
||||
// Keep the [DONE] marker as-is for OpenAI clients
|
||||
transformed_event.sse_transformed_lines = "data: [DONE]".to_string();
|
||||
return Ok(transformed_event);
|
||||
|
|
@ -328,7 +351,7 @@ impl TryFrom<(SseEvent, &SupportedAPIsFromClient, &SupportedUpstreamAPIs)> for S
|
|||
// OpenAI clients don't expect separate event: lines
|
||||
// Suppress upstream Anthropic event-only lines
|
||||
if transformed_event.is_event_only() && transformed_event.event.is_some() {
|
||||
transformed_event.sse_transformed_lines = format!("\n");
|
||||
transformed_event.sse_transformed_lines = "\n".to_string();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
|
|
@ -345,7 +368,8 @@ impl TryFrom<(SseEvent, &SupportedAPIsFromClient, &SupportedUpstreamAPIs)> for S
|
|||
(
|
||||
SupportedAPIsFromClient::AnthropicMessagesAPI(_),
|
||||
SupportedUpstreamAPIs::AnthropicMessagesAPI(_),
|
||||
) | (
|
||||
)
|
||||
| (
|
||||
SupportedAPIsFromClient::OpenAIResponsesAPI(_),
|
||||
SupportedUpstreamAPIs::OpenAIResponsesAPI(_),
|
||||
) => {
|
||||
|
|
@ -415,7 +439,7 @@ impl
|
|||
openai_event,
|
||||
))
|
||||
}
|
||||
(
|
||||
(
|
||||
SupportedUpstreamAPIs::AmazonBedrockConverseStream(_),
|
||||
SupportedAPIsFromClient::OpenAIResponsesAPI(_),
|
||||
) => {
|
||||
|
|
@ -428,7 +452,7 @@ impl
|
|||
openai_chat_completions_event.try_into()?;
|
||||
|
||||
Ok(ProviderStreamResponseType::ResponseAPIStreamEvent(
|
||||
openai_responses_api_event,
|
||||
Box::new(openai_responses_api_event),
|
||||
))
|
||||
}
|
||||
_ => Err("Unsupported API combination for event-stream decoding".into()),
|
||||
|
|
@ -445,11 +469,11 @@ impl
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::apis::streaming_shapes::amazon_bedrock_binary_frame::BedrockBinaryFrameDecoder;
|
||||
use crate::clients::endpoints::SupportedAPIsFromClient;
|
||||
use crate::apis::streaming_shapes::sse::SseStreamIter;
|
||||
use crate::clients::endpoints::SupportedAPIsFromClient;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
#[test]
|
||||
fn test_sse_event_parsing() {
|
||||
// Test valid SSE data line
|
||||
let line = "data: {\"id\":\"test\",\"object\":\"chat.completion.chunk\"}\n\n";
|
||||
|
|
@ -792,7 +816,7 @@ mod tests {
|
|||
// Simulate chunked network arrivals with realistic chunk sizes
|
||||
// Using varying chunk sizes to test partial frame handling
|
||||
let mut buffer = BytesMut::new();
|
||||
let chunk_size_pattern = vec![500, 1000, 750, 1200, 800, 1500];
|
||||
let chunk_size_pattern = [500, 1000, 750, 1200, 800, 1500];
|
||||
let mut offset = 0;
|
||||
let mut total_frames = 0;
|
||||
let mut chunk_num = 0;
|
||||
|
|
@ -837,7 +861,7 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[test]
|
||||
fn test_bedrock_decoded_frame_to_provider_response() {
|
||||
test_bedrock_conversion(false);
|
||||
}
|
||||
|
|
@ -879,8 +903,9 @@ mod tests {
|
|||
|
||||
let mut decoder = BedrockBinaryFrameDecoder::new(&mut buffer);
|
||||
|
||||
let client_api =
|
||||
SupportedAPIsFromClient::AnthropicMessagesAPI(crate::apis::anthropic::AnthropicApi::Messages);
|
||||
let client_api = SupportedAPIsFromClient::AnthropicMessagesAPI(
|
||||
crate::apis::anthropic::AnthropicApi::Messages,
|
||||
);
|
||||
let upstream_api = SupportedUpstreamAPIs::AmazonBedrockConverseStream(
|
||||
crate::apis::amazon_bedrock::AmazonBedrockApi::ConverseStream,
|
||||
);
|
||||
|
|
@ -966,8 +991,9 @@ mod tests {
|
|||
|
||||
let mut decoder = BedrockBinaryFrameDecoder::new(&mut buffer);
|
||||
|
||||
let client_api =
|
||||
SupportedAPIsFromClient::AnthropicMessagesAPI(crate::apis::anthropic::AnthropicApi::Messages);
|
||||
let client_api = SupportedAPIsFromClient::AnthropicMessagesAPI(
|
||||
crate::apis::anthropic::AnthropicApi::Messages,
|
||||
);
|
||||
let upstream_api = SupportedUpstreamAPIs::AmazonBedrockConverseStream(
|
||||
crate::apis::amazon_bedrock::AmazonBedrockApi::ConverseStream,
|
||||
);
|
||||
|
|
@ -1051,7 +1077,6 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn test_sse_event_transformation_openai_to_anthropic_message_delta() {
|
||||
use crate::apis::anthropic::AnthropicApi;
|
||||
|
|
@ -1079,8 +1104,8 @@ mod tests {
|
|||
let sse_event = SseEvent {
|
||||
data: Some(openai_stream_chunk.to_string()),
|
||||
event: None,
|
||||
raw_line: format!("data: {}", openai_stream_chunk.to_string()),
|
||||
sse_transformed_lines: format!("data: {}", openai_stream_chunk.to_string()),
|
||||
raw_line: format!("data: {}", openai_stream_chunk),
|
||||
sse_transformed_lines: format!("data: {}", openai_stream_chunk),
|
||||
provider_stream_response: None,
|
||||
};
|
||||
|
||||
|
|
@ -1101,7 +1126,8 @@ mod tests {
|
|||
// Verify the event was transformed to Anthropic format
|
||||
// This should contain message_delta with stop_reason and usage
|
||||
assert!(
|
||||
buffer.contains("event: message_delta") || buffer.contains("\"type\":\"message_delta\""),
|
||||
buffer.contains("event: message_delta")
|
||||
|| buffer.contains("\"type\":\"message_delta\""),
|
||||
"Should contain message_delta in transformed event"
|
||||
);
|
||||
|
||||
|
|
@ -1134,8 +1160,8 @@ mod tests {
|
|||
let sse_event = SseEvent {
|
||||
data: Some(openai_stream_chunk.to_string()),
|
||||
event: None,
|
||||
raw_line: format!("data: {}", openai_stream_chunk.to_string()),
|
||||
sse_transformed_lines: format!("data: {}", openai_stream_chunk.to_string()),
|
||||
raw_line: format!("data: {}", openai_stream_chunk),
|
||||
sse_transformed_lines: format!("data: {}", openai_stream_chunk),
|
||||
provider_stream_response: None,
|
||||
};
|
||||
|
||||
|
|
@ -1223,8 +1249,8 @@ mod tests {
|
|||
let sse_event = SseEvent {
|
||||
data: Some(anthropic_event.to_string()),
|
||||
event: None,
|
||||
raw_line: format!("data: {}", anthropic_event.to_string()),
|
||||
sse_transformed_lines: format!("data: {}", anthropic_event.to_string()),
|
||||
raw_line: format!("data: {}", anthropic_event),
|
||||
sse_transformed_lines: format!("data: {}", anthropic_event),
|
||||
provider_stream_response: None,
|
||||
};
|
||||
|
||||
|
|
@ -1314,8 +1340,8 @@ mod tests {
|
|||
let sse_event = SseEvent {
|
||||
data: Some(openai_stream_chunk.to_string()),
|
||||
event: None,
|
||||
raw_line: format!("data: {}", openai_stream_chunk.to_string()),
|
||||
sse_transformed_lines: format!("data: {}", openai_stream_chunk.to_string()),
|
||||
raw_line: format!("data: {}", openai_stream_chunk),
|
||||
sse_transformed_lines: format!("data: {}", openai_stream_chunk),
|
||||
provider_stream_response: None,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -11,11 +11,11 @@ pub trait ExtractText {
|
|||
/// Trait for utility functions on content collections
|
||||
pub trait ContentUtils<T> {
|
||||
fn extract_tool_calls(&self) -> Result<Option<Vec<ToolCall>>, TransformError>;
|
||||
fn split_for_openai(
|
||||
&self,
|
||||
) -> Result<(Vec<ContentPart>, Vec<ToolCall>, Vec<(String, String, bool)>), TransformError>;
|
||||
fn split_for_openai(&self) -> Result<SplitForOpenAIResult, TransformError>;
|
||||
}
|
||||
|
||||
pub type SplitForOpenAIResult = (Vec<ContentPart>, Vec<ToolCall>, Vec<(String, String, bool)>);
|
||||
|
||||
/// Helper to create a current unix timestamp
|
||||
pub fn current_timestamp() -> u64 {
|
||||
SystemTime::now()
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ impl TryFrom<AnthropicMessagesRequest> for ChatCompletionsRequest {
|
|||
}
|
||||
|
||||
// Convert tools and tool choice
|
||||
let openai_tools = req.tools.map(|tools| convert_anthropic_tools(tools));
|
||||
let openai_tools = req.tools.map(convert_anthropic_tools);
|
||||
let (openai_tool_choice, parallel_tool_calls) =
|
||||
convert_anthropic_tool_choice(req.tool_choice);
|
||||
|
||||
|
|
@ -218,18 +218,18 @@ impl TryFrom<MessagesMessage> for Vec<Message> {
|
|||
}
|
||||
|
||||
// Role Conversions
|
||||
impl Into<Role> for MessagesRole {
|
||||
fn into(self) -> Role {
|
||||
match self {
|
||||
impl From<MessagesRole> for Role {
|
||||
fn from(val: MessagesRole) -> Self {
|
||||
match val {
|
||||
MessagesRole::User => Role::User,
|
||||
MessagesRole::Assistant => Role::Assistant,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<MessagesStopReason> for FinishReason {
|
||||
fn into(self) -> MessagesStopReason {
|
||||
match self {
|
||||
impl From<FinishReason> for MessagesStopReason {
|
||||
fn from(val: FinishReason) -> Self {
|
||||
match val {
|
||||
FinishReason::Stop => MessagesStopReason::EndTurn,
|
||||
FinishReason::Length => MessagesStopReason::MaxTokens,
|
||||
FinishReason::ToolCalls => MessagesStopReason::ToolUse,
|
||||
|
|
@ -239,11 +239,11 @@ impl Into<MessagesStopReason> for FinishReason {
|
|||
}
|
||||
}
|
||||
|
||||
impl Into<MessagesUsage> for Usage {
|
||||
fn into(self) -> MessagesUsage {
|
||||
impl From<Usage> for MessagesUsage {
|
||||
fn from(val: Usage) -> Self {
|
||||
MessagesUsage {
|
||||
input_tokens: self.prompt_tokens,
|
||||
output_tokens: self.completion_tokens,
|
||||
input_tokens: val.prompt_tokens,
|
||||
output_tokens: val.completion_tokens,
|
||||
cache_creation_input_tokens: None,
|
||||
cache_read_input_tokens: None,
|
||||
}
|
||||
|
|
@ -251,9 +251,9 @@ impl Into<MessagesUsage> for Usage {
|
|||
}
|
||||
|
||||
// System Prompt Conversions
|
||||
impl Into<Message> for MessagesSystemPrompt {
|
||||
fn into(self) -> Message {
|
||||
let system_content = match self {
|
||||
impl From<MessagesSystemPrompt> for Message {
|
||||
fn from(val: MessagesSystemPrompt) -> Self {
|
||||
let system_content = match val {
|
||||
MessagesSystemPrompt::Single(text) => MessageContent::Text(text),
|
||||
MessagesSystemPrompt::Blocks(blocks) => MessageContent::Text(blocks.extract_text()),
|
||||
};
|
||||
|
|
@ -384,12 +384,8 @@ impl TryFrom<MessagesMessage> for BedrockMessage {
|
|||
ToolResultContent::Blocks(blocks) => {
|
||||
let mut result_blocks = Vec::new();
|
||||
for result_block in blocks {
|
||||
match result_block {
|
||||
crate::apis::anthropic::MessagesContentBlock::Text { text, .. } => {
|
||||
result_blocks.push(ToolResultContentBlock::Text { text });
|
||||
}
|
||||
// For now, skip other content types in tool results
|
||||
_ => {}
|
||||
if let crate::apis::anthropic::MessagesContentBlock::Text { text, .. } = result_block {
|
||||
result_blocks.push(ToolResultContentBlock::Text { text });
|
||||
}
|
||||
}
|
||||
result_blocks
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ use crate::apis::openai::{
|
|||
};
|
||||
|
||||
use crate::apis::openai_responses::{
|
||||
ResponsesAPIRequest, InputContent, InputItem, InputParam, MessageRole, Modality, ReasoningEffort, Tool as ResponsesTool, ToolChoice as ResponsesToolChoice
|
||||
InputContent, InputItem, InputParam, MessageRole, Modality, ReasoningEffort,
|
||||
ResponsesAPIRequest, Tool as ResponsesTool, ToolChoice as ResponsesToolChoice,
|
||||
};
|
||||
use crate::clients::TransformError;
|
||||
use crate::transforms::lib::ExtractText;
|
||||
|
|
@ -27,9 +28,9 @@ type AnthropicMessagesRequest = MessagesRequest;
|
|||
// MAIN REQUEST TRANSFORMATIONS
|
||||
// ============================================================================
|
||||
|
||||
impl Into<MessagesSystemPrompt> for Message {
|
||||
fn into(self) -> MessagesSystemPrompt {
|
||||
let system_text = match self.content {
|
||||
impl From<Message> for MessagesSystemPrompt {
|
||||
fn from(val: Message) -> Self {
|
||||
let system_text = match val.content {
|
||||
MessageContent::Text(text) => text,
|
||||
MessageContent::Parts(parts) => parts.extract_text(),
|
||||
};
|
||||
|
|
@ -163,7 +164,7 @@ impl TryFrom<Message> for BedrockMessage {
|
|||
let has_tool_calls = message
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.map_or(false, |calls| !calls.is_empty());
|
||||
.is_some_and(|calls| !calls.is_empty());
|
||||
|
||||
// Add text content if it's non-empty, or if we have no tool calls (to avoid empty content)
|
||||
if !text_content.is_empty() {
|
||||
|
|
@ -252,7 +253,6 @@ impl TryFrom<ResponsesAPIRequest> for ChatCompletionsRequest {
|
|||
type Error = TransformError;
|
||||
|
||||
fn try_from(req: ResponsesAPIRequest) -> Result<Self, Self::Error> {
|
||||
|
||||
// Convert input to messages
|
||||
let messages = match req.input {
|
||||
InputParam::Text(text) => {
|
||||
|
|
@ -282,50 +282,27 @@ impl TryFrom<ResponsesAPIRequest> for ChatCompletionsRequest {
|
|||
|
||||
// Convert each input item
|
||||
for item in items {
|
||||
match item {
|
||||
InputItem::Message(input_msg) => {
|
||||
let role = match input_msg.role {
|
||||
MessageRole::User => Role::User,
|
||||
MessageRole::Assistant => Role::Assistant,
|
||||
MessageRole::System => Role::System,
|
||||
MessageRole::Developer => Role::System, // Map developer to system
|
||||
};
|
||||
if let InputItem::Message(input_msg) = item {
|
||||
let role = match input_msg.role {
|
||||
MessageRole::User => Role::User,
|
||||
MessageRole::Assistant => Role::Assistant,
|
||||
MessageRole::System => Role::System,
|
||||
MessageRole::Developer => Role::System, // Map developer to system
|
||||
};
|
||||
|
||||
// Convert content based on MessageContent type
|
||||
let content = match &input_msg.content {
|
||||
crate::apis::openai_responses::MessageContent::Text(text) => {
|
||||
// Simple text content
|
||||
MessageContent::Text(text.clone())
|
||||
}
|
||||
crate::apis::openai_responses::MessageContent::Items(content_items) => {
|
||||
// Check if it's a single text item (can use simple text format)
|
||||
if content_items.len() == 1 {
|
||||
if let InputContent::InputText { text } = &content_items[0] {
|
||||
MessageContent::Text(text.clone())
|
||||
} else {
|
||||
// Single non-text item - use parts format
|
||||
MessageContent::Parts(
|
||||
content_items.iter()
|
||||
.filter_map(|c| match c {
|
||||
InputContent::InputText { text } => {
|
||||
Some(crate::apis::openai::ContentPart::Text { text: text.clone() })
|
||||
}
|
||||
InputContent::InputImage { image_url, .. } => {
|
||||
Some(crate::apis::openai::ContentPart::ImageUrl {
|
||||
image_url: crate::apis::openai::ImageUrl {
|
||||
url: image_url.clone(),
|
||||
detail: None,
|
||||
}
|
||||
})
|
||||
}
|
||||
InputContent::InputFile { .. } => None, // Skip files for now
|
||||
InputContent::InputAudio { .. } => None, // Skip audio for now
|
||||
})
|
||||
.collect()
|
||||
)
|
||||
}
|
||||
// Convert content based on MessageContent type
|
||||
let content = match &input_msg.content {
|
||||
crate::apis::openai_responses::MessageContent::Text(text) => {
|
||||
// Simple text content
|
||||
MessageContent::Text(text.clone())
|
||||
}
|
||||
crate::apis::openai_responses::MessageContent::Items(content_items) => {
|
||||
// Check if it's a single text item (can use simple text format)
|
||||
if content_items.len() == 1 {
|
||||
if let InputContent::InputText { text } = &content_items[0] {
|
||||
MessageContent::Text(text.clone())
|
||||
} else {
|
||||
// Multiple content items - convert to parts
|
||||
// Single non-text item - use parts format
|
||||
MessageContent::Parts(
|
||||
content_items.iter()
|
||||
.filter_map(|c| match c {
|
||||
|
|
@ -346,20 +323,41 @@ impl TryFrom<ResponsesAPIRequest> for ChatCompletionsRequest {
|
|||
.collect()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Multiple content items - convert to parts
|
||||
MessageContent::Parts(
|
||||
content_items
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
InputContent::InputText { text } => {
|
||||
Some(crate::apis::openai::ContentPart::Text {
|
||||
text: text.clone(),
|
||||
})
|
||||
}
|
||||
InputContent::InputImage { image_url, .. } => Some(
|
||||
crate::apis::openai::ContentPart::ImageUrl {
|
||||
image_url: crate::apis::openai::ImageUrl {
|
||||
url: image_url.clone(),
|
||||
detail: None,
|
||||
},
|
||||
},
|
||||
),
|
||||
InputContent::InputFile { .. } => None, // Skip files for now
|
||||
InputContent::InputAudio { .. } => None, // Skip audio for now
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
converted_messages.push(Message {
|
||||
role,
|
||||
content,
|
||||
name: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
});
|
||||
}
|
||||
// Skip non-message items (references, outputs) for now
|
||||
// These would need special handling in chat completions format
|
||||
_ => {}
|
||||
converted_messages.push(Message {
|
||||
role,
|
||||
content,
|
||||
name: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -474,7 +472,7 @@ impl TryFrom<ChatCompletionsRequest> for AnthropicMessagesRequest {
|
|||
}
|
||||
|
||||
// Convert tools and tool choice
|
||||
let anthropic_tools = req.tools.map(|tools| convert_openai_tools(tools));
|
||||
let anthropic_tools = req.tools.map(convert_openai_tools);
|
||||
let anthropic_tool_choice =
|
||||
convert_openai_tool_choice(req.tool_choice, req.parallel_tool_calls);
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue