diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000..eb76f37
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,17 @@
+{
+ "name": "ChromaDB.Client with Chroma",
+ "dockerComposeFile": "docker-compose.yml",
+ "service": "app",
+ "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
+ "forwardPorts": [8000],
+ "portsAttributes": {
+ "8000": { "label": "Chroma" }
+ },
+ "postCreateCommand": "dotnet --version",
+ "postAttachCommand": "echo 'Chroma is on http://localhost:8000. Try the client: dotnet run Samples/quickstart.cs'",
+ "customizations": {
+ "vscode": {
+ "extensions": ["ms-dotnettools.csharp"]
+ }
+ }
+}
diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml
new file mode 100644
index 0000000..e0ccb13
--- /dev/null
+++ b/.devcontainer/docker-compose.yml
@@ -0,0 +1,12 @@
+services:
+ app:
+ image: mcr.microsoft.com/devcontainers/dotnet:1-10.0
+ volumes:
+ - ../..:/workspaces:cached
+ command: sleep infinity
+ # Chroma answers on localhost:8000 inside the app container too.
+ network_mode: service:chroma
+
+ chroma:
+ image: chromadb/chroma:1.5.9
+ restart: unless-stopped
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..861196d
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,2 @@
+# The maintainers review every change.
+* @ChromaDotNet/maintainers
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..e2e1445
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,17 @@
+version: 2
+updates:
+ # Security updates only: the library keeps the lowest versions of its dependencies on purpose.
+ - package-ecosystem: nuget
+ directory: /
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 0
+
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
+ groups:
+ actions:
+ patterns:
+ - '*'
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fc224ff..c0a1605 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,23 +4,55 @@ on:
push:
branches:
- master
+ - main
tags:
- v*
pull_request:
schedule:
- cron: '4 13 * * 2'
+permissions:
+ contents: read
+
env:
CONFIGURATION: Release
DOTNET_VERSION: 8.0.x
+ # The seed of the property tests, so that every run checks the same cases: without it, each run takes new ones.
+ FSCHECK_REPLAY: '(5123468595321266860,8907826574119387873)'
jobs:
ci:
runs-on: ubuntu-latest
- name: ${{ github.event_name != 'schedule' && 'CI' || 'CI (schedule)' }}
+ name: ${{ github.event_name != 'schedule' && 'CI' || 'CI (schedule)' }} (Chroma ${{ matrix.chroma }})
+ strategy:
+ fail-fast: false
+ matrix:
+ # v2 API: 0.5.16, the first release with it; 0.5.20, the first that rejects embeddings of different dimensions;
+ # 0.6.3, the last 0.x; 1.0.0, the first 1.x; 1.5.9, the current release; latest catches new releases without blocking.
+ chroma: ['0.5.16', '0.5.20', '0.6.3', '1.0.0', '1.5.9', 'latest']
+ include:
+ # v1 API, with ChromaApiVersion.V1: 0.4.10, without tenants and databases; 0.4.15, without count_collections
+ # and $not_contains; 0.4.23, with both; 0.5.15, the last release with only the v1 API. See docs/COMPATIBILITY.md.
+ - chroma: '0.4.10'
+ api: v1
+ - chroma: '0.4.15'
+ api: v1
+ - chroma: '0.4.23'
+ api: v1
+ - chroma: '0.5.15'
+ api: v1
+ - chroma: '1.5.9'
+ pack: true
+ continue-on-error: ${{ matrix.chroma == 'latest' }}
+ env:
+ CHROMA_IMAGE: chromadb/chroma:${{ matrix.chroma }}
+ CHROMA_TEST_API_VERSION: ${{ matrix.api }}
+ # An environment variable, so that the restore in the Build step reads it too:
+ # pack --no-build takes the package id of the referenced client project from that restore.
+ PackageIdPrefix: ${{ vars.PACKAGE_ID_PREFIX }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: CI version suffix
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
@@ -31,7 +63,7 @@ jobs:
run: echo 'VERSION_SUFFIX=' >> $GITHUB_ENV
- name: Setup .NET
- uses: actions/setup-dotnet@v4
+ uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
@@ -41,34 +73,166 @@ jobs:
- name: Test
run: dotnet test -c ${{ env.CONFIGURATION }} --no-build --verbosity normal --logger 'GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true'
+ - name: Test in a non-default tenant and database
+ run: dotnet test -c ${{ env.CONFIGURATION }} --no-build --verbosity normal --logger 'GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true'
+ env:
+ CHROMA_TEST_TENANT: test_tenant
+ CHROMA_TEST_DATABASE: test_database
+
- name: Pack
+ if: ${{ matrix.pack }}
run: dotnet pack -c ${{ env.CONFIGURATION }} --no-build --version-suffix '${{ env.VERSION_SUFFIX }}'
+ # The release notes of every package link the GitHub release of its version, so they cannot stay at an older one.
+ - name: Check the release notes of the packages
+ if: ${{ matrix.pack }}
+ run: |
+ status=0
+ for pkg in $(find . -path '*/bin/*' -name '*.nupkg'); do
+ nuspec=$(unzip -p "$pkg" '*.nuspec')
+ version=$(echo "$nuspec" | sed -n 's:.*\([0-9.]*\).*.*:\1:p')
+ if echo "$nuspec" | grep -Eq ".*/releases/tag/v$(echo "$version" | sed 's/\./\\./g')([^0-9]|$)"; then
+ echo "$(basename "$pkg"): the release notes link the release v$version"
+ else
+ echo "::error::$(basename "$pkg"): the release notes do not link the release v$version"
+ status=1
+ fi
+ done
+ exit $status
+
- name: Upload
- if: ${{ github.event_name != 'schedule' }}
- uses: actions/upload-artifact@v4
+ if: ${{ matrix.pack && github.event_name != 'schedule' }}
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: packages
path: |
**/*.nupkg
**/*.snupkg
+ # An application on .NET 6 or 7 takes the netstandard2.0 build: the packages it brings must not warn that they do
+ # not support it. Built outside the repository, so its props do not apply, with -warnaserror, which turns the
+ # warnings of the targets of a package into errors (TreatWarningsAsErrors only covers the compiler).
+ - name: Build applications on .NET 6 and 7 with the package
+ if: ${{ matrix.pack }}
+ run: |
+ pkgdir="$GITHUB_WORKSPACE/ChromaDB.Client/bin/${{ env.CONFIGURATION }}"
+ file=$(basename "$(ls "$pkgdir"/*.nupkg | head -1)" .nupkg)
+ id=$(echo "$file" | sed -E 's/\.[0-9]+\.[0-9]+\.[0-9]+.*$//')
+ version="${file#$id.}"
+ mkdir -p "$RUNNER_TEMP/consumer" && cd "$RUNNER_TEMP/consumer"
+ cat > Consumer.csproj <
+
+ Exe
+ net6.0;net7.0
+ false
+ true
+
+
+
+
+
+ XML
+ echo 'System.Console.WriteLine(new ChromaDB.Client.ChromaConfigurationOptions("http://localhost:8000").Uri);' > Program.cs
+ dotnet build -warnaserror -p:RestoreSources="https://api.nuget.org/v3/index.json;$pkgdir"
+
+ # Runs after Pack: it rebuilds the netstandard2.0 client without the version suffix.
+ - name: Test the netstandard2.0 build
+ if: ${{ matrix.pack }}
+ run: dotnet test ChromaDB.Client.Tests -c ${{ env.CONFIGURATION }} -p:ChromaClientTargetFramework=netstandard2.0 --verbosity normal --logger 'GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true'
+
+ trimming:
+ # The client published with trimming and with NativeAOT, where reflection-based serialization is off,
+ # running real requests: Samples/ChromaDB.Client.TrimmingTest. Every trimming or AOT warning is an error.
+ # --lists: Chroma 1.5.9 stores lists in metadata, so a rejected list is a failure.
+ runs-on: ubuntu-latest
+ name: Trimming and NativeAOT
+ services:
+ chroma:
+ image: chromadb/chroma:1.5.9
+ ports:
+ - 8000:8000
+
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
+ with:
+ dotnet-version: ${{ env.DOTNET_VERSION }}
+
+ - name: NativeAOT prerequisites
+ run: sudo apt-get install -y clang zlib1g-dev
+
+ - name: Wait for Chroma
+ run: for i in $(seq 1 60); do curl -sf http://localhost:8000/api/v2/heartbeat && break; sleep 1; done
+
+ - name: Publish with trimming
+ run: dotnet publish Samples/ChromaDB.Client.TrimmingTest -c ${{ env.CONFIGURATION }} -r linux-x64 -o trimmed -p:TreatWarningsAsErrors=true
+
+ - name: Run with trimming
+ run: ./trimmed/ChromaDB.Client.TrimmingTest http://localhost:8000 --lists
+
+ - name: Publish with NativeAOT
+ run: dotnet publish Samples/ChromaDB.Client.TrimmingTest -c ${{ env.CONFIGURATION }} -r linux-x64 -o aot -p:TrimmingTestAot=true -p:TreatWarningsAsErrors=true
+
+ - name: Run with NativeAOT
+ run: ./aot/ChromaDB.Client.TrimmingTest http://localhost:8000 --lists
+
+ framework:
+ # The tests whose outcome depends on the runtime, on .NET Framework 4.8, where the client runs its net462 build and
+ # the runtime formats and parses numbers in its own way: ChromaDB.Client.FrameworkTests, with no server.
+ runs-on: windows-latest
+ name: .NET Framework
+
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
+ with:
+ dotnet-version: ${{ env.DOTNET_VERSION }}
+
+ - name: Test on .NET Framework 4.8
+ run: dotnet test ChromaDB.Client.FrameworkTests -c ${{ env.CONFIGURATION }} --verbosity normal
+
publish:
- needs: ci
- if: ${{ github.repository == 'ssone95/ChromaDB.Client' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v')) && github.event_name != 'schedule' }}
+ needs: [ci, trimming, framework]
+ # Publishes the version tags: with the NUGET_API_KEY secret in the original repository, and with NuGet trusted
+ # publishing where the NUGET_USER variable is set. The original repository publishes the default branch too; elsewhere
+ # its pushes leave the packages as artifacts of the run.
+ if: ${{ github.event_name != 'schedule' && (startsWith(github.ref, 'refs/tags/v') && (github.repository == 'ssone95/ChromaDB.Client' || vars.NUGET_USER != '') || github.repository == 'ssone95/ChromaDB.Client' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)) }}
runs-on: ubuntu-latest
name: Publish
+ environment: nuget
+ permissions:
+ attestations: write
+ contents: read
+ id-token: write
steps:
- name: Setup .NET
- uses: actions/setup-dotnet@v4
+ uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Download
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: packages
+ - name: Attest build provenance
+ if: ${{ startsWith(github.ref, 'refs/tags/v') }}
+ uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
+ with:
+ subject-path: '**/*.nupkg'
+
+ - name: NuGet login
+ if: ${{ vars.NUGET_USER != '' }}
+ id: login
+ uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1
+ with:
+ user: ${{ vars.NUGET_USER }}
+
- name: Publish to NuGet.org
- run: dotnet nuget push '**/*.nupkg' --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json
+ run: dotnet nuget push '**/*.nupkg' --api-key ${{ steps.login.outputs.NUGET_API_KEY || secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..7043e27
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,36 @@
+name: CodeQL
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ schedule:
+ - cron: '17 5 * * 1'
+
+permissions:
+ contents: read
+
+jobs:
+ analyze:
+ name: Analyze C#
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
+ with:
+ languages: csharp
+ build-mode: none
+
+ - name: Analyze
+ uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
+ with:
+ category: /language:csharp
diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml
new file mode 100644
index 0000000..02500e6
--- /dev/null
+++ b/.github/workflows/release-assets.yml
@@ -0,0 +1,100 @@
+name: Release assets
+
+# Attaches to each GitHub release the packages that the CI built from its tag, their Sigstore signatures and the build
+# provenance that the CI attested, after checking that nuget.org serves the same packages.
+on:
+ release:
+ types:
+ - published
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: Release tag, like v2.10.0
+ required: true
+
+permissions:
+ contents: read
+
+jobs:
+ assets:
+ name: Packages, signatures and provenance
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ attestations: read
+ contents: write
+ id-token: write
+ env:
+ TAG: ${{ github.event.release.tag_name || inputs.tag }}
+ GH_TOKEN: ${{ github.token }}
+
+ steps:
+ - name: Install Cosign
+ uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
+
+ - name: Download the packages from nuget.org
+ run: |
+ version="${TAG#v}"
+ mkdir nuget
+ for id in ChromaDotNet.Client ChromaDotNet.Client.DependencyInjection; do
+ lower=$(echo "$id" | tr '[:upper:]' '[:lower:]')
+ file="nuget/$id.$version.nupkg"
+ # nuget.org can take a few minutes to serve a new package.
+ for attempt in $(seq 1 60); do
+ if curl -fsSL -o "$file" "https://api.nuget.org/v3-flatcontainer/$lower/$version/$lower.$version.nupkg"; then
+ break
+ fi
+ # A failed transfer can leave a partial file.
+ rm -f "$file"
+ sleep 30
+ done
+ test -f "$file"
+ done
+
+ - name: Download the packages built by the CI
+ run: |
+ # The CI run of the tag publishes the packages, so it is about to finish once nuget.org serves them.
+ for attempt in $(seq 1 20); do
+ run=$(gh run list --repo "$GITHUB_REPOSITORY" --workflow ci.yml --branch "$TAG" --event push --status success --limit 1 --json databaseId --jq '.[].databaseId')
+ test -n "$run" && break
+ sleep 30
+ done
+ test -n "$run"
+ gh run download "$run" --repo "$GITHUB_REPOSITORY" --name packages --dir ci
+ mkdir packages
+ find ci -name '*.nupkg' -exec mv {} packages/ \;
+
+ - name: Check the provenance
+ run: |
+ for file in packages/*.nupkg; do
+ gh attestation verify "$file" --repo "$GITHUB_REPOSITORY" --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/ci.yml" --source-ref "refs/tags/$TAG" --format json --jq '.[].attestation.bundle' >> bundles.jsonl
+ done
+ sort -u bundles.jsonl > "packages/provenance-$TAG.intoto.jsonl"
+
+ - name: Compare with nuget.org
+ shell: python
+ run: |
+ # nuget.org adds its repository signature, .signature.p7s, to each package: every other file must be the same.
+ import glob, hashlib, os, sys, zipfile
+
+ def files(path):
+ with zipfile.ZipFile(path) as package:
+ return {entry.filename: hashlib.sha256(package.read(entry)).hexdigest() for entry in package.infolist()}
+
+ names = sorted(os.path.basename(path) for path in glob.glob('nuget/*.nupkg'))
+ if names != sorted(os.path.basename(path) for path in glob.glob('packages/*.nupkg')):
+ sys.exit('The CI built other packages than the ones on nuget.org.')
+ for name in names:
+ published = files(os.path.join('nuget', name))
+ published.pop('.signature.p7s', None)
+ if published != files(os.path.join('packages', name)):
+ sys.exit(f'{name} on nuget.org differs from the package built by the CI.')
+
+ - name: Sign
+ run: |
+ for file in packages/*.nupkg; do
+ cosign sign-blob --yes --bundle "$file.sigstore.json" "$file"
+ done
+
+ - name: Upload to the release
+ run: gh release upload "$TAG" packages/* --clobber --repo "$GITHUB_REPOSITORY"
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
new file mode 100644
index 0000000..08fbf50
--- /dev/null
+++ b/.github/workflows/scorecard.yml
@@ -0,0 +1,44 @@
+name: Scorecard
+
+on:
+ branch_protection_rule:
+ schedule:
+ - cron: '30 4 * * 1'
+ push:
+ branches:
+ - main
+
+permissions: read-all
+
+jobs:
+ analysis:
+ name: Scorecard analysis
+ runs-on: ubuntu-latest
+ permissions:
+ security-events: write
+ id-token: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ persist-credentials: false
+
+ - name: Run analysis
+ uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
+ with:
+ results_file: results.sarif
+ results_format: sarif
+ publish_results: true
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: SARIF file
+ path: results.sarif
+ retention-days: 5
+
+ - name: Upload to code scanning
+ uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
+ with:
+ sarif_file: results.sarif
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..6ea02e7
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,37 @@
+# Contributing
+
+Issues and pull requests are welcome.
+
+## Build and test
+
+```
+dotnet build
+dotnet test
+```
+
+`dotnet test` starts a Chroma container for each test fixture with Testcontainers, so Docker must be running. The variables that choose the Chroma version, the v1 API, a tenant and a database, or a server already running like Chroma Cloud are described in [Tests](#tests). `-p:ChromaClientTargetFramework=netstandard2.0` runs the tests against the .NET Standard 2.0 builds of the packages.
+
+## Pull requests
+
+- A fix comes with a test that fails without it.
+- New functionality comes with tests in the automated test suite, which the CI runs on every pull request.
+- A behavior that differs between Chroma versions is tested on the versions where it changes, and [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md) names those versions.
+- The public API is listed in `PublicAPI.Shipped.txt`, the API marked as shipped, and `PublicAPI.Unshipped.txt`, what was added or removed since; the version bump after a release moves it to `PublicAPI.Shipped.txt`. A new public member goes in `PublicAPI.Unshipped.txt`, otherwise the build warns with RS0016; the code fix of the analyzer adds it.
+- Public types and members have XML documentation, which goes in the package.
+- Versions follow semantic versioning: removing or changing public API needs a major version.
+
+The CI runs the tests against several Chroma versions, and publishes the app in `Samples/ChromaDB.Client.TrimmingTest` with trimming and with NativeAOT. Only the version tags are published to NuGet; every change merged into `main` builds the packages, which its CI run keeps as artifacts.
+
+## Tests
+
+`dotnet test` starts a Chroma container for each test fixture: `chromadb/chroma:0.6.3`, unless `CHROMA_IMAGE` names another image. `CHROMA_TEST_API_VERSION=v1` runs the tests with the v1 API.
+
+With `CHROMA_TEST_URI`, the tests run against a server that is already running, like Chroma Cloud, and take it for the latest Chroma unless `CHROMA_IMAGE` says otherwise:
+
+- `CHROMA_TEST_TOKEN` goes in `X-Chroma-Token`;
+- `CHROMA_TEST_TENANT` and `CHROMA_TEST_DATABASE` are used as they are, not created;
+- `CHROMA_TEST_MAX_BATCH_SIZE` is a batch limit lower than the one the server declares, like 300 on Chroma Cloud.
+
+Each fixture deletes the collections and the databases its requests created, and nothing else. The tests that reset the server, create or look up other tenants and databases, or query an id that does not exist are skipped.
+
+The property tests, in the classes whose names end in `PropertyTests`, check random cases with [FsCheck](https://github.com/fscheck/FsCheck) and need no Chroma. Each run takes new cases. A failure prints the case and its seed, like `(5123468595321266860,8907826574119387873)`: `FSCHECK_REPLAY` with that seed runs the same cases again. The CI sets one, so that its runs check the same cases.
diff --git a/ChromaDB.Client.DependencyInjection/ChromaClientExtensions.cs b/ChromaDB.Client.DependencyInjection/ChromaClientExtensions.cs
index 0c69b02..93ebabc 100644
--- a/ChromaDB.Client.DependencyInjection/ChromaClientExtensions.cs
+++ b/ChromaDB.Client.DependencyInjection/ChromaClientExtensions.cs
@@ -1,23 +1,175 @@
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Http;
+using Microsoft.Extensions.Options;
namespace ChromaDB.Client.DependencyInjection;
+///
+/// The registration of the ChromaClient for Microsoft.Extensions.DependencyInjection. The client is a singleton
+/// and sends each request with the current handler of IHttpClientFactory, which the factory renews after its handler
+/// lifetime, two minutes by default.
+///
public static class ChromaClientExtensions
{
- public static void AddChromaClient(this IServiceCollection services, Func? configurationOptions = null)
+ ///
+ /// Registers a ChromaClient and its options as singletons. configurationOptions takes the default options,
+ /// for http://localhost:8000, and returns the options of the client. Returns the services, for more registrations.
+ ///
+ /// The services.
+ /// Takes the default options, for http://localhost:8000, and returns the options of the client; null for the default ones.
+ /// The services, for more registrations.
+ public static IServiceCollection AddChromaClient(this IServiceCollection services, Func? configurationOptions = null)
+ => AddChromaClient(services, configurationOptions, _ => { });
+
+ ///
+ /// Registers a ChromaClient and its options as singletons. configureHttpClient configures the HttpClient
+ /// of the client, like a resilience handler, a proxy or a timeout. Returns the services, for more registrations.
+ ///
+ /// The services.
+ /// Takes the default options, for http://localhost:8000, and returns the options of the client; null for the default ones.
+ /// Configures the HttpClient of the client, like a resilience handler, a proxy or a timeout.
+ /// The services, for more registrations.
+ public static IServiceCollection AddChromaClient(this IServiceCollection services, Func? configurationOptions, Action configureHttpClient)
+ {
+ configurationOptions ??= DefaultConfigurationOptions;
+
+ ChromaConfigurationOptions options = new();
+ options = configurationOptions(options);
+
+ services.AddSingleton(options);
+ configureHttpClient(services.AddHttpClient(nameof(ChromaClient)));
+ services.AddSingleton(serviceProvider => serviceProvider.CreateChromaClient(options, nameof(ChromaClient)));
+ return services;
+ }
+
+ ///
+ /// A client and its options under a key, for an application that talks to more than one server, tenant or database.
+ /// Both are registered as singletons. Returns the services, for more registrations.
+ ///
+ /// The services.
+ /// The key of the client and of its options.
+ /// Takes the default options, for http://localhost:8000, and returns the options of the client; null for the default ones.
+ /// The services, for more registrations.
+ public static IServiceCollection AddKeyedChromaClient(this IServiceCollection services, object? serviceKey, Func? configurationOptions = null)
+ => AddKeyedChromaClient(services, serviceKey, configurationOptions, _ => { });
+
+ ///
+ /// Registers a ChromaClient and its options as singletons under serviceKey. configureHttpClient
+ /// configures the HttpClient of the client, like a resilience handler, a proxy or a timeout. Returns the services, for
+ /// more registrations.
+ ///
+ /// The services.
+ /// The key of the client and of its options.
+ /// Takes the default options, for http://localhost:8000, and returns the options of the client; null for the default ones.
+ /// Configures the HttpClient of the client, like a resilience handler, a proxy or a timeout.
+ /// The services, for more registrations.
+ public static IServiceCollection AddKeyedChromaClient(this IServiceCollection services, object? serviceKey, Func? configurationOptions, Action configureHttpClient)
{
+ // A null key would register the client without a key, next to the one of AddChromaClient.
+ if (serviceKey is null)
+ {
+ throw new ArgumentNullException(nameof(serviceKey));
+ }
configurationOptions ??= DefaultConfigurationOptions;
ChromaConfigurationOptions options = new();
options = configurationOptions(options);
- services.AddScoped();
- services.AddHttpClient(o =>
+ // The type with the text, so that keys like 1 and "1" get an HttpClient each.
+ var httpClientName = $"{nameof(ChromaClient)}:{serviceKey.GetType().FullName}:{serviceKey}";
+ services.AddKeyedSingleton(serviceKey, options);
+ configureHttpClient(services.AddHttpClient(httpClientName));
+ services.AddKeyedSingleton(serviceKey, (serviceProvider, _) => serviceProvider.CreateChromaClient(options, httpClientName));
+ return services;
+ }
+
+ ///
+ /// Registers a ChromaClient and its options as singletons, with options that configurationOptions makes from the
+ /// services when the client or the options are first resolved, like from IConfiguration or IOptions of the
+ /// application, also registered after this call. configureHttpClient configures the HttpClient of the client, like a
+ /// resilience handler, a proxy or a timeout. Returns the services, for more registrations.
+ ///
+ /// The services.
+ /// Makes the options of the client from the services.
+ /// Configures the HttpClient of the client; null for none.
+ /// The services, for more registrations.
+ public static IServiceCollection AddChromaClientFromServices(this IServiceCollection services, Func configurationOptions, Action? configureHttpClient = null)
+ {
+ if (configurationOptions is null)
+ {
+ throw new ArgumentNullException(nameof(configurationOptions));
+ }
+ services.AddSingleton(serviceProvider => MadeOptions(configurationOptions, serviceProvider));
+ var httpClientBuilder = services.AddHttpClient(nameof(ChromaClient));
+ configureHttpClient?.Invoke(httpClientBuilder);
+ services.AddSingleton(serviceProvider => serviceProvider.CreateChromaClient(serviceProvider.GetRequiredService(), nameof(ChromaClient)));
+ return services;
+ }
+
+ ///
+ /// As AddChromaClientFromServices, for a client and its options under a key, for an application that talks to more than
+ /// one server, tenant or database. Returns the services, for more registrations.
+ ///
+ /// The services.
+ /// The key of the client and of its options.
+ /// Makes the options of the client from the services.
+ /// Configures the HttpClient of the client; null for none.
+ /// The services, for more registrations.
+ public static IServiceCollection AddKeyedChromaClientFromServices(this IServiceCollection services, object serviceKey, Func configurationOptions, Action? configureHttpClient = null)
+ {
+ // A null key would register the client without a key, next to the one of AddChromaClient.
+ if (serviceKey is null)
+ {
+ throw new ArgumentNullException(nameof(serviceKey));
+ }
+ if (configurationOptions is null)
+ {
+ throw new ArgumentNullException(nameof(configurationOptions));
+ }
+ // The type with the text, so that keys like 1 and "1" get an HttpClient each, as in AddKeyedChromaClient.
+ var httpClientName = $"{nameof(ChromaClient)}:{serviceKey.GetType().FullName}:{serviceKey}";
+ services.AddKeyedSingleton(serviceKey, (serviceProvider, _) => MadeOptions(configurationOptions, serviceProvider));
+ var httpClientBuilder = services.AddHttpClient(httpClientName);
+ configureHttpClient?.Invoke(httpClientBuilder);
+ services.AddKeyedSingleton(serviceKey, (serviceProvider, key) => serviceProvider.CreateChromaClient(serviceProvider.GetRequiredKeyedService(key), httpClientName));
+ return services;
+ }
+
+ private static ChromaConfigurationOptions MadeOptions(Func configurationOptions, IServiceProvider serviceProvider)
+ => configurationOptions(serviceProvider) ?? throw new InvalidOperationException("The function of the options of the ChromaClient returned null.");
+
+ ///
+ /// A ChromaClient to keep, like a singleton of an application or of an integration, that sends each request with the
+ /// current handler of IHttpClientFactory for httpClientName: the factory renews it after its handler lifetime, two
+ /// minutes by default, so a change of the address of the server in the DNS is seen. The settings of that HttpClient, like
+ /// its timeout, are applied too. AddChromaClient and AddKeyedChromaClient create their clients with it.
+ ///
+ /// The services.
+ /// The options of the client.
+ /// The name of the HttpClient of IHttpClientFactory that sends the requests.
+ /// The client.
+ public static ChromaClient CreateChromaClient(this IServiceProvider services, ChromaConfigurationOptions options, string httpClientName)
+ => new(options, CreateHttpClient(services, httpClientName));
+
+ // The client keeps its HttpClient, which sends each request with the current handler of the factory, on every target, also
+ // .NET Framework. The settings of the HttpClient itself, like ConfigureHttpClient, are applied here.
+ private static HttpClient CreateHttpClient(IServiceProvider serviceProvider, string name)
+ {
+ var httpClient = new HttpClient(new CurrentHandler(serviceProvider.GetRequiredService(), name));
+ foreach (var action in serviceProvider.GetRequiredService>().Get(name).HttpClientActions)
{
- o.BaseAddress = options.Uri;
- });
+ action(httpClient);
+ }
+ return httpClient;
+ }
+
+ private sealed class CurrentHandler(IHttpMessageHandlerFactory factory, string name) : HttpMessageHandler
+ {
+ // The factory owns the handler, so the invoker does not dispose it.
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ => new HttpMessageInvoker(factory.CreateHandler(name), disposeHandler: false).SendAsync(request, cancellationToken);
}
private static ChromaConfigurationOptions DefaultConfigurationOptions(ChromaConfigurationOptions? options = null)
=> options ?? new ChromaConfigurationOptions();
-}
\ No newline at end of file
+}
diff --git a/ChromaDB.Client.DependencyInjection/ChromaDB.Client.DependencyInjection.csproj b/ChromaDB.Client.DependencyInjection/ChromaDB.Client.DependencyInjection.csproj
index 1d47e71..5817d01 100644
--- a/ChromaDB.Client.DependencyInjection/ChromaDB.Client.DependencyInjection.csproj
+++ b/ChromaDB.Client.DependencyInjection/ChromaDB.Client.DependencyInjection.csproj
@@ -5,20 +5,42 @@
enable
enable
true
- 1.0.1
+
+ true
+ 2.18.0
ChromaDB.Client.DependencyInjection
- .NET SDK for Chroma database
- chroma chromadb vector database
+
+ $(PackageIdPrefix).Client.DependencyInjection
+ ssone95, cincuranet, ChromaDotNet contributors
+ Dependency injection for ChromaDotNet.Client, the .NET client for Chroma and Chroma Cloud: AddChromaClient, AddKeyedChromaClient and CreateChromaClient. Tested with Chroma 0.4.10 to 1.5.9. Community project, not affiliated with Chroma.
+ chroma chromadb vector-database chroma-cloud ai embeddings rag
README.md
+
+ icon.png
MIT
git
https://github.com/ssone95/ChromaDB.Client
https://github.com/ssone95/ChromaDB.Client
+
+ https://github.com/$(GITHUB_REPOSITORY)
+ https://github.com/$(GITHUB_REPOSITORY)
true
+
+ true
+ $(NoWarn);RS0026;RS0027
snupkg
+
+ With ChromaDotNet.Client $(VersionPrefix). Release notes: $(PackageProjectUrl)/releases/tag/v$(VersionPrefix).
+
+
+
+
+
+
diff --git a/ChromaDB.Client.DependencyInjection/PublicAPI.Shipped.txt b/ChromaDB.Client.DependencyInjection/PublicAPI.Shipped.txt
new file mode 100644
index 0000000..49fda8d
--- /dev/null
+++ b/ChromaDB.Client.DependencyInjection/PublicAPI.Shipped.txt
@@ -0,0 +1,7 @@
+#nullable enable
+ChromaDB.Client.DependencyInjection.ChromaClientExtensions
+static ChromaDB.Client.DependencyInjection.ChromaClientExtensions.AddChromaClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Func? configurationOptions = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
+static ChromaDB.Client.DependencyInjection.ChromaClientExtensions.AddChromaClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Func? configurationOptions, System.Action! configureHttpClient) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
+static ChromaDB.Client.DependencyInjection.ChromaClientExtensions.AddKeyedChromaClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, object? serviceKey, System.Func? configurationOptions = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
+static ChromaDB.Client.DependencyInjection.ChromaClientExtensions.AddKeyedChromaClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, object? serviceKey, System.Func? configurationOptions, System.Action! configureHttpClient) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
+static ChromaDB.Client.DependencyInjection.ChromaClientExtensions.CreateChromaClient(this System.IServiceProvider! services, ChromaDB.Client.ChromaConfigurationOptions! options, string! httpClientName) -> ChromaDB.Client.ChromaClient!
diff --git a/ChromaDB.Client.DependencyInjection/PublicAPI.Unshipped.txt b/ChromaDB.Client.DependencyInjection/PublicAPI.Unshipped.txt
new file mode 100644
index 0000000..c54fcbc
--- /dev/null
+++ b/ChromaDB.Client.DependencyInjection/PublicAPI.Unshipped.txt
@@ -0,0 +1,3 @@
+#nullable enable
+static ChromaDB.Client.DependencyInjection.ChromaClientExtensions.AddChromaClientFromServices(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Func! configurationOptions, System.Action? configureHttpClient = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
+static ChromaDB.Client.DependencyInjection.ChromaClientExtensions.AddKeyedChromaClientFromServices(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, object! serviceKey, System.Func! configurationOptions, System.Action? configureHttpClient = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
diff --git a/ChromaDB.Client.FrameworkTests/ChromaDB.Client.FrameworkTests.csproj b/ChromaDB.Client.FrameworkTests/ChromaDB.Client.FrameworkTests.csproj
new file mode 100644
index 0000000..0ecf49e
--- /dev/null
+++ b/ChromaDB.Client.FrameworkTests/ChromaDB.Client.FrameworkTests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net48
+ latest
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ChromaDB.Client.Tests/ApiV2CompleteTests.cs b/ChromaDB.Client.Tests/ApiV2CompleteTests.cs
new file mode 100644
index 0000000..9e76574
--- /dev/null
+++ b/ChromaDB.Client.Tests/ApiV2CompleteTests.cs
@@ -0,0 +1,352 @@
+using System.Net;
+using ChromaDB.Client.Models;
+using NUnit.Framework;
+
+namespace ChromaDB.Client.Tests;
+
+// The operations added to cover the v2 API, against each version of Chroma, and against Chroma Cloud with CHROMA_TEST_URI.
+[TestFixture]
+public class ApiV2CompleteTests : ChromaTestsBase
+{
+ // Chroma Cloud rejects a regex pattern beyond 256 bytes in UTF-8, the default quota of a tenant: the client says it with the pattern,
+ // its bytes and the limit.
+ [TestCase("a", 257, 257)]
+ [TestCase("é", 129, 258)]
+ public async Task RegexPatternQuotaOfChromaCloud(string character, int count, int bytes)
+ {
+ Assume.That(ChromaCloud, Is.True, "Only Chroma Cloud has the quota of regex patterns.");
+ var client = new ChromaClient(BaseConfigurationOptions, HttpClient);
+ var collectionClient = client.GetCollectionClient(await client.CreateCollectionAsync($"collection{Random.Shared.Next()}"));
+ var pattern = string.Concat(Enumerable.Repeat(character, count));
+ var ex = Assert.ThrowsAsync(() => collectionClient.GetAsync(whereDocument: ChromaWhereDocumentOperator.Regex(pattern)));
+ Assert.That(ex!.Message, Does.StartWith($"Chroma Cloud rejects the pattern \"{pattern}\" of $regex, of {bytes} bytes in UTF-8: the quota of the tenant for a regex pattern is 256 bytes"));
+ Assert.That(ex.StatusCode, Is.EqualTo(HttpStatusCode.UnprocessableEntity));
+ }
+
+ [Test]
+ public async Task Healthcheck()
+ {
+ var client = new ChromaClient(BaseConfigurationOptions, HttpClient);
+ if (!HealthcheckSupported)
+ {
+ await Assert.ThatAsync(() => client.HealthcheckAsync(), Throws.InstanceOf().With.Property(nameof(ChromaException.StatusCode)).EqualTo(HttpStatusCode.NotFound));
+ return;
+ }
+ var result = await client.HealthcheckAsync();
+ Assert.That(result.IsExecutorReady, Is.True);
+ }
+
+ [Test]
+ public async Task RegexAndNotRegex()
+ {
+ var collection = await Init();
+ if (!RegexSupported)
+ {
+ await Assert.ThatAsync(() => collection.GetAsync(whereDocument: ChromaWhereDocumentOperator.Regex("^apple")), Throws.InstanceOf());
+ return;
+ }
+ Assert.That((await collection.GetAsync(whereDocument: ChromaWhereDocumentOperator.Regex("^apple"))).Select(x => x.Id), Is.EquivalentTo(new[] { "a", "d" }));
+ Assert.That((await collection.GetAsync(whereDocument: ChromaWhereDocumentOperator.NotRegex("^apple"))).Select(x => x.Id), Is.EquivalentTo(new[] { "b", "c" }));
+ }
+
+ // KD-56: on Chroma Cloud a pattern that takes an empty text, and a $contains of fewer than 3 characters, throw before the request, also
+ // in a delete, which would delete every record. On every server Regex("(?s).") finds the records with a document that is not empty,
+ // also of only a new line, NotRegex("(?s).") the others, and a $regex with a short text the documents that contain it.
+ [Test]
+ public async Task DocumentFiltersThatChromaCloudGetsWrong()
+ {
+ Assume.That(RegexSupported, Is.True, "Chroma 1.0.11 and earlier have no $regex.");
+ var client = new ChromaClient(BaseConfigurationOptions, HttpClient);
+ var collection = client.GetCollectionClient(await client.CreateCollectionAsync($"collection{Random.Shared.Next()}"));
+ await collection.AddAsync(new ChromaRecords(["none", "empty", "text", "line", "abc"])
+ {
+ Embeddings = [new([1f, 0f]), new([0f, 1f]), new([1f, 1f]), new([1f, 2f]), new([2f, 1f])],
+ Documents = [null, "", "x", "\n", "abc"],
+ });
+ var empty = ChromaWhereDocumentOperator.Regex("^$");
+ var shortText = ChromaWhereDocumentOperator.Contains("ab");
+ if (ChromaCloud)
+ {
+ Assert.That(() => collection.GetAsync(whereDocument: empty), Throws.ArgumentException.With.Message.Contains("takes an empty text"));
+ Assert.That(() => collection.GetAsync(whereDocument: ChromaWhereDocumentOperator.Regex("^[[:alpha:]]*$")), Throws.ArgumentException);
+ Assert.That(() => collection.GetAsync(whereDocument: shortText), Throws.ArgumentException.With.Message.Contains("fewer than 3 characters"));
+ Assert.That(() => collection.DeleteAsync(new ChromaDelete { WhereDocument = empty }), Throws.ArgumentException);
+ Assert.That(() => collection.DeleteAsync(new ChromaDelete { WhereDocument = ChromaWhereDocumentOperator.NotContains("") }), Throws.ArgumentException);
+ Assert.That(await collection.CountAsync(), Is.EqualTo(5));
+ }
+ else
+ {
+ Assert.That((await collection.GetAsync(whereDocument: empty, include: ChromaGetInclude.None)).Select(x => x.Id), Is.EqualTo(new[] { "empty" }));
+ Assert.That((await collection.GetAsync(whereDocument: shortText, include: ChromaGetInclude.None)).Select(x => x.Id), Is.EqualTo(new[] { "abc" }));
+ }
+ Assert.That((await collection.GetAsync(whereDocument: ChromaWhereDocumentOperator.Regex("(?s)."), include: ChromaGetInclude.None)).Select(x => x.Id), Is.EquivalentTo(new[] { "text", "line", "abc" }));
+ Assert.That((await collection.GetAsync(whereDocument: ChromaWhereDocumentOperator.NotRegex("(?s)."), include: ChromaGetInclude.None)).Select(x => x.Id), Is.EquivalentTo(new[] { "none", "empty" }));
+ Assert.That((await collection.GetAsync(whereDocument: ChromaWhereDocumentOperator.Regex("ab"), include: ChromaGetInclude.None)).Select(x => x.Id), Is.EqualTo(new[] { "abc" }));
+ Assert.That((await collection.GetAsync(whereDocument: ChromaWhereDocumentOperator.Contains("abc"), include: ChromaGetInclude.None)).Select(x => x.Id), Is.EqualTo(new[] { "abc" }));
+ }
+
+ // On the servers that ignore the limit, nothing is deleted: without the check they would delete both apples.
+ [Test]
+ public async Task DeleteWithLimit()
+ {
+ var collection = await Init();
+ var delete = new ChromaDelete { WhereDocument = ChromaWhereDocumentOperator.Contains("apple"), Limit = 1 };
+ if (!DeleteLimitSupported)
+ {
+ await Assert.ThatAsync(() => collection.DeleteAsync(delete), Throws.InstanceOf().With.Message.Contains("Chroma 1.5.3"));
+ Assert.That(await collection.CountAsync(), Is.EqualTo(4));
+ return;
+ }
+ Assert.That(await collection.DeleteAsync(delete), Is.EqualTo(1));
+ Assert.That(await collection.CountAsync(), Is.EqualTo(3));
+ }
+
+ [Test]
+ public async Task DeleteByFilterOnly()
+ {
+ var collection = await Init();
+ var deleted = await collection.DeleteAsync(new ChromaDelete { WhereDocument = ChromaWhereDocumentOperator.Contains("apple") });
+ Assert.That(deleted, DeleteLimitSupported ? Is.EqualTo(2) : Is.Null);
+ Assert.That((await collection.GetAsync()).Select(x => x.Id), Is.EquivalentTo(new[] { "b", "c" }));
+ }
+
+ // A single server indexes at once; Chroma Cloud may leave out of IndexOnly the records not indexed yet.
+ [Test]
+ public async Task CountAtAReadLevel()
+ {
+ var collection = await Init();
+ Assert.That(await collection.CountAsync(ChromaReadLevel.IndexAndWal), Is.EqualTo(4));
+ Assert.That(await collection.CountAsync(ChromaReadLevel.IndexOnly), ChromaCloud ? Is.InRange(0, 4) : Is.EqualTo(4));
+ }
+
+ // A single server has an HNSW index, Chroma Cloud a SPANN one.
+ [Test]
+ public async Task ModifyConfiguration()
+ {
+ var collection = await Init();
+ var update = ChromaCloud
+ ? new ChromaCollectionConfigurationUpdate { Spann = new() { SearchNprobe = 32 } }
+ : new ChromaCollectionConfigurationUpdate { Hnsw = new() { EfSearch = 200 } };
+ if (!NewConfigurationApplied)
+ {
+ await Assert.ThatAsync(() => collection.ModifyConfigurationAsync(update), Throws.InstanceOf().With.Message.Contains("Chroma 1.0.6"));
+ return;
+ }
+ await collection.ModifyConfigurationAsync(update);
+ var configuration = (await new ChromaClient(BaseConfigurationOptions, HttpClient).GetCollectionAsync(collection.Collection.Name)).ConfigurationJson!.Value;
+ Assert.That(ChromaCloud
+ ? configuration.GetProperty("spann").GetProperty("search_nprobe").GetInt32()
+ : configuration.GetProperty("hnsw").GetProperty("ef_search").GetInt32(), Is.EqualTo(ChromaCloud ? 32 : 200));
+ }
+
+ // Fork, its count and the indexing status exist only on Chroma Cloud: a single server answers with an error.
+ [Test]
+ public async Task ForkAndIndexingStatus()
+ {
+ var collection = await Init();
+ if (!ChromaCloud)
+ {
+ await Assert.ThatAsync(() => collection.ForkAsync($"collection{Random.Shared.Next()}"), Throws.InstanceOf());
+ await Assert.ThatAsync(() => collection.ForkCountAsync(), Throws.InstanceOf());
+ await Assert.ThatAsync(() => collection.GetIndexingStatusAsync(), Throws.InstanceOf());
+ return;
+ }
+ var fork = await collection.ForkAsync($"collection{Random.Shared.Next()}");
+ Assert.That(await new ChromaClient(BaseConfigurationOptions, HttpClient).GetCollectionClient(fork).CountAsync(), Is.EqualTo(4));
+ Assert.That(await collection.ForkCountAsync(), Is.EqualTo(1));
+ Assert.That((await collection.GetIndexingStatusAsync()).TotalOps, Is.GreaterThanOrEqualTo(4));
+ }
+
+ // On Chroma Cloud the tenant and the database of the API key; a single server, also one already running, always answers
+ // with the default ones.
+ [Test]
+ public async Task TenantAndDatabaseFromIdentity()
+ {
+ Assume.That(ApiVersion, Is.EqualTo(ChromaApiVersion.V2), "The v1 API has no auth/identity.");
+ var options = new ChromaConfigurationOptions(uri: BaseConfigurationOptions.Uri.ToString()).WithApiVersion(ApiVersion);
+ options = BaseConfigurationOptions.ChromaToken is { } token ? options.WithChromaToken(token) : options;
+ var client = await new ChromaClient(options, HttpClient).WithTenantAndDatabaseFromIdentityAsync();
+ Assert.That(client.Options.Tenant, Is.EqualTo(ChromaCloud ? BaseConfigurationOptions.Tenant ?? "default_tenant" : "default_tenant"));
+ Assert.That(client.Options.Database, Is.EqualTo(ChromaCloud ? BaseConfigurationOptions.Database ?? "default_database" : "default_database"));
+ Assert.That(await client.ListCollectionsAsync(), Is.Not.Null);
+ }
+
+ // The Search API exists only on Chroma Cloud: a single server answers with an error (501 from Chroma 1.x).
+ [Test]
+ public async Task Search()
+ {
+ var collection = await Init();
+ var search = new ChromaSearch
+ {
+ WhereDocument = ChromaWhereDocumentOperator.Contains("apple"),
+ Rank = ChromaRank.Knn(new([1f, 0f])),
+ Limit = 3,
+ Select = [ChromaSearchKeys.Document, ChromaSearchKeys.Score],
+ };
+ if (!ChromaCloud)
+ {
+ await Assert.ThatAsync(() => collection.SearchAsync(search), Throws.InstanceOf());
+ return;
+ }
+ var apples = await collection.SearchAsync(search);
+ Assert.That(apples.Select(x => (x.Id, x.Document, x.Score)), Is.EqualTo(new[] { ("a", "apple pie", (float?)0f), ("d", "apple juice", (float?)0.5f) }));
+ var rrf = await collection.SearchAsync(new ChromaSearch
+ {
+ Rank = ChromaRank.Rrf([ChromaRank.Knn(new([1f, 0f]), returnRank: true), ChromaRank.Knn(new([0f, 1f]), returnRank: true)]),
+ Limit = 4,
+ Select = [ChromaSearchKeys.Score],
+ });
+ Assert.That(rrf.Select(x => x.Score), Is.Ordered.Ascending);
+ Assert.That(rrf, Has.Count.EqualTo(4));
+ var two = await collection.SearchAsync([new ChromaSearch { Ids = ["b", "c"], Select = [ChromaSearchKeys.Document] }, new ChromaSearch { Rank = ChromaRank.Knn(new([0f, 1f])), Limit = 1 }]);
+ Assert.That(two[0].Select(x => x.Document), Is.EquivalentTo(new[] { "banana split", "cherry tart" }));
+ Assert.That(two[1].Single().Id, Is.EqualTo("b"));
+ }
+
+ // Sparse vector indexes and sparse vectors in metadata exist only on Chroma Cloud. A single server rejects the schema from Chroma 1.3.0,
+ // and the earlier ones create the collection without it: then the client deletes it. Sparse vectors in metadata fail on a single
+ // server; Chroma 0.x would drop them, so the client rejects them first.
+ [Test]
+ public async Task SchemaAndSparseVectors()
+ {
+ var client = new ChromaClient(BaseConfigurationOptions.WithMetadataValues(ChromaMetadataValues.Exact), HttpClient);
+ var name = $"collection{Random.Shared.Next()}";
+ var definition = new ChromaCollectionDefinition(name)
+ {
+ Schema = new ChromaCollectionSchema().WithSparseVectorIndex("doc_bm25", ChromaSearchKeys.Document, bm25: true, ChromaEmbeddingFunctionReference.ChromaBm25()),
+ };
+ var records = new ChromaRecords(["a", "b"])
+ {
+ Embeddings = [new([1f, 0f]), new([0f, 1f])],
+ Documents = ["apple pie", "banana split"],
+ Metadatas = [new Dictionary { ["doc_bm25"] = new ChromaSparseVector([1, 5], [0.5f, 0.7f]) }, new Dictionary { ["doc_bm25"] = new ChromaSparseVector([2], [0.9f]) }],
+ };
+ if (!ChromaCloud)
+ {
+ await Assert.ThatAsync(() => client.CreateCollectionAsync(definition), Throws.InstanceOf());
+ Assert.That(await client.CollectionExistsAsync(name), Is.False);
+ if (!RejectedSparseVectorsBreakTheServer)
+ {
+ var plain = client.GetCollectionClient(await client.CreateCollectionAsync($"collection{Random.Shared.Next()}"));
+ await Assert.ThatAsync(() => plain.AddAsync(records), Throws.InstanceOf());
+ }
+ return;
+ }
+ var collection = await client.CreateCollectionAsync(definition);
+ var index = collection.SparseVectorIndexes.Single();
+ Assert.That((index.Key, index.SourceKey, index.Bm25, index.EmbeddingFunction), Is.EqualTo(("doc_bm25", "#document", true, "chroma_bm25")));
+ var collectionClient = client.GetCollectionClient(collection);
+ await collectionClient.AddAsync(records);
+ var stored = (ChromaSparseVector)(await collectionClient.GetAsync("a", include: ChromaGetInclude.Metadatas))!.Metadata!["doc_bm25"];
+ Assert.That((stored.Indices, stored.Values), Is.EqualTo(((IReadOnlyList)[1, 5], (IReadOnlyList)[0.5f, 0.7f])));
+ var found = await collectionClient.SearchAsync(new ChromaSearch { Rank = ChromaRank.SparseKnn(new ChromaSparseVector([1, 5], [1f, 1f]), "doc_bm25"), Limit = 1 });
+ Assert.That(found.Single().Id, Is.EqualTo("a"));
+ }
+
+ // The options of Chroma Cloud from a connection string, as the settings of an application keep them.
+ [Test]
+ public async Task ConnectionStringOfChromaCloud()
+ {
+ Assume.That(ChromaCloud, Is.True, "A connection string with a token, a tenant and a database is for Chroma Cloud.");
+ var options = ChromaConfigurationOptions.FromConnectionString(
+ $"Endpoint={BaseConfigurationOptions.Uri};Token={BaseConfigurationOptions.ChromaToken};Tenant={BaseConfigurationOptions.Tenant};Database={BaseConfigurationOptions.Database}");
+ using var client = new ChromaClient(options);
+ var name = $"collection{Random.Shared.Next()}";
+ // Its own HttpClient, which the fixture does not see: the collection is deleted here.
+ var collection = await client.CreateCollectionAsync(name);
+ try
+ {
+ Assert.That((collection.Tenant, collection.Database), Is.EqualTo((BaseConfigurationOptions.Tenant, BaseConfigurationOptions.Database)));
+ Assert.That(await client.CollectionExistsAsync(name), Is.True);
+ }
+ finally
+ {
+ await client.DeleteCollectionAsync(name);
+ }
+ }
+
+ // Hybrid search on Chroma Cloud: the client computes the BM25 vectors of the documents and of the text queries from the schema,
+ // searched alone and fused with the dense vectors.
+ [Test]
+ public async Task HybridSearchWithBm25()
+ {
+ Assume.That(ChromaCloud, Is.True, "Only Chroma Cloud has sparse vector indexes.");
+ var bm25 = new ChromaBm25(k: 1.5);
+ var client = new ChromaClient(BaseConfigurationOptions, HttpClient);
+ // The space goes in the schema: Chroma Cloud rejects a configuration together with a schema.
+ var collection = client.GetCollectionClient(await client.CreateCollectionAsync(new ChromaCollectionDefinition($"collection{Random.Shared.Next()}")
+ {
+ Configuration = new() { Space = ChromaSpace.Cosine },
+ Schema = new ChromaCollectionSchema().WithSparseVectorIndex("doc_bm25", ChromaSearchKeys.Document, bm25: true, bm25.Reference),
+ }));
+ Assert.That(collection.Collection.Space, Is.EqualTo(ChromaSpace.Cosine));
+ string[] documents = ["apple pie with cinnamon", "banana split with chocolate", "cherry tart", "apple juice and apple cider"];
+ await collection.AddAsync(new ChromaRecords(["a", "b", "c", "d"])
+ {
+ Embeddings = [new([1f, 0f]), new([0f, 1f]), new([1f, 1f]), new([0.5f, 0.5f])],
+ Documents = [.. documents],
+ });
+ var stored = (ChromaSparseVector)(await client.WithMetadataValues(ChromaMetadataValues.Exact).GetCollectionClient(collection.Collection).GetAsync("b"))!.Metadata!["doc_bm25"];
+ Assert.That(stored.ToString(), Is.EqualTo(bm25.Embed(documents[1]).ToString()));
+ var keyword = await collection.SearchAsync(new ChromaSearch { Rank = ChromaRank.SparseKnn("apples", "doc_bm25"), Limit = 2 });
+ Assert.That(keyword.Select(x => x.Id), Is.EquivalentTo(new[] { "a", "d" }));
+ var hybrid = await collection.SearchAsync(new ChromaSearch
+ {
+ Rank = ChromaRank.Rrf([ChromaRank.Knn(new([0f, 1f]), returnRank: true), ChromaRank.SparseKnn("banana", "doc_bm25", returnRank: true)]),
+ Limit = 1,
+ });
+ Assert.That(hybrid.Single().Id, Is.EqualTo("b"));
+ }
+
+ // Chroma Cloud takes a metadata value of at most 8,182 bytes, and a document of 16,384: a longer document goes without its copy, and
+ // its record, without metadata keys, comes back with an empty Metadata. A key of the schema beyond 36 bytes, which Chroma Cloud takes
+ // and then rejects on every write, stops before the request.
+ [Test]
+ public async Task QuotasOfChromaCloud()
+ {
+ Assume.That(ChromaCloud, Is.True, "Only Chroma Cloud has these quotas.");
+ var client = new ChromaClient(BaseConfigurationOptions.WithMetadataValues(ChromaMetadataValues.Exact), HttpClient);
+ var name = $"collection{Random.Shared.Next()}";
+ var tooLong = new ChromaCollectionDefinition(name) { Schema = new ChromaCollectionSchema().WithIndex(ChromaSchemaIndex.StringInverted, new string('k', 37)) };
+ Assert.That(() => client.CreateCollectionAsync(tooLong), Throws.ArgumentException);
+ Assert.That(await client.CollectionExistsAsync(name), Is.False);
+ var collection = client.GetCollectionClient(await client.CreateCollectionAsync(name)).WithDocumentCopyKey("text");
+ var longText = new string('a', ChromaCloudQuotas.MaxMetadataValueBytes + 1);
+ await collection.AddAsync(new ChromaRecords(["short", "long"]) { Embeddings = [new([1f, 0f]), new([0f, 1f])], Documents = ["apple pie", longText] });
+ var records = (await collection.GetAsync(include: ChromaGetInclude.Metadatas | ChromaGetInclude.Documents)).ToDictionary(x => x.Id);
+ Assert.That(records["short"].Metadata!["text"], Is.EqualTo("apple pie"));
+ Assert.That(records["long"].Metadata, Is.Not.Null.And.Empty);
+ Assert.That(records["long"].Document, Is.EqualTo(longText));
+ }
+
+ // With HybridRrf the records without the terms of the text get no points from it: they keep the order of the dense search.
+ [Test]
+ public async Task HybridRrfOnChromaCloud()
+ {
+ Assume.That(ChromaCloud, Is.True, "Only Chroma Cloud has sparse vector indexes.");
+ var client = new ChromaClient(BaseConfigurationOptions, HttpClient);
+ var collection = client.GetCollectionClient(await client.CreateCollectionAsync(new ChromaCollectionDefinition($"collection{Random.Shared.Next()}")
+ {
+ Configuration = new() { Space = ChromaSpace.Cosine },
+ Schema = new ChromaCollectionSchema().WithSparseVectorIndex("doc_bm25", ChromaSearchKeys.Document, bm25: true, new ChromaBm25().Reference),
+ }));
+ await collection.AddAsync(new ChromaRecords(["a", "b", "c", "d"])
+ {
+ Embeddings = [new([1f, 0f]), new([0f, 1f]), new([0.6f, 0.8f]), new([0.8f, 0.6f])],
+ Documents = ["apple pie", "banana split", "cherry tart", "date cake"],
+ });
+ var bm25Key = collection.Collection.FindBm25Index(ChromaSearchKeys.Document)!.Key;
+ var results = await collection.SearchAsync(new ChromaSearch { Rank = ChromaRank.HybridRrf(new([0f, 1f]), "banana", bm25Key, limit: 4), Limit = 4, Select = [ChromaSearchKeys.Score] });
+ Assert.That(results.Select(x => x.Id), Is.EqualTo(new[] { "b", "c", "d", "a" }));
+ Assert.That(results[1].Score!.Value, Is.EqualTo(1 / 61.0).Within(1e-6));
+ }
+
+ async Task Init()
+ {
+ var client = new ChromaClient(BaseConfigurationOptions, HttpClient);
+ var collection = client.GetCollectionClient(await client.CreateCollectionAsync($"collection{Random.Shared.Next()}"));
+ await collection.AddAsync(["a", "b", "c", "d"], embeddings: [new([1f, 0f]), new([0f, 1f]), new([1f, 1f]), new([0.5f, 0.5f])],
+ documents: ["apple pie", "banana split", "cherry tart", "apple juice"]);
+ return collection;
+ }
+}
diff --git a/ChromaDB.Client.Tests/ApiV2RequestsTests.cs b/ChromaDB.Client.Tests/ApiV2RequestsTests.cs
new file mode 100644
index 0000000..a7df35f
--- /dev/null
+++ b/ChromaDB.Client.Tests/ApiV2RequestsTests.cs
@@ -0,0 +1,347 @@
+using System.Net;
+using System.Text.Json;
+using ChromaDB.Client.Models;
+using NUnit.Framework;
+
+namespace ChromaDB.Client.Tests;
+
+// The requests of the operations of the v2 API as the OpenAPI description of Chroma 1.5.9 defines them, against a fake server:
+// some of them exist only on Chroma Cloud.
+[TestFixture]
+public class ApiV2RequestsTests
+{
+ static readonly Guid Id = Guid.Parse("11111111-2222-3333-4444-555555555555");
+ const string CollectionPath = "/api/v2/tenants/default_tenant/databases/default_database/collections/11111111-2222-3333-4444-555555555555";
+ const string OpenApiWithDeleteLimit = """{"components":{"schemas":{"DeleteCollectionRecordsPayload":{"allOf":[{"properties":{"ids":{},"limit":{}}}]}}}}""";
+ const string OpenApiWithoutDeleteLimit = """{"components":{"schemas":{"DeleteCollectionRecordsPayload":{"allOf":[{"properties":{"ids":{}}}]}}}}""";
+
+ [Test]
+ public async Task Healthcheck()
+ {
+ var server = new FakeServer(_ => (HttpStatusCode.OK, """{"is_executor_ready":true,"is_log_client_ready":false}"""));
+ var result = await Client(server).HealthcheckAsync();
+ Assert.That(server.Requests.Single().Line, Is.EqualTo("GET /api/v2/healthcheck"));
+ Assert.That(result.IsExecutorReady, Is.True);
+ Assert.That(result.IsLogClientReady, Is.False);
+ }
+
+ [Test]
+ public async Task GetCollectionByCrn()
+ {
+ var server = new FakeServer(_ => (HttpStatusCode.OK, $$"""{"id":"{{Id}}","name":"c"}"""));
+ var result = await Client(server).GetCollectionByCrnAsync("tenant:database:c");
+ Assert.That(Uri.UnescapeDataString(server.Requests.Single().Line), Is.EqualTo("GET /api/v2/collections/tenant:database:c"));
+ Assert.That(result.Id, Is.EqualTo(Id));
+ }
+
+ [Test]
+ public async Task UpdateTenant()
+ {
+ var server = new FakeServer(_ => (HttpStatusCode.OK, "{}"));
+ await Client(server).UpdateTenantAsync("t", "resource");
+ Assert.That(server.Requests.Single().Line, Is.EqualTo("PATCH /api/v2/tenants/t"));
+ Assert.That(server.Requests.Single().Body.GetProperty("resource_name").GetString(), Is.EqualTo("resource"));
+ }
+
+ [Test]
+ public async Task ResourceNameOfTheTenant()
+ {
+ var server = new FakeServer(_ => (HttpStatusCode.OK, """{"name":"t","resource_name":"resource"}"""));
+ Assert.That((await Client(server).GetTenantAsync("t")).ResourceName, Is.EqualTo("resource"));
+ }
+
+ [Test]
+ public void RegexFilters()
+ {
+ Assert.That(ChromaWhereDocumentOperator.Regex("^a").ToString(), Is.EqualTo("""{"$regex":"^a"}"""));
+ Assert.That(ChromaWhereDocumentOperator.NotRegex("^a").ToString(), Is.EqualTo("""{"$not_regex":"^a"}"""));
+ Assert.That((ChromaWhereDocumentOperator.Regex("^a") | ChromaWhereDocumentOperator.Contains("b")).ToString(), Is.EqualTo("""{"$or":[{"$regex":"^a"},{"$contains":"b"}]}"""));
+ }
+
+ [Test]
+ public async Task DeleteWithLimitAndFilters()
+ {
+ var server = new FakeServer(r => r.Path == "/openapi.json" ? (HttpStatusCode.OK, OpenApiWithDeleteLimit) : (HttpStatusCode.OK, """{"deleted":2}"""));
+ var deleted = await CollectionClient(server).DeleteAsync(new ChromaDelete { WhereDocument = ChromaWhereDocumentOperator.Contains("x"), Limit = 2 });
+ Assert.That(deleted, Is.EqualTo(2));
+ var delete = server.Requests.Single(x => x.Path.EndsWith("/delete"));
+ Assert.That(delete.Line, Is.EqualTo($"POST {CollectionPath}/delete"));
+ Assert.That(delete.Body.GetProperty("limit").GetInt32(), Is.EqualTo(2));
+ Assert.That(delete.Body.GetProperty("where_document").GetProperty("$contains").GetString(), Is.EqualTo("x"));
+ Assert.That(delete.Body.TryGetProperty("ids", out _), Is.False);
+ }
+
+ // Behind a proxy with a path, the base URI names the API, as https://proxy/chroma/api/v2/: the description is at the root of the
+ // server, next to it.
+ [Test]
+ public async Task DeleteWithLimitBehindAProxy()
+ {
+ var server = new FakeServer(r => r.Path == "/chroma/openapi.json" ? (HttpStatusCode.OK, OpenApiWithDeleteLimit) : r.Path.EndsWith("/delete") ? (HttpStatusCode.OK, """{"deleted":2}""") : (HttpStatusCode.NotFound, "{}"));
+ var deleted = await CollectionClient(server, new ChromaConfigurationOptions("http://localhost:8000/chroma/api/v2/")).DeleteAsync(new ChromaDelete { WhereDocument = ChromaWhereDocumentOperator.Contains("x"), Limit = 2 });
+ Assert.That(deleted, Is.EqualTo(2));
+ }
+
+ // The servers that do not declare the limit ignore it and would delete every matching record: nothing is sent.
+ [TestCase(HttpStatusCode.OK, OpenApiWithoutDeleteLimit)]
+ [TestCase(HttpStatusCode.NotFound, "")]
+ public async Task DeleteWithLimitOnAServerThatIgnoresIt(HttpStatusCode status, string description)
+ {
+ var server = new FakeServer(r => r.Path == "/openapi.json" ? (status, description) : (HttpStatusCode.OK, "{}"));
+ await Assert.ThatAsync(() => CollectionClient(server).DeleteAsync(new ChromaDelete { Ids = ["a"], WhereDocument = ChromaWhereDocumentOperator.Contains("x"), Limit = 1 }),
+ Throws.InstanceOf().With.Message.Contains("Chroma 1.5.3"));
+ Assert.That(server.Requests.Any(x => x.Path.EndsWith("/delete")), Is.False);
+ }
+
+ // The limit left after each batch goes in the next one, and the batches stop when it is used up.
+ [Test]
+ public async Task DeleteWithLimitAcrossBatches()
+ {
+ var server = new FakeServer(r => r.Path switch
+ {
+ "/openapi.json" => (HttpStatusCode.OK, OpenApiWithDeleteLimit),
+ "/api/v2/pre-flight-checks" => (HttpStatusCode.OK, """{"max_batch_size":2}"""),
+ _ => (HttpStatusCode.OK, $$"""{"deleted":{{Math.Min(r.Body.GetProperty("ids").GetArrayLength(), r.Body.GetProperty("limit").GetInt32())}}}"""),
+ });
+ var client = CollectionClient(server, new ChromaConfigurationOptions("http://localhost:8000").WithBatchSplitting());
+ var deleted = await client.DeleteAsync(new ChromaDelete { Ids = ["a", "b", "c", "d", "e"], WhereDocument = ChromaWhereDocumentOperator.Contains("x"), Limit = 3 });
+ Assert.That(deleted, Is.EqualTo(3));
+ var limits = server.Requests.Where(x => x.Path.EndsWith("/delete")).Select(x => x.Body.GetProperty("limit").GetInt32());
+ Assert.That(limits, Is.EqualTo(new[] { 3, 1 }));
+ }
+
+ // {} from Chroma 1.0.0 to 1.5.2, null from 0.x, and a list of ids from some older 0.x servers: the count is unknown.
+ [TestCase("{}")]
+ [TestCase("null")]
+ [TestCase("""["a"]""")]
+ public async Task DeleteWithoutTheCount(string response)
+ {
+ var server = new FakeServer(_ => (HttpStatusCode.OK, response));
+ Assert.That(await CollectionClient(server).DeleteAsync(new ChromaDelete { Ids = ["a"] }), Is.Null);
+ }
+
+ // What Chroma or its Python client reject: nothing is sent, not even the request for the OpenAPI description.
+ [Test]
+ public async Task DeleteThatChromaRejects()
+ {
+ var server = new FakeServer(_ => (HttpStatusCode.OK, OpenApiWithDeleteLimit));
+ var client = CollectionClient(server);
+ var filter = ChromaWhereDocumentOperator.Contains("x");
+ await Assert.ThatAsync(() => client.DeleteAsync(new ChromaDelete()), Throws.ArgumentException);
+ await Assert.ThatAsync(() => client.DeleteAsync(new ChromaDelete { Ids = [] }), Throws.ArgumentException);
+ await Assert.ThatAsync(() => client.DeleteAsync(new ChromaDelete { Ids = [], WhereDocument = filter }), Throws.ArgumentException);
+ await Assert.ThatAsync(() => client.DeleteAsync(new ChromaDelete { Ids = ["a"], Limit = 1 }), Throws.ArgumentException);
+ await Assert.ThatAsync(() => client.DeleteAsync(new ChromaDelete { WhereDocument = filter, Limit = -1 }), Throws.InstanceOf());
+ Assert.That(server.Requests, Is.Empty);
+ }
+
+ // Chroma answers {"deleted":0} to a limit of 0.
+ [Test]
+ public async Task DeleteWithLimitZero()
+ {
+ var server = new FakeServer(r => r.Path == "/openapi.json" ? (HttpStatusCode.OK, OpenApiWithDeleteLimit) : (HttpStatusCode.OK, """{"deleted":0}"""));
+ Assert.That(await CollectionClient(server).DeleteAsync(new ChromaDelete { WhereDocument = ChromaWhereDocumentOperator.Contains("x"), Limit = 0 }), Is.EqualTo(0));
+ Assert.That(server.Requests.Single(x => x.Path.EndsWith("/delete")).Body.GetProperty("limit").GetInt32(), Is.EqualTo(0));
+ }
+
+ [TestCase(ChromaReadLevel.IndexAndWal, "index_and_wal")]
+ [TestCase(ChromaReadLevel.IndexOnly, "index_only")]
+ [TestCase(ChromaReadLevel.IndexAndBoundedWal, "index_and_bounded_wal")]
+ public async Task CountAtAReadLevel(ChromaReadLevel readLevel, string name)
+ {
+ var server = new FakeServer(_ => (HttpStatusCode.OK, "4"));
+ Assert.That(await CollectionClient(server).CountAsync(readLevel), Is.EqualTo(4));
+ Assert.That(server.Requests.Single().Line, Is.EqualTo($"GET {CollectionPath}/count?read_level={name}"));
+ }
+
+ // A single server sends "hnsw" with "spann" null, Chroma Cloud the other way around.
+ [TestCase("""{"hnsw":{"ef_search":100},"spann":null}""", """{"hnsw":{"ef_search":200}}""")]
+ [TestCase("""{"hnsw":null,"spann":{"search_nprobe":64}}""", """{"spann":{"search_nprobe":32}}""")]
+ public async Task ModifyConfiguration(string configuration, string expected)
+ {
+ var server = new FakeServer(_ => (HttpStatusCode.OK, "{}"));
+ var update = configuration.StartsWith("""{"hnsw":{""")
+ ? new ChromaCollectionConfigurationUpdate { Hnsw = new() { EfSearch = 200 } }
+ : new ChromaCollectionConfigurationUpdate { Spann = new() { SearchNprobe = 32 } };
+ await CollectionClient(server, collection: Collection(configuration)).ModifyConfigurationAsync(update);
+ var request = server.Requests.Single();
+ Assert.That(request.Line, Is.EqualTo($"PUT {CollectionPath}"));
+ Assert.That(request.Body.GetProperty("new_configuration").GetRawText(), Is.EqualTo(expected));
+ }
+
+ // Chroma Cloud answers 500 to HNSW settings on its SPANN index; a single server answers 200 to SPANN settings and keeps "spann" null.
+ [TestCase("""{"hnsw":null,"spann":{"search_nprobe":64}}""", true, "set Spann")]
+ [TestCase("""{"hnsw":{"ef_search":100},"spann":null}""", false, "set Hnsw")]
+ public async Task ModifyConfigurationOfAnotherIndex(string configuration, bool hnsw, string message)
+ {
+ var server = new FakeServer(_ => (HttpStatusCode.OK, "{}"));
+ var update = hnsw
+ ? new ChromaCollectionConfigurationUpdate { Hnsw = new() { EfSearch = 200 } }
+ : new ChromaCollectionConfigurationUpdate { Spann = new() { SearchNprobe = 32 } };
+ await Assert.ThatAsync(() => CollectionClient(server, collection: Collection(configuration)).ModifyConfigurationAsync(update),
+ Throws.InstanceOf().With.Message.Contains(message));
+ Assert.That(server.Requests, Is.Empty);
+ }
+
+ // Chroma 0.5.4 to 1.0.5 send "hnsw_configuration", 0.4.10 to 0.5.3 no configuration: they answer without applying it.
+ [TestCase("""{"_type":"CollectionConfigurationInternal","hnsw_configuration":{"ef_search":10}}""")]
+ [TestCase(null)]
+ public async Task ModifyConfigurationOnAServerThatIgnoresIt(string? configuration)
+ {
+ var server = new FakeServer(_ => (HttpStatusCode.OK, "{}"));
+ var collection = new ChromaCollection("c") { Id = Id, ConfigurationJson = configuration is null ? JsonDocument.Parse("null").RootElement : JsonDocument.Parse(configuration).RootElement };
+ await Assert.ThatAsync(() => CollectionClient(server, collection: collection).ModifyConfigurationAsync(new() { Hnsw = new() { EfSearch = 200 } }),
+ Throws.InstanceOf().With.Message.Contains("Chroma 1.0.6"));
+ Assert.That(server.Requests.Any(x => x.Method == "PUT"), Is.False);
+ }
+
+ // A collection client made from the id and the name has no configuration at hand: it reads the collection first.
+ [Test]
+ public async Task ModifyConfigurationReadsTheCollection()
+ {
+ var server = new FakeServer(r => r.Method == "GET" ? (HttpStatusCode.OK, $$$$"""{"id":"{{{{Id}}}}","name":"c","configuration_json":{"hnsw":{}}}""") : (HttpStatusCode.OK, "{}"));
+ await CollectionClient(server).ModifyConfigurationAsync(new() { Hnsw = new() { EfSearch = 200 } });
+ Assert.That(server.Requests.Select(x => x.Line), Is.EqualTo(new[] { "GET /api/v2/tenants/default_tenant/databases/default_database/collections/c", $"PUT {CollectionPath}" }));
+ }
+
+ [Test]
+ public async Task Fork()
+ {
+ var server = new FakeServer(_ => (HttpStatusCode.OK, """{"id":"22222222-2222-3333-4444-555555555555","name":"copy"}"""));
+ var result = await CollectionClient(server).ForkAsync("copy");
+ Assert.That(server.Requests.Single().Line, Is.EqualTo($"POST {CollectionPath}/fork"));
+ Assert.That(server.Requests.Single().Body.GetProperty("new_name").GetString(), Is.EqualTo("copy"));
+ Assert.That(result.Name, Is.EqualTo("copy"));
+ }
+
+ [Test]
+ public async Task ForkCount()
+ {
+ var server = new FakeServer(_ => (HttpStatusCode.OK, """{"count":3}"""));
+ Assert.That(await CollectionClient(server).ForkCountAsync(), Is.EqualTo(3));
+ Assert.That(server.Requests.Single().Line, Is.EqualTo($"GET {CollectionPath}/fork_count"));
+ }
+
+ [Test]
+ public async Task IndexingStatus()
+ {
+ var server = new FakeServer(_ => (HttpStatusCode.OK, """{"op_indexing_progress":0.25,"num_unindexed_ops":3,"num_indexed_ops":1,"total_ops":4}"""));
+ var result = await CollectionClient(server).GetIndexingStatusAsync();
+ Assert.That(server.Requests.Single().Line, Is.EqualTo($"GET {CollectionPath}/indexing_status"));
+ Assert.That((result.OpIndexingProgress, result.NumUnindexedOps, result.NumIndexedOps, result.TotalOps), Is.EqualTo((0.25f, 3L, 1L, 4L)));
+ }
+
+ [Test]
+ public async Task AttachFunction()
+ {
+ var server = new FakeServer(_ => (HttpStatusCode.OK, """{"attached_function":{"id":"33333333-2222-3333-4444-555555555555","name":"stats","function_name":"statistics"},"created":true}"""));
+ var (attached, created) = await CollectionClient(server).AttachFunctionAsync(ChromaFunctions.Statistics, "stats", "stats_output", new Dictionary { ["k"] = 1 });
+ var request = server.Requests.Single();
+ Assert.That(request.Line, Is.EqualTo($"POST {CollectionPath}/functions/attach"));
+ Assert.That(request.Body.GetRawText(), Is.EqualTo("""{"name":"stats","function_id":"statistics","output_collection":"stats_output","params":{"k":1}}"""));
+ Assert.That((attached.Id, attached.Name, attached.FunctionName, created), Is.EqualTo((Guid.Parse("33333333-2222-3333-4444-555555555555"), "stats", "statistics", true)));
+ }
+
+ [TestCase(""","created":false""", false)]
+ [TestCase("", true)]
+ public async Task AttachFunctionCreated(string created, bool expected)
+ {
+ var server = new FakeServer(_ => (HttpStatusCode.OK, $$"""{"attached_function":{"id":"33333333-2222-3333-4444-555555555555","name":"stats","function_name":"statistics"}{{created}}}"""));
+ Assert.That((await CollectionClient(server).AttachFunctionAsync(ChromaFunctions.Statistics, "stats", "stats_output")).Created, Is.EqualTo(expected));
+ }
+
+ [Test]
+ public async Task GetAttachedFunction()
+ {
+ var server = new FakeServer(_ => (HttpStatusCode.OK, $$$"""
+ {"attached_function":{"id":"33333333-2222-3333-4444-555555555555","name":"stats","function_name":"statistics","input_collection_id":"{{{Id}}}",
+ "output_collection":"stats_output","output_collection_id":null,"tenant_id":"t","database_id":"d","params":"{\"k\":1}","completion_offset":7,"min_records_for_invocation":100}}
+ """));
+ var result = await CollectionClient(server).GetAttachedFunctionAsync("stats");
+ Assert.That(server.Requests.Single().Line, Is.EqualTo($"GET {CollectionPath}/functions/stats"));
+ Assert.That((result.Name, result.FunctionName, result.InputCollectionId, result.OutputCollection, result.OutputCollectionId), Is.EqualTo(("stats", "statistics", (Guid?)Id, "stats_output", (Guid?)null)));
+ Assert.That((result.Tenant, result.Database, result.Params, result.CompletionOffset, result.MinRecordsForInvocation), Is.EqualTo(("t", "d", """{"k":1}""", (long?)7, (long?)100)));
+ }
+
+ [Test]
+ public async Task DetachFunction()
+ {
+ var server = new FakeServer(_ => (HttpStatusCode.OK, """{"success":true}"""));
+ Assert.That(await CollectionClient(server).DetachFunctionAsync("stats", deleteOutputCollection: true), Is.True);
+ Assert.That(server.Requests.Single().Line, Is.EqualTo($"POST {CollectionPath}/attached_functions/stats/detach"));
+ Assert.That(server.Requests.Single().Body.GetProperty("delete_output").GetBoolean(), Is.True);
+ }
+
+ // Like the CloudClient of the Python client of Chroma: the tenant unless it is "*", the database only when there is exactly one.
+ [TestCase("""{"tenant":"t1","databases":["d1"]}""", "t1", "d1")]
+ [TestCase("""{"tenant":"t1","databases":["d1","d1"]}""", "t1", "d1")]
+ [TestCase("""{"tenant":"t1","databases":[]}""", "t1", null)]
+ [TestCase("""{"tenant":"t1","databases":["d1","d2"]}""", "t1", null)]
+ [TestCase("""{"tenant":"*","databases":["*"]}""", null, null)]
+ [TestCase("""{"tenant":null,"databases":null}""", null, null)]
+ public async Task TenantAndDatabaseFromIdentity(string identity, string? tenant, string? database)
+ {
+ var server = new FakeServer(r => r.Path.EndsWith("/auth/identity") ? (HttpStatusCode.OK, identity) : (HttpStatusCode.OK, "[]"));
+ var client = await Client(server).WithTenantAndDatabaseFromIdentityAsync();
+ Assert.That((client.Options.Tenant, client.Options.Database), Is.EqualTo((tenant, database)));
+ await client.ListCollectionsAsync();
+ Assert.That(server.Requests.Last().Line, Is.EqualTo($"GET /api/v2/tenants/{tenant ?? "default_tenant"}/databases/{database ?? "default_database"}/collections"));
+ }
+
+ // A tenant or a database of the options other than the default one must match the credentials.
+ [TestCase("other", null, "tenant other")]
+ [TestCase(null, "other", "database other")]
+ public async Task TenantAndDatabaseFromIdentityThatDoNotMatch(string? tenant, string? database, string message)
+ {
+ var server = new FakeServer(_ => (HttpStatusCode.OK, """{"tenant":"t1","databases":["d1"]}"""));
+ var options = new ChromaConfigurationOptions("http://localhost:8000", tenant: tenant, database: database);
+ await Assert.ThatAsync(() => new ChromaClient(options, new HttpClient(server)).WithTenantAndDatabaseFromIdentityAsync(),
+ Throws.InstanceOf().With.Message.Contains(message));
+ }
+
+ // With several databases in the credentials, the one of the options stays, as in the Python client: there is none to compare.
+ [Test]
+ public async Task TenantAndDatabaseFromIdentityWithSeveralDatabases()
+ {
+ var server = new FakeServer(_ => (HttpStatusCode.OK, """{"tenant":"t1","databases":["d1","d2"]}"""));
+ var options = new ChromaConfigurationOptions("http://localhost:8000", database: "d3");
+ var client = await new ChromaClient(options, new HttpClient(server)).WithTenantAndDatabaseFromIdentityAsync();
+ Assert.That((client.Options.Tenant, client.Options.Database), Is.EqualTo(("t1", "d3")));
+ }
+
+ [Test]
+ public async Task TenantAndDatabaseFromIdentityReplaceTheDefaults()
+ {
+ var server = new FakeServer(_ => (HttpStatusCode.OK, """{"tenant":"t1","databases":["d1"]}"""));
+ var options = new ChromaConfigurationOptions("http://localhost:8000", tenant: "default_tenant", database: "default_database");
+ var client = await new ChromaClient(options, new HttpClient(server)).WithTenantAndDatabaseFromIdentityAsync();
+ Assert.That((client.Options.Tenant, client.Options.Database), Is.EqualTo(("t1", "d1")));
+ }
+
+ static ChromaClient Client(HttpMessageHandler handler)
+ => new(new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(handler));
+
+ static ChromaCollection Collection(string configuration)
+ => new("c") { Id = Id, ConfigurationJson = JsonDocument.Parse(configuration).RootElement };
+
+ static ChromaCollectionClient CollectionClient(HttpMessageHandler handler, ChromaConfigurationOptions? options = null, ChromaCollection? collection = null)
+ => new(collection ?? new ChromaCollection("c") { Id = Id }, options ?? new ChromaConfigurationOptions("http://localhost:8000"), new HttpClient(handler));
+
+ sealed record Request(string Method, string Path, string Line, JsonElement Body);
+
+ // Answers each request with the status and body the function returns for it, and keeps the requests.
+ sealed class FakeServer(Func answer) : HttpMessageHandler
+ {
+ public List Requests { get; } = [];
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var text = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
+ var body = text is { Length: > 0 } ? JsonDocument.Parse(text).RootElement.Clone() : default;
+ var path = request.RequestUri!.AbsolutePath;
+ var recorded = new Request(request.Method.Method, path, $"{request.Method.Method} {request.RequestUri.PathAndQuery}", body);
+ Requests.Add(recorded);
+ var (status, response) = answer(recorded);
+ return new HttpResponseMessage(status) { Content = new StringContent(response) };
+ }
+ }
+}
diff --git a/ChromaDB.Client.Tests/ApiVersionTests.cs b/ChromaDB.Client.Tests/ApiVersionTests.cs
new file mode 100644
index 0000000..0d8c578
--- /dev/null
+++ b/ChromaDB.Client.Tests/ApiVersionTests.cs
@@ -0,0 +1,121 @@
+using System.Net;
+using ChromaDB.Client.Models;
+using NUnit.Framework;
+
+namespace ChromaDB.Client.Tests;
+
+[TestFixture]
+public class ApiVersionTests
+{
+ const string Id = "11111111-2222-3333-4444-555555555555";
+ static readonly ReadOnlyMemory Embedding = new([1f, 0f]);
+
+ static IEnumerable Requests()
+ {
+ yield return Case("ListCollections", c => c.ListCollectionsAsync(), "GET", "tenants/t/databases/d/collections", "collections?tenant=t&database=d");
+ yield return Case("ListCollectionsPage", c => c.ListCollectionsAsync(limit: 2, offset: 1), "GET", "tenants/t/databases/d/collections?limit=2&offset=1", "collections?tenant=t&database=d&limit=2&offset=1");
+ yield return Case("GetCollection", c => c.GetCollectionAsync("c"), "GET", "tenants/t/databases/d/collections/c", "collections/c?tenant=t&database=d");
+ yield return Case("CollectionExists", c => c.CollectionExistsAsync("c"), "GET", "tenants/t/databases/d/collections/c", "collections/c?tenant=t&database=d");
+ yield return Case("GetCollectionById", c => c.GetCollectionByIdAsync(Guid.Parse(Id)), "GET", $"tenants/t/databases/d/collections/by-id/{Id}", $"collections/by-id/{Id}?tenant=t&database=d");
+ yield return Case("Heartbeat", c => c.HeartbeatAsync(), "GET", "heartbeat", "heartbeat");
+ yield return Case("CreateCollection", c => c.CreateCollectionAsync("c"), "POST", "tenants/t/databases/d/collections", "collections?tenant=t&database=d");
+ yield return Case("CreateCollectionDefinition", c => c.CreateCollectionAsync(new ChromaCollectionDefinition("c") { Configuration = new() { Space = ChromaSpace.Cosine } }), "POST", "tenants/t/databases/d/collections", "collections?tenant=t&database=d");
+ yield return Case("GetOrCreateCollectionDefinition", c => c.GetOrCreateCollectionAsync(new ChromaCollectionDefinition("c")), "POST", "tenants/t/databases/d/collections", "collections?tenant=t&database=d");
+ yield return Case("GetOrCreateCollection", c => c.GetOrCreateCollectionAsync("c"), "POST", "tenants/t/databases/d/collections", "collections?tenant=t&database=d");
+ yield return Case("DeleteCollection", c => c.DeleteCollectionAsync("c"), "DELETE", "tenants/t/databases/d/collections/c", "collections/c?tenant=t&database=d");
+ yield return Case("GetVersion", c => c.GetVersionAsync(), "GET", "version", "version");
+ yield return Case("GetUserIdentity", c => c.GetUserIdentityAsync(), "GET", "auth/identity", "auth/identity");
+ yield return Case("GetPreFlightChecks", c => c.GetPreFlightChecksAsync(), "GET", "pre-flight-checks", "pre-flight-checks");
+ yield return Case("Reset", c => c.ResetAsync(), "POST", "reset", "reset");
+ yield return Case("CountCollections", c => c.CountCollectionsAsync(), "GET", "tenants/t/databases/d/collections_count", "count_collections?tenant=t&database=d");
+ yield return Case("CreateTenant", c => c.CreateTenantAsync("t"), "POST", "tenants", "tenants");
+ yield return Case("GetTenant", c => c.GetTenantAsync("t"), "GET", "tenants/t", "tenants/t");
+ yield return Case("CreateDatabase", c => c.CreateDatabaseAsync("d"), "POST", "tenants/t/databases", "databases?tenant=t");
+ yield return Case("GetDatabase", c => c.GetDatabaseAsync("d"), "GET", "tenants/t/databases/d", "databases/d?tenant=t");
+ yield return Case("ListDatabases", c => c.ListDatabasesAsync(), "GET", "tenants/t/databases", "databases?tenant=t");
+ yield return Case("ListDatabasesPage", c => c.ListDatabasesAsync(limit: 2, offset: 1), "GET", "tenants/t/databases?limit=2&offset=1", "databases?tenant=t&limit=2&offset=1");
+ yield return Case("DeleteDatabase", c => c.DeleteDatabaseAsync("d"), "DELETE", "tenants/t/databases/d", "databases/d?tenant=t");
+ yield return Case("Get", c => c.GetAsync(), "POST", $"tenants/t/databases/d/collections/{Id}/get", $"collections/{Id}/get");
+ yield return Case("Query", c => c.QueryAsync(Embedding), "POST", $"tenants/t/databases/d/collections/{Id}/query", $"collections/{Id}/query");
+ yield return Case("QueryWithIds", c => c.QueryAsync(new ChromaQuery([Embedding]) { Ids = ["a"] }), "POST", $"tenants/t/databases/d/collections/{Id}/query", $"collections/{Id}/query");
+ yield return Case("Add", c => c.AddAsync(["a"], embeddings: [Embedding]), "POST", $"tenants/t/databases/d/collections/{Id}/add", $"collections/{Id}/add");
+ yield return Case("Update", c => c.UpdateAsync(["a"]), "POST", $"tenants/t/databases/d/collections/{Id}/update", $"collections/{Id}/update");
+ yield return Case("Upsert", c => c.UpsertAsync(["a"], embeddings: [Embedding]), "POST", $"tenants/t/databases/d/collections/{Id}/upsert", $"collections/{Id}/upsert");
+ yield return Case("Delete", c => c.DeleteAsync(["a"]), "POST", $"tenants/t/databases/d/collections/{Id}/delete", $"collections/{Id}/delete");
+ yield return Case("Count", c => c.CountAsync(), "GET", $"tenants/t/databases/d/collections/{Id}/count", $"collections/{Id}/count");
+ yield return Case("CountFromGetCollectionClient", c => c.GetCollectionClient(Guid.Parse(Id), "c").CountAsync(), "GET", $"tenants/t/databases/d/collections/{Id}/count", $"collections/{Id}/count");
+ yield return Case("Peek", c => c.PeekAsync(), "POST", $"tenants/t/databases/d/collections/{Id}/get", $"collections/{Id}/get");
+ yield return Case("Modify", c => c.ModifyAsync(name: "c2"), "PUT", $"tenants/t/databases/d/collections/{Id}", $"collections/{Id}");
+ }
+
+ static TestCaseData Case(string name, Func call, string method, string v2, string v1)
+ => new TestCaseData(new Func((c, _) => call(c)), method, v2, v1).SetName($"ChromaClient.{name}");
+
+ static TestCaseData Case(string name, Func call, string method, string v2, string v1)
+ => new TestCaseData(new Func((_, c) => call(c)), method, v2, v1).SetName($"ChromaCollectionClient.{name}");
+
+ [TestCaseSource(nameof(Requests))]
+ public async Task V2(Func call, string method, string v2, string v1)
+ => await AssertRequest(ChromaApiVersion.V2, call, method, "/api/v2/" + v2);
+
+ [TestCaseSource(nameof(Requests))]
+ public async Task V1(Func call, string method, string v2, string v1)
+ => await AssertRequest(ChromaApiVersion.V1, call, method, "/api/v1/" + v1);
+
+ // The default URI is the address of the server, so it follows the chosen version.
+ [TestCase(ChromaApiVersion.V2, "http://localhost:8000/api/v2/heartbeat")]
+ [TestCase(ChromaApiVersion.V1, "http://localhost:8000/api/v1/heartbeat")]
+ public async Task DefaultOptions(ChromaApiVersion apiVersion, string expected)
+ {
+ var handler = new RecordingHandler();
+ using var httpClient = new HttpClient(handler);
+ try
+ {
+ await new ChromaClient(new ChromaConfigurationOptions().WithApiVersion(apiVersion), httpClient).HeartbeatAsync();
+ }
+ catch (ChromaException)
+ {
+ }
+ Assert.That(handler.Uri, Is.EqualTo(expected));
+ }
+
+ [Test]
+ public void DefaultIsV2()
+ => Assert.That(new ChromaConfigurationOptions().ApiVersion, Is.EqualTo(ChromaApiVersion.V2));
+
+ static async Task AssertRequest(ChromaApiVersion apiVersion, Func call, string method, string expected)
+ {
+ var handler = new RecordingHandler();
+ using var httpClient = new HttpClient(handler);
+ var options = new ChromaConfigurationOptions("http://localhost:8000", tenant: "t", database: "d").WithApiVersion(apiVersion);
+ var collection = new ChromaCollection("c") { Id = Guid.Parse(Id) };
+ try
+ {
+ await call(new ChromaClient(options, httpClient), new ChromaCollectionClient(collection, options, httpClient));
+ }
+ catch (ChromaException)
+ {
+ // The empty answer does not fit every response type: only the request matters here.
+ }
+ Assert.That(handler.Method, Is.EqualTo(method));
+ Assert.That(handler.PathAndQuery, Is.EqualTo(expected));
+ }
+
+ sealed class RecordingHandler : HttpMessageHandler
+ {
+ public string? Method { get; private set; }
+ public string? PathAndQuery { get; private set; }
+ public string? Uri { get; private set; }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ Method = request.Method.Method;
+ PathAndQuery = request.RequestUri?.PathAndQuery;
+ Uri = request.RequestUri?.AbsoluteUri;
+ // A 0.x version: the deletes go without deleting the records first, so the request is the delete. A collection with its
+ // space, which needs no version to tell it.
+ var body = request.RequestUri?.AbsolutePath.EndsWith("/version") == true ? "\"0.6.3\"" : """{"name":"c","metadata":{"hnsw:space":"cosine"}}""";
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body) });
+ }
+ }
+}
diff --git a/ChromaDB.Client.Tests/AuthenticationHeaderTests.cs b/ChromaDB.Client.Tests/AuthenticationHeaderTests.cs
new file mode 100644
index 0000000..391f804
--- /dev/null
+++ b/ChromaDB.Client.Tests/AuthenticationHeaderTests.cs
@@ -0,0 +1,98 @@
+using System.Net;
+using NUnit.Framework;
+
+namespace ChromaDB.Client.Tests;
+
+[TestFixture]
+public class AuthenticationHeaderTests
+{
+ static readonly ChromaConfigurationOptions Options = new(uri: "http://localhost:8000/api/v2/");
+
+ [Test]
+ public async Task NoCredentials()
+ {
+ var request = (await Send(Options)).Single();
+ Assert.That(request.ChromaToken, Is.Null);
+ Assert.That(request.Authorization, Is.Null);
+ }
+
+ [Test]
+ public async Task ChromaTokenInXChromaTokenHeader()
+ {
+ var request = (await Send(Options.WithChromaToken("token"))).Single();
+ Assert.That(request.ChromaToken, Is.EqualTo("token"));
+ Assert.That(request.Authorization, Is.Null);
+ }
+
+ [Test]
+ public async Task ChromaTokenInAuthorizationHeader()
+ {
+ var request = (await Send(Options.WithChromaToken("token", ChromaTokenTransportHeader.Authorization))).Single();
+ Assert.That(request.ChromaToken, Is.Null);
+ Assert.That(request.Authorization, Is.EqualTo("Bearer token"));
+ }
+
+ [Test]
+ public async Task BasicAuth()
+ {
+ var request = (await Send(Options.WithBasicAuth("admin", "secret"))).Single();
+ Assert.That(request.ChromaToken, Is.Null);
+ Assert.That(request.Authorization, Is.EqualTo("Basic YWRtaW46c2VjcmV0"));
+ }
+
+ [Test]
+ public async Task CredentialsKeptByTheOtherOptions()
+ {
+ var basic = (await Send(Options.WithBasicAuth("admin", "secret").WithTenant("tenant").WithDatabase("database").WithUri("http://localhost:8001/api/v2/"))).Single();
+ Assert.That(basic.Authorization, Is.EqualTo("Basic YWRtaW46c2VjcmV0"));
+ var bearer = (await Send(Options.WithChromaToken("token", ChromaTokenTransportHeader.Authorization).WithTenant("tenant"))).Single();
+ Assert.That(bearer.Authorization, Is.EqualTo("Bearer token"));
+ }
+
+ [Test]
+ public async Task ClientsSharingHttpClientSendTheirOwnCredentials()
+ {
+ var handler = new RecordingHandler();
+ using var httpClient = new HttpClient(handler);
+ var first = new ChromaClient(Options.WithChromaToken("first"), httpClient);
+ var second = new ChromaClient(Options.WithChromaToken("second", ChromaTokenTransportHeader.Authorization), httpClient);
+ var third = new ChromaClient(Options, httpClient);
+ await first.HeartbeatAsync();
+ await second.HeartbeatAsync();
+ await third.HeartbeatAsync();
+ await first.HeartbeatAsync();
+ Assert.That(handler.Requests.Select(x => x.ChromaToken), Is.EqualTo(new[] { "first", null, null, "first" }));
+ Assert.That(handler.Requests.Select(x => x.Authorization), Is.EqualTo(new[] { null, "Bearer second", null, null }));
+ }
+
+ [Test]
+ public void BasicAuthAndTokenInAuthorizationHeader()
+ {
+ var options = Options.WithBasicAuth("admin", "secret").WithChromaToken("token", ChromaTokenTransportHeader.Authorization);
+ Assert.That(() => new ChromaClient(options, new HttpClient()), Throws.ArgumentException);
+ Assert.That(() => new ChromaClient(options), Throws.ArgumentException);
+ }
+
+ static async Task> Send(ChromaConfigurationOptions options)
+ {
+ var handler = new RecordingHandler();
+ using var httpClient = new HttpClient(handler);
+ await new ChromaClient(options, httpClient).HeartbeatAsync();
+ return handler.Requests;
+ }
+
+ record RecordedRequest(string? ChromaToken, string? Authorization);
+
+ sealed class RecordingHandler : HttpMessageHandler
+ {
+ public List Requests { get; } = [];
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ Requests.Add(new(
+ request.Headers.TryGetValues("X-Chroma-Token", out var values) ? values.Single() : null,
+ request.Headers.Authorization?.ToString()));
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("""{"nanosecond heartbeat":1}""") });
+ }
+ }
+}
diff --git a/ChromaDB.Client.Tests/Base64AndLimitsTests.cs b/ChromaDB.Client.Tests/Base64AndLimitsTests.cs
new file mode 100644
index 0000000..e7e9337
--- /dev/null
+++ b/ChromaDB.Client.Tests/Base64AndLimitsTests.cs
@@ -0,0 +1,211 @@
+using System.Net;
+using System.Runtime.InteropServices;
+using System.Text.Json;
+using ChromaDB.Client.DependencyInjection;
+using ChromaDB.Client.Models;
+using Microsoft.Extensions.DependencyInjection;
+using NUnit.Framework;
+
+namespace ChromaDB.Client.Tests;
+
+[TestFixture]
+public class Base64AndLimitsTests
+{
+ static readonly ReadOnlyMemory Embedding = new([0.1f, -2.5f, 3.25f, 1e-7f]);
+ static readonly ChromaConfigurationOptions Options = new("http://localhost:8000");
+ const string WithBase64 = """{"max_batch_size":1000,"supports_base64_encoding":true}""";
+ const string WithoutBase64 = """{"max_batch_size":1000}""";
+
+ [Test]
+ public async Task EmbeddingsInBase64WhereTheServerDeclaresIt()
+ {
+ var handler = new Handler(WithBase64);
+ var client = Client(Options, handler);
+ await client.AddAsync(["a"], embeddings: [Embedding]);
+ await client.UpdateAsync(["a"], embeddings: [Embedding]);
+ await client.UpsertAsync(["a"], embeddings: [Embedding]);
+ Assert.That(handler.Paths.Select(Last), Is.EqualTo(new[] { "pre-flight-checks", "add", "update", "upsert" }));
+ foreach (var body in handler.Bodies)
+ {
+ var base64 = body.GetProperty("embeddings")[0].GetString()!;
+ // Little-endian float32 values, whatever the order of the bytes of the machine.
+ var bytes = Convert.FromBase64String(base64);
+ Assert.That(Enumerable.Range(0, bytes.Length / 4).Select(i => System.Buffers.Binary.BinaryPrimitives.ReadSingleLittleEndian(bytes.AsSpan(i * 4))), Is.EqualTo(Embedding.ToArray()));
+ }
+ }
+
+ // Queries always send numbers: the servers reject base64 there.
+ [Test]
+ public async Task QueriesSendNumbers()
+ {
+ var handler = new Handler(WithBase64);
+ await Client(Options, handler).QueryAsync(Embedding);
+ Assert.That(handler.Bodies.Single().GetProperty("query_embeddings")[0].GetRawText(), Is.EqualTo("[0.1,-2.5,3.25,1E-07]"));
+ }
+
+ [TestCase(WithoutBase64)]
+ [TestCase("""{"max_batch_size":1000,"supports_base64_encoding":false}""")]
+ [TestCase(null)]
+ // An answer the client cannot read: the write still goes, with numbers.
+ [TestCase("\"broken\"")]
+ public async Task NumbersWhereTheServerDoesNotDeclareIt(string? preFlightChecks)
+ {
+ var handler = new Handler(preFlightChecks);
+ await Client(Options, handler).AddAsync(["a"], embeddings: [Embedding]);
+ Assert.That(handler.Bodies.Single().GetProperty("embeddings")[0].GetRawText(), Is.EqualTo("[0.1,-2.5,3.25,1E-07]"));
+ }
+
+ // Without embeddings the client does not need to know.
+ [Test]
+ public async Task NoPreFlightChecksWithoutEmbeddings()
+ {
+ var handler = new Handler(WithBase64);
+ await Client(Options.WithBatchSplitting(false), handler).UpdateAsync(["a"], documents: ["d"]);
+ Assert.That(handler.Paths.Select(Last), Is.EqualTo(new[] { "update" }));
+ }
+
+ [TestCase(2, """{"max_batch_size":1000}""", new[] { 2, 2, 1 })]
+ [TestCase(3, """{"max_batch_size":2}""", new[] { 2, 2, 1 })]
+ [TestCase(2, null, new[] { 2, 2, 1 })]
+ [TestCase(10, """{"max_batch_size":1000}""", new[] { 5 })]
+ public async Task BatchesOfTheSmallerLimit(int maxBatchSize, string? preFlightChecks, int[] batches)
+ {
+ var handler = new Handler(preFlightChecks);
+ await Client(Options.WithBatchSplitting(maxBatchSize), handler).AddAsync(["a", "b", "c", "d", "e"], embeddings: Enumerable.Repeat(Embedding, 5).ToList());
+ Assert.That(handler.Bodies.Select(b => b.GetProperty("ids").GetArrayLength()), Is.EqualTo(batches));
+ }
+
+ [Test]
+ public void MaxBatchSizeOption()
+ {
+ Assert.Throws(() => Options.WithBatchSplitting(0));
+ Assert.Throws(() => _ = new ChromaConfigurationOptions { BatchSplitting = true, MaxBatchSize = 0 });
+ var options = Options.WithBatchSplitting(300).WithTenant("t");
+ Assert.That(options.BatchSplitting, Is.True);
+ Assert.That(options.MaxBatchSize, Is.EqualTo(300));
+ Assert.That(Options.WithBatchSplitting().MaxBatchSize, Is.Null);
+ }
+
+ [TestCase(HttpStatusCode.NotFound, """{"error":"NotFoundError","message":"Collection [c] does not exist"}""", "NotFoundError", "Collection [c] does not exist")]
+ [TestCase(HttpStatusCode.BadRequest, """{"error":"InvalidCollection","message":"Collection c does not exist."}""", "InvalidCollection", "Collection c does not exist.")]
+ [TestCase(HttpStatusCode.InternalServerError, """{"error":"ValueError('Collection c does not exist.')"}""", "ValueError", "Collection c does not exist.")]
+ [TestCase(HttpStatusCode.InternalServerError, """{"detail":"Internal Server Error"}""", null, "Internal Server Error")]
+ [TestCase(HttpStatusCode.NotFound, """{"error":"NotFoundError","detail":"Not found"}""", "NotFoundError", "Not found")]
+ // The validation errors of FastAPI, from the 0.x servers.
+ [TestCase((HttpStatusCode)422, """{"detail":[{"type":"int_parsing","loc":["body","n_results"],"msg":"Input should be a valid integer","input":"x"}]}""", null, "body.n_results: Input should be a valid integer")]
+ [TestCase((HttpStatusCode)422, """{"detail":[{"loc":["body","include",0],"msg":"Input should be 'documents'"},{"loc":["body","include",0],"msg":"Input should be 'embeddings'"}]}""", null, "body.include.0: Input should be 'documents'; body.include.0: Input should be 'embeddings'")]
+ [TestCase((HttpStatusCode)422, """{"detail":[{"msg":"Field required"}]}""", null, "Field required")]
+ [TestCase(HttpStatusCode.NotFound, """{"error":"NotFoundError"}""", "NotFoundError", "Couldn't identify the error message: {\"error\":\"NotFoundError\"}")]
+ [TestCase(HttpStatusCode.NotFound, "", null, "NotFound: GET /api/v2/tenants/default_tenant/databases/default_database/collections/c")]
+ public async Task ErrorTypeOfTheServer(HttpStatusCode statusCode, string body, string? errorType, string message)
+ {
+ using var httpClient = new HttpClient(new FixedHandler(statusCode, body));
+ var ex = Assert.ThrowsAsync(() => new ChromaClient(Options, httpClient).GetCollectionAsync("c"));
+ Assert.That(ex!.ErrorType, Is.EqualTo(errorType));
+ Assert.That(ex.Message, Is.EqualTo(message));
+ await Task.CompletedTask;
+ }
+
+ // A missing collection named by the kind of error, even if the wording of the message changes.
+ [TestCase("""{"error":"NotFoundError","message":"Collection [c] not found"}""", false)]
+ [TestCase("""{"error":"NotFoundError","message":"Tenant [t] not found"}""", null)]
+ public async Task CollectionExistsByTheErrorType(string body, bool? expected)
+ {
+ using var httpClient = new HttpClient(new FixedHandler(HttpStatusCode.NotFound, body));
+ var client = new ChromaClient(Options, httpClient);
+ if (expected is { } exists)
+ {
+ Assert.That(await client.CollectionExistsAsync("c"), Is.EqualTo(exists));
+ }
+ else
+ {
+ Assert.ThrowsAsync(() => client.CollectionExistsAsync("c"));
+ }
+ }
+
+ // The settings of the HttpClient reach the singleton: a handler in the pipeline and a timeout.
+ [Test]
+ public async Task ConfigureTheHttpClient()
+ {
+ var headers = new List();
+ var services = new ServiceCollection();
+ services.AddChromaClient(_ => Options, builder => builder
+ .AddHttpMessageHandler(() => new HeaderHandler("plain", headers))
+ .ConfigurePrimaryHttpMessageHandler(() => new HeartbeatOrWaitHandler())
+ .ConfigureHttpClient(c => c.Timeout = TimeSpan.FromMilliseconds(300)));
+ services.AddKeyedChromaClient("k", _ => Options, builder => builder
+ .AddHttpMessageHandler(() => new HeaderHandler("keyed", headers))
+ .ConfigurePrimaryHttpMessageHandler(() => new HeartbeatOrWaitHandler())
+ .ConfigureHttpClient(c => c.Timeout = TimeSpan.FromMilliseconds(300)));
+ using var provider = services.BuildServiceProvider();
+ var plain = provider.GetRequiredService();
+ var keyed = provider.GetRequiredKeyedService("k");
+ await plain.HeartbeatAsync();
+ await keyed.HeartbeatAsync();
+ Assert.That(headers, Is.EqualTo(new[] { "plain", "keyed" }));
+ // The timeout of 300 ms, not the 100 s of a default HttpClient.
+ foreach (var client in new[] { plain, keyed })
+ {
+ var watch = System.Diagnostics.Stopwatch.StartNew();
+ var timeout = Assert.ThrowsAsync(() => client.GetVersionAsync());
+ Assert.That(timeout!.InnerException, Is.InstanceOf());
+ Assert.That(watch.Elapsed, Is.LessThan(TimeSpan.FromSeconds(10)));
+ }
+ }
+
+ static string Last(string path) => path.Substring(path.LastIndexOf('/') + 1);
+
+ static ChromaCollectionClient Client(ChromaConfigurationOptions options, HttpMessageHandler handler)
+ => new(new ChromaCollection("collection") { Id = Guid.NewGuid() }, options, new HttpClient(handler));
+
+ // Answers pre-flight-checks with the given body, or 404 when it is null, and the other requests as a server would.
+ sealed class Handler(string? preFlightChecks) : HttpMessageHandler
+ {
+ public List Paths { get; } = [];
+ public List Bodies { get; } = [];
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ Paths.Add(request.RequestUri!.AbsolutePath);
+ if (request.RequestUri.AbsolutePath.EndsWith("/pre-flight-checks"))
+ {
+ return preFlightChecks is null
+ ? new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("""{"detail":"Not Found"}""") }
+ : new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(preFlightChecks) };
+ }
+ Bodies.Add(JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)).RootElement.Clone());
+ return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(request.RequestUri.AbsolutePath.EndsWith("/query") ? """{"ids":[[]]}""" : "true") };
+ }
+ }
+
+ sealed class FixedHandler(HttpStatusCode statusCode, string body) : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ => Task.FromResult(new HttpResponseMessage(statusCode) { Content = new StringContent(body) });
+ }
+
+ sealed class HeaderHandler(string name, List headers) : DelegatingHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ if (request.RequestUri!.AbsolutePath.EndsWith("/heartbeat"))
+ {
+ lock (headers) headers.Add(name);
+ }
+ return base.SendAsync(request, cancellationToken);
+ }
+ }
+
+ // Answers the heartbeat, and makes the other requests wait until they time out.
+ sealed class HeartbeatOrWaitHandler : HttpMessageHandler
+ {
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ if (!request.RequestUri!.AbsolutePath.EndsWith("/heartbeat"))
+ {
+ await Task.Delay(Timeout.Infinite, cancellationToken);
+ }
+ return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("""{"nanosecond heartbeat":1}""") };
+ }
+ }
+}
diff --git a/ChromaDB.Client.Tests/BatchSplittingTests.cs b/ChromaDB.Client.Tests/BatchSplittingTests.cs
new file mode 100644
index 0000000..fee06ee
--- /dev/null
+++ b/ChromaDB.Client.Tests/BatchSplittingTests.cs
@@ -0,0 +1,119 @@
+using ChromaDB.Client.Models;
+using NUnit.Framework;
+
+namespace ChromaDB.Client.Tests;
+
+[TestFixture]
+public class BatchSplittingTests : ChromaTestsBase
+{
+ // One record beyond the max_batch_size of the server: up to Chroma 1.0.13 a single request fails, in batches it works.
+ [Test]
+ public async Task AddAndDeleteBeyondTheMaxBatchSize()
+ {
+ Assume.That(PreFlightChecksSupported, Is.True, "Chroma 0.4.10 has no pre-flight-checks, so the client does not know its limit.");
+ var options = TestMaxBatchSize is { } limit ? BaseConfigurationOptions.WithBatchSplitting(limit) : BaseConfigurationOptions.WithBatchSplitting();
+ var client = new ChromaClient(options, HttpClient);
+ var count = (await client.GetPreFlightChecksAsync()).MaxBatchSize + 1;
+ var collectionClient = client.GetCollectionClient(await client.CreateCollectionAsync($"collection{Random.Shared.Next()}"));
+ var ids = Enumerable.Range(0, count).Select(i => $"r{i}").ToList();
+
+ await collectionClient.AddAsync(new ChromaRecords(ids) { Embeddings = Enumerable.Repeat(new ReadOnlyMemory([0.5f, 0.5f]), count).ToList() });
+ Assert.That(await collectionClient.CountAsync(), Is.EqualTo(count));
+ await collectionClient.DeleteAsync(ids);
+ Assert.That(await collectionClient.CountAsync(), Is.EqualTo(0));
+ }
+
+ // DeleteAllAsync deletes a page of the batch size at a time, until none is left.
+ [Test]
+ public async Task DeleteAllInPages()
+ {
+ var client = new ChromaClient(BaseConfigurationOptions.WithBatchSplitting(1000), HttpClient);
+ var collectionClient = client.GetCollectionClient(await client.CreateCollectionAsync($"collection{Random.Shared.Next()}"));
+ var ids = Enumerable.Range(0, 3000).Select(i => $"r{i}").ToList();
+ await collectionClient.AddAsync(new ChromaRecords(ids) { Embeddings = Enumerable.Repeat(new ReadOnlyMemory([0.5f, 0.5f]), 3000).ToList() });
+
+ Assert.That(await collectionClient.DeleteAllAsync(), Is.EqualTo(3000));
+ Assert.That(await collectionClient.CountAsync(), Is.EqualTo(0));
+ Assert.That(await collectionClient.DeleteAllAsync(), Is.EqualTo(0));
+ }
+
+ // Chroma Cloud answers at most 300 records per read and rejects a larger limit: a fresh client with WithBatchSplitting(1000), on a
+ // collection another client filled, learns it from the first get, reads all the records and deletes them all.
+ [Test]
+ public async Task QuotaOfTheReadsOfChromaCloud()
+ {
+ Assume.That(ChromaCloud, Is.True, "Only Chroma Cloud has the quota of 300 records per read.");
+ var filler = new ChromaClient(BaseConfigurationOptions, HttpClient);
+ var collection = await filler.CreateCollectionAsync($"collection{Random.Shared.Next()}");
+ var ids = Enumerable.Range(0, 350).Select(i => $"r{i}").ToList();
+ await filler.GetCollectionClient(collection).AddAsync(new ChromaRecords(ids) { Embeddings = Enumerable.Repeat(new ReadOnlyMemory([0.5f, 0.5f]), 350).ToList() });
+
+ var reader = new ChromaClient(BaseConfigurationOptions.WithBatchSplitting(1000), NewHttpClient()).GetCollectionClient(collection);
+ Assert.That((await reader.GetAsync(include: ChromaGetInclude.None)).Count, Is.EqualTo(350));
+ var deleter = new ChromaClient(BaseConfigurationOptions.WithBatchSplitting(1000), NewHttpClient()).GetCollectionClient(collection);
+ Assert.That(await deleter.DeleteAllAsync(), Is.EqualTo(350));
+ Assert.That(await filler.GetCollectionClient(collection).CountAsync(), Is.EqualTo(0));
+ }
+
+ // Chroma Cloud rejects a get of more ids than its quota of 300, with a limit or without: a fresh client with WithBatchSplitting(1000)
+ // learns it and reads the ids in batches.
+ [TestCase(350)]
+ [TestCase(null)]
+ public async Task QuotaOfAReadByIdsOfChromaCloud(int? limit)
+ {
+ Assume.That(ChromaCloud, Is.True, "Only Chroma Cloud has the quota of 300 records per read.");
+ var filler = new ChromaClient(BaseConfigurationOptions, HttpClient);
+ var collection = await filler.CreateCollectionAsync($"collection{Random.Shared.Next()}");
+ var ids = Enumerable.Range(0, 350).Select(i => $"r{i}").ToList();
+ await filler.GetCollectionClient(collection).AddAsync(new ChromaRecords(ids) { Embeddings = Enumerable.Repeat(new ReadOnlyMemory([0.5f, 0.5f]), 350).ToList() });
+
+ var reader = new ChromaClient(BaseConfigurationOptions.WithBatchSplitting(1000), NewHttpClient()).GetCollectionClient(collection);
+ Assert.That((await reader.GetAsync(ids, limit: limit, include: ChromaGetInclude.None)).Select(x => x.Id), Is.EquivalentTo(ids));
+ }
+
+ // More ids than the batch size of the caller: its batches go beyond the quota of Chroma Cloud, and the client reads them again smaller.
+ [Test]
+ public async Task QuotaOfReadsByIdsInBatchesOfChromaCloud()
+ {
+ Assume.That(ChromaCloud, Is.True, "Only Chroma Cloud has the quota of 300 records per read.");
+ var filler = new ChromaClient(BaseConfigurationOptions, HttpClient);
+ var collection = await filler.CreateCollectionAsync($"collection{Random.Shared.Next()}");
+ var ids = Enumerable.Range(0, 1001).Select(i => $"r{i}").ToList();
+ await filler.GetCollectionClient(collection).AddAsync(new ChromaRecords(ids) { Embeddings = Enumerable.Repeat(new ReadOnlyMemory([0.5f, 0.5f]), 1001).ToList() });
+
+ var reader = new ChromaClient(BaseConfigurationOptions.WithBatchSplitting(1000), NewHttpClient()).GetCollectionClient(collection);
+ Assert.That((await reader.GetAsync(ids, include: ChromaGetInclude.None)).Select(x => x.Id), Is.EquivalentTo(ids));
+ }
+
+ // Get in pages of the batch size: all the records, a limit and an offset across pages, and ids beyond the batch size.
+ [Test]
+ public async Task GetBeyondTheBatchSize()
+ {
+ var client = new ChromaClient(BaseConfigurationOptions.WithBatchSplitting(3), HttpClient);
+ var collectionClient = client.GetCollectionClient(await client.CreateCollectionAsync($"collection{Random.Shared.Next()}"));
+ var ids = Enumerable.Range(0, 7).Select(i => $"r{i}").ToList();
+ await collectionClient.AddAsync(new ChromaRecords(ids) { Embeddings = Enumerable.Repeat(new ReadOnlyMemory([0.5f, 0.5f]), 7).ToList() });
+
+ var all = await collectionClient.GetAsync();
+ Assert.That(all.Select(x => x.Id), Is.EquivalentTo(ids));
+ var page = await collectionClient.GetAsync(limit: 5, offset: 1);
+ Assert.That(page.Select(x => x.Id), Is.EqualTo(all.Skip(1).Take(5).Select(x => x.Id)));
+ Assert.That((await collectionClient.GetAsync(ids)).Select(x => x.Id), Is.EquivalentTo(ids));
+ }
+
+ // Chroma Cloud declares a max_batch_size of 1000 but takes 300 records per write: by default the client sends 300 at a time; with a
+ // larger limit of the caller, the rejected batch and the rest go in batches of the quota that its message tells.
+ [TestCase(null)]
+ [TestCase(1000)]
+ public async Task QuotaOfRecordsOfChromaCloud(int? maxBatchSize)
+ {
+ Assume.That(ChromaCloud, Is.True, "Only Chroma Cloud has the quota of 300 records.");
+ var options = maxBatchSize is { } limit ? BaseConfigurationOptions.WithBatchSplitting(limit) : BaseConfigurationOptions;
+ var client = new ChromaClient(options, HttpClient);
+ var collectionClient = client.GetCollectionClient(await client.CreateCollectionAsync($"collection{Random.Shared.Next()}"));
+ var ids = Enumerable.Range(0, 301).Select(i => $"r{i}").ToList();
+ await collectionClient.AddAsync(new ChromaRecords(ids) { Embeddings = Enumerable.Repeat(new ReadOnlyMemory([0.5f, 0.5f]), 301).ToList() });
+ Assert.That(await collectionClient.CountAsync(), Is.EqualTo(301));
+ Assert.That((await collectionClient.GetAsync()).Count, Is.EqualTo(301));
+ }
+}
diff --git a/ChromaDB.Client.Tests/BatchesAndFiltersTests.cs b/ChromaDB.Client.Tests/BatchesAndFiltersTests.cs
new file mode 100644
index 0000000..789463d
--- /dev/null
+++ b/ChromaDB.Client.Tests/BatchesAndFiltersTests.cs
@@ -0,0 +1,383 @@
+using System.Net;
+using System.Text.Json;
+using ChromaDB.Client.Models;
+using NUnit.Framework;
+
+namespace ChromaDB.Client.Tests;
+
+[TestFixture]
+public class BatchesAndFiltersTests
+{
+ static readonly ReadOnlyMemory Embedding = new([1f, 0f]);
+ static readonly ChromaConfigurationOptions Options = new("http://localhost:8000");
+
+ static ChromaRecords FiveRecords() => new(["a", "b", "c", "d", "e"])
+ {
+ Embeddings = Enumerable.Repeat(Embedding, 5).ToList(),
+ Metadatas = Enumerable.Range(1, 5).Select(i => new Dictionary { ["i"] = i }).ToList(),
+ Documents = ["1", "2", "3", "4", "5"],
+ Uris = ["u1", null, "u3", null, "u5"],
+ };
+
+ // With WithBatchSplitting(false) the records go in one request; pre-flight-checks is asked only for the base64 of the embeddings.
+ [Test]
+ public async Task OneRequestWithoutBatchSplitting()
+ {
+ var handler = new Handler("""{"max_batch_size":2}""");
+ await Client(Options.WithBatchSplitting(false), handler).AddAsync(FiveRecords());
+ Assert.That(handler.Paths.Select(Last), Is.EqualTo(new[] { "pre-flight-checks", "add" }));
+ Assert.That(handler.Bodies.Single().GetProperty("ids").GetArrayLength(), Is.EqualTo(5));
+ }
+
+ // Batch splitting is on by default.
+ [Test]
+ public async Task BatchesByDefault()
+ {
+ var handler = new Handler("""{"max_batch_size":2}""");
+ await Client(new ChromaConfigurationOptions("http://localhost:8000"), handler).AddAsync(FiveRecords());
+ Assert.That(handler.Bodies.Select(b => b.GetProperty("ids").GetArrayLength()), Is.EqualTo(new[] { 2, 2, 1 }));
+ }
+
+ [Test]
+ public async Task BatchesOfTheMaxBatchSize()
+ {
+ var handler = new Handler("""{"max_batch_size":2}""");
+ await Client(Options.WithBatchSplitting(), handler).AddAsync(FiveRecords());
+ Assert.That(handler.Paths.Select(Last), Is.EqualTo(new[] { "pre-flight-checks", "add", "add", "add" }));
+ Assert.That(handler.Bodies.Select(b => Strings(b, "ids")), Is.EqualTo(new[] { new[] { "a", "b" }, new[] { "c", "d" }, new[] { "e" } }));
+ Assert.That(handler.Bodies.Select(b => Strings(b, "documents")), Is.EqualTo(new[] { new[] { "1", "2" }, new[] { "3", "4" }, new[] { "5" } }));
+ Assert.That(handler.Bodies.Select(b => Strings(b, "uris")), Is.EqualTo(new[] { new[] { "u1", null }, new[] { "u3", null }, new[] { "u5" } }));
+ Assert.That(handler.Bodies.Select(b => b.GetProperty("metadatas").EnumerateArray().Select(m => m.GetProperty("i").GetInt32())), Is.EqualTo(new[] { new[] { 1, 2 }, new[] { 3, 4 }, new[] { 5 } }));
+ Assert.That(handler.Bodies.Select(b => b.GetProperty("embeddings").GetArrayLength()), Is.EqualTo(new[] { 2, 2, 1 }));
+ }
+
+ // The limit is asked once for all the calls of a client.
+ [Test]
+ public async Task UpdateUpsertAndDeleteToo()
+ {
+ var handler = new Handler("""{"max_batch_size":2}""");
+ var client = Client(Options.WithBatchSplitting(), handler);
+ await client.UpdateAsync(FiveRecords());
+ await client.UpsertAsync(FiveRecords());
+ await client.DeleteAsync(["a", "b", "c"], where: ChromaWhereOperator.Equal("k", 1));
+ Assert.That(handler.Paths.Select(Last), Is.EqualTo(new[] { "pre-flight-checks", "update", "update", "update", "upsert", "upsert", "upsert", "delete", "delete" }));
+ Assert.That(handler.Bodies.Skip(6).Select(b => Strings(b, "ids")), Is.EqualTo(new[] { new[] { "a", "b" }, new[] { "c" } }));
+ Assert.That(handler.Bodies.Skip(6).Select(b => b.GetProperty("where").GetRawText()), Is.All.EqualTo("""{"k":{"$eq":1}}"""));
+ }
+
+ [Test]
+ public async Task OneRequestWithinTheLimit()
+ {
+ var handler = new Handler("""{"max_batch_size":5}""");
+ await Client(Options.WithBatchSplitting(), handler).AddAsync(FiveRecords());
+ Assert.That(handler.Paths.Select(Last), Is.EqualTo(new[] { "pre-flight-checks", "add" }));
+ }
+
+ // Chroma 0.4.10 has no pre-flight-checks: the records go in one request.
+ [Test]
+ public async Task OneRequestWithoutPreFlightChecks()
+ {
+ var handler = new Handler(null);
+ await Client(Options.WithBatchSplitting(), handler).AddAsync(FiveRecords());
+ Assert.That(handler.Paths.Select(Last), Is.EqualTo(new[] { "pre-flight-checks", "add" }));
+ Assert.That(handler.Bodies.Single().GetProperty("ids").GetArrayLength(), Is.EqualTo(5));
+ }
+
+ // A failed batch stops the others; the earlier batches stay written.
+ [Test]
+ public async Task FailedBatchStops()
+ {
+ var handler = new Handler("""{"max_batch_size":2}""") { FailAt = 2 };
+ Assert.ThrowsAsync(() => Client(Options.WithBatchSplitting(), handler).AddAsync(FiveRecords()));
+ Assert.That(handler.Paths.Select(Last), Is.EqualTo(new[] { "pre-flight-checks", "add", "add" }));
+ }
+
+ [Test]
+ public void OtherOptionsKeepBatchSplitting()
+ => Assert.That(Options.WithBatchSplitting().WithTenant("t").WithMetadataValues(ChromaMetadataValues.Exact).BatchSplitting, Is.True);
+
+ [Test]
+ public async Task FiltersAsTheJsonTheClientSends()
+ {
+ var where = ChromaWhereOperator.Equal("k", 1) & (ChromaWhereOperator.In("t", "a", "b") | ChromaWhereOperator.Contains("tags", "x"));
+ var whereDocument = ChromaWhereDocumentOperator.Contains("foo") | ChromaWhereDocumentOperator.NotContains("bar");
+ Assert.That(where.ToString(), Is.EqualTo("""{"$and":[{"k":{"$eq":1}},{"$or":[{"t":{"$in":["a","b"]}},{"tags":{"$contains":"x"}}]}]}"""));
+ Assert.That(whereDocument.ToString(), Is.EqualTo("""{"$or":[{"$contains":"foo"},{"$not_contains":"bar"}]}"""));
+
+ var handler = new Handler("""{"ids":[]}""");
+ await Client(Options, handler).GetAsync(where: where, whereDocument: whereDocument);
+ Assert.That(handler.Bodies.Single().GetProperty("where").GetRawText(), Is.EqualTo(where.ToString()));
+ Assert.That(handler.Bodies.Single().GetProperty("where_document").GetRawText(), Is.EqualTo(whereDocument.ToString()));
+ }
+
+ // An empty metadata goes as null in add, upsert and update: every tested Chroma treats the two the same.
+ [Test]
+ public async Task EmptyMetadataGoesAsNull()
+ {
+ var records = new ChromaRecords(["a", "b"])
+ {
+ Embeddings = [Embedding, Embedding],
+ Metadatas = [new Dictionary(), new Dictionary { ["k"] = 1 }],
+ };
+ foreach (var write in new Func[] { x => x.AddAsync(records), x => x.UpsertAsync(records), x => x.UpdateAsync(records) })
+ {
+ var handler = new Handler("""{"max_batch_size":10}""");
+ await write(Client(Options, handler));
+ Assert.That(handler.Bodies.Single().GetProperty("metadatas").GetRawText(), Is.EqualTo("""[null,{"k":1}]"""));
+ }
+ }
+
+ // A long $and or $or given as a dictionary, with an array, another list or from JSON, goes split as the same filter written with the
+ // operators (KD-4), in where and where_document, also nested; a short one goes as it is.
+ [Test]
+ public async Task LongListsFromDictionaries()
+ {
+ static string Contains(int i) => $$"""{"$contains":"w{{i:D4}}"}""";
+ static string NotEqual(int i) => "{\"k\":{\"$ne\":" + i + "}}";
+ var documents = "{\"$or\":[{\"$and\":[" + string.Join(",", Enumerable.Range(0, 1000).Select(Contains)) + "]},{\"$contains\":\"xyz\"}]}";
+ var where = "{\"$and\":[" + string.Join(",", Enumerable.Range(0, 1000).Select(NotEqual)) + "]}";
+ var documentsWithOperators = Enumerable.Range(0, 1000).Select(i => ChromaWhereDocumentOperator.Contains($"w{i:D4}")).Aggregate((a, b) => a & b) | ChromaWhereDocumentOperator.Contains("xyz");
+ var whereWithOperators = Enumerable.Range(0, 1000).Select(i => ChromaWhereOperator.NotEqual("k", i)).Aggregate((a, b) => a & b);
+
+ var expected = await Sent(whereWithOperators, documentsWithOperators);
+ Assert.That(JsonDocument.Parse(expected.WhereDocument).RootElement.GetProperty("$or")[0].GetProperty("$and").GetArrayLength(), Is.EqualTo(32));
+ foreach (var (whereFilter, documentFilter) in new[]
+ {
+ (ChromaWhereOperator.FromDictionary(FromJson(where)), ChromaWhereDocumentOperator.FromDictionary(FromJson(documents))),
+ (ChromaWhereOperator.FromDictionary(AsLists(FromJson(where))), ChromaWhereDocumentOperator.FromDictionary(AsLists(FromJson(documents)))),
+ })
+ {
+ var sent = await Sent(whereFilter, documentFilter);
+ Assert.That(sent.Where, Is.EqualTo(expected.Where));
+ Assert.That(sent.WhereDocument, Is.EqualTo(expected.WhereDocument));
+ }
+ var shortList = ChromaWhereDocumentOperator.FromDictionary(FromJson("""{"$and":[{"$contains":"abc"},{"$contains":"def"}]}"""));
+ Assert.That((await Sent(null, shortList)).WhereDocument, Is.EqualTo("""{"$and":[{"$contains":"abc"},{"$contains":"def"}]}"""));
+ }
+
+ // Operands that can be read only once: the client copies the dictionary when it gets it and reads each list once, so the request has
+ // them all, short or long, in where and where_document; later changes to the dictionary do not change the filter.
+ [TestCase(2)]
+ [TestCase(1000)]
+ public async Task OperandsThatCanBeReadOnce(int count)
+ {
+ var documents = ChromaWhereDocumentOperator.FromDictionary(new Dictionary { ["$and"] = new Once(Enumerable.Range(0, count).Select(i => (object)new Dictionary { ["$contains"] = $"w{i:D4}" })) });
+ var whereDictionary = new Dictionary { ["$and"] = new Once(Enumerable.Range(0, count).Select(i => (object)new Dictionary { ["k"] = new Dictionary { ["$ne"] = i } })) };
+ var where = ChromaWhereOperator.FromDictionary(whereDictionary);
+ whereDictionary["$or"] = "changed";
+ var expected = await Sent(
+ Enumerable.Range(0, count).Select(i => ChromaWhereOperator.NotEqual("k", i)).Aggregate((a, b) => a & b),
+ Enumerable.Range(0, count).Select(i => ChromaWhereDocumentOperator.Contains($"w{i:D4}")).Aggregate((a, b) => a & b));
+ var sent = await Sent(where, documents);
+ Assert.That(sent.Where, Is.EqualTo(expected.Where));
+ Assert.That(sent.WhereDocument, Is.EqualTo(expected.WhereDocument));
+ Assert.That(where.ToString(), Is.EqualTo(expected.Where));
+ }
+
+ static async Task<(string Where, string WhereDocument)> Sent(ChromaWhereOperator? where, ChromaWhereDocumentOperator whereDocument)
+ {
+ var handler = new Handler("""{"ids":[]}""");
+ await Client(Options, handler).GetAsync(where: where, whereDocument: whereDocument);
+ var body = handler.Bodies.Single();
+ return (where is null ? "" : body.GetProperty("where").GetRawText(), body.GetProperty("where_document").GetRawText());
+ }
+
+ // A sequence that can be enumerated only once.
+ sealed class Once(IEnumerable