From 09cdd00105e72c72056afca1034efb94b1c42a30 Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Thu, 6 Aug 2026 17:07:05 -0300 Subject: [PATCH 1/2] fix(memory): support Chroma v2 collections --- .changeset/gentle-dragons-listen.md | 5 + .../content/docs/data/memory/chroma.mdx | 17 ++- packages/memory/README.md | 2 +- packages/memory/src/vector/chroma.ts | 51 ++++++++- packages/memory/tests/chroma-v2.test.ts | 103 ++++++++++++++++++ packages/memory/tests/vector-extra.test.ts | 21 +++- packages/memory/tests/vector.test.ts | 16 ++- readme-standard-v1.json | 2 +- 8 files changed, 202 insertions(+), 15 deletions(-) create mode 100644 .changeset/gentle-dragons-listen.md create mode 100644 packages/memory/tests/chroma-v2.test.ts diff --git a/.changeset/gentle-dragons-listen.md b/.changeset/gentle-dragons-listen.md new file mode 100644 index 00000000..bcc0d1fa --- /dev/null +++ b/.changeset/gentle-dragons-listen.md @@ -0,0 +1,5 @@ +--- +'@agentskit/memory': patch +--- + +Update the Chroma adapter to use v2 tenant and database collection endpoints, resolve collection IDs, and support token authentication and custom headers. diff --git a/apps/docs-next/content/docs/data/memory/chroma.mdx b/apps/docs-next/content/docs/data/memory/chroma.mdx index aea3e3a7..bb9928d4 100644 --- a/apps/docs-next/content/docs/data/memory/chroma.mdx +++ b/apps/docs-next/content/docs/data/memory/chroma.mdx @@ -1,6 +1,6 @@ --- title: chroma -description: Chroma vector DB via HTTP. +description: Chroma v2 vector memory via HTTP, with local and hosted deployment support. --- ```ts @@ -9,9 +9,17 @@ import { chroma } from '@agentskit/memory' const store = chroma({ url: process.env.CHROMA_URL ?? 'http://localhost:8000', collection: 'agentskit', + tenant: process.env.CHROMA_TENANT, + database: process.env.CHROMA_DATABASE, + apiKey: process.env.CHROMA_API_KEY, }) ``` +The adapter resolves the collection name to its Chroma v2 collection ID on the +first operation and caches it for subsequent stores, searches, and deletes. +`tenant` and `database` default to Chroma's `default_tenant` and +`default_database`, so local deployments only need `url` and `collection`. + ## Options | Option | Type | @@ -20,6 +28,13 @@ const store = chroma({ | `collection` | `string` | | `tenant` | `string?` | | `database` | `string?` | +| `apiKey` | `string?` | +| `headers` | `Record?` | +| `topK` | `number?` | +| `fetch` | `typeof globalThis.fetch?` | + +`apiKey` is sent as `x-chroma-token`. Use `headers` when a hosted or proxied +deployment requires additional request headers. ## Related diff --git a/packages/memory/README.md b/packages/memory/README.md index ffc2a414..8fda3dc3 100644 --- a/packages/memory/README.md +++ b/packages/memory/README.md @@ -109,7 +109,7 @@ persistence succeeds. - `pgvector` — BYO SQL runner (`postgres.js`, `pg`, Drizzle, Prisma, Neon). - `pinecone` — managed; namespaces + metadata filters. - `qdrant` — self-hosted or cloud via HTTP. -- `chroma` — HTTP collection client. +- `chroma` — Chroma v2 HTTP client with tenant, database, and token support. - `upstashVector` — serverless HTTP. Same 3-method `VectorStore` contract — swap without touching agent code. diff --git a/packages/memory/src/vector/chroma.ts b/packages/memory/src/vector/chroma.ts index 30ea3c2d..da0aa20c 100644 --- a/packages/memory/src/vector/chroma.ts +++ b/packages/memory/src/vector/chroma.ts @@ -5,6 +5,14 @@ export interface ChromaConfig { /** Base URL of a running Chroma HTTP server. */ url: string collection: string + /** Chroma tenant. Defaults to `default_tenant`. */ + tenant?: string + /** Chroma database. Defaults to `default_database`. */ + database?: string + /** Chroma token sent through the `x-chroma-token` header. */ + apiKey?: string + /** Additional headers for hosted or proxied Chroma deployments. */ + headers?: Record topK?: number fetch?: typeof globalThis.fetch } @@ -16,9 +24,12 @@ async function call( body?: unknown, ): Promise { const fetchImpl = config.fetch ?? globalThis.fetch + const headers = new Headers(config.headers) + if (config.apiKey !== undefined) headers.set('x-chroma-token', config.apiKey) + headers.set('content-type', 'application/json') const response = await fetchImpl(`${config.url}${path}`, { method, - headers: { 'content-type': 'application/json' }, + headers, body: body === undefined ? undefined : JSON.stringify(body), }) const text = await response.text() @@ -34,11 +45,43 @@ async function call( export function chroma(config: ChromaConfig): VectorMemory { const defaultTopK = Math.max(1, config.topK ?? 10) + const resolvedConfig = { ...config, url: config.url.replace(/\/+$/, '') } + const tenant = encodeURIComponent(config.tenant ?? 'default_tenant') + const database = encodeURIComponent(config.database ?? 'default_database') + const collection = encodeURIComponent(config.collection) + const collectionsPath = `/api/v2/tenants/${tenant}/databases/${database}/collections` + let collectionIdPromise: Promise | undefined + + function resolveCollectionId(): Promise { + collectionIdPromise ??= call<{ id?: string }>( + resolvedConfig, + 'GET', + `${collectionsPath}/${collection}`, + ).then(result => { + if (typeof result.id !== 'string' || result.id.length === 0) { + throw new MemoryError({ + code: ErrorCodes.AK_MEMORY_REMOTE_HTTP, + message: 'chroma collection response did not include an id', + hint: `Check collection ${config.collection} in tenant ${config.tenant ?? 'default_tenant'} and database ${config.database ?? 'default_database'}.`, + }) + } + return result.id + }).catch(error => { + collectionIdPromise = undefined + throw error + }) + return collectionIdPromise + } + + async function collectionPath(operation: 'upsert' | 'query' | 'delete'): Promise { + const collectionId = encodeURIComponent(await resolveCollectionId()) + return `${collectionsPath}/${collectionId}/${operation}` + } return { async store(docs: VectorDocument[]) { if (docs.length === 0) return - await call(config, 'POST', `/api/v1/collections/${config.collection}/upsert`, { + await call(resolvedConfig, 'POST', await collectionPath('upsert'), { ids: docs.map(d => d.id), embeddings: docs.map(d => d.embedding), documents: docs.map(d => d.content), @@ -54,7 +97,7 @@ export function chroma(config: ChromaConfig): VectorMemory { documents?: string[][] metadatas?: Array>> distances?: number[][] - }>(config, 'POST', `/api/v1/collections/${config.collection}/query`, { + }>(resolvedConfig, 'POST', await collectionPath('query'), { query_embeddings: [embedding], n_results: topK, }) @@ -74,7 +117,7 @@ export function chroma(config: ChromaConfig): VectorMemory { async delete(ids: string[]) { if (ids.length === 0) return - await call(config, 'POST', `/api/v1/collections/${config.collection}/delete`, { ids }) + await call(resolvedConfig, 'POST', await collectionPath('delete'), { ids }) }, } } diff --git a/packages/memory/tests/chroma-v2.test.ts b/packages/memory/tests/chroma-v2.test.ts new file mode 100644 index 00000000..95cc17b9 --- /dev/null +++ b/packages/memory/tests/chroma-v2.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from 'vitest' +import { chroma } from '../src/vector/chroma' + +type FetchCall = { url: string; init?: RequestInit } + +function chromaFetch(operationResponse: unknown = {}) { + const calls: FetchCall[] = [] + const fetch = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + calls.push({ url, init }) + const body = init?.method === 'GET' ? { id: 'collection-id' } : operationResponse + return new Response(JSON.stringify(body), { status: 200 }) + }) + return { fetch: fetch as unknown as typeof globalThis.fetch, calls } +} + +describe('chroma v2', () => { + it('resolves a collection name once and uses its id for v2 operations', async () => { + const { fetch, calls } = chromaFetch({ + ids: [['doc-1']], + documents: [['hello']], + metadatas: [[{ topic: 'test' }]], + distances: [[0.2]], + }) + const store = chroma({ url: 'https://chroma.example/', collection: 'docs', fetch }) + + await store.store([{ id: 'doc-1', content: 'hello', embedding: [0.1] }]) + const result = await store.search([0.1]) + await store.delete?.(['doc-1']) + + expect(calls.map(call => call.url)).toEqual([ + 'https://chroma.example/api/v2/tenants/default_tenant/databases/default_database/collections/docs', + 'https://chroma.example/api/v2/tenants/default_tenant/databases/default_database/collections/collection-id/upsert', + 'https://chroma.example/api/v2/tenants/default_tenant/databases/default_database/collections/collection-id/query', + 'https://chroma.example/api/v2/tenants/default_tenant/databases/default_database/collections/collection-id/delete', + ]) + expect(result[0]).toMatchObject({ id: 'doc-1', content: 'hello', score: 0.8 }) + }) + + it('encodes custom tenant, database, collection name, and resolved collection id', async () => { + const calls: FetchCall[] = [] + const fetch = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + calls.push({ url, init }) + const body = init?.method === 'GET' ? { id: 'id/with space' } : {} + return new Response(JSON.stringify(body)) + }) as unknown as typeof globalThis.fetch + const store = chroma({ + url: 'https://chroma.example', + tenant: 'acme corp', + database: 'support/prod', + collection: 'customer docs', + fetch, + }) + + await store.delete?.(['doc-1']) + + expect(calls[0]?.url).toBe( + 'https://chroma.example/api/v2/tenants/acme%20corp/databases/support%2Fprod/collections/customer%20docs', + ) + expect(calls[1]?.url).toBe( + 'https://chroma.example/api/v2/tenants/acme%20corp/databases/support%2Fprod/collections/id%2Fwith%20space/delete', + ) + }) + + it('merges custom headers with token auth and JSON content type', async () => { + const { fetch, calls } = chromaFetch() + const store = chroma({ + url: 'https://chroma.example', + collection: 'docs', + apiKey: 'secret-token', + headers: { + 'x-tenant-header': 'tenant-value', + 'Content-Type': 'text/plain', + 'X-Chroma-Token': 'stale-token', + }, + fetch, + }) + + await store.store([{ id: 'doc-1', content: 'hello', embedding: [0.1] }]) + + const resolutionHeaders = new Headers(calls[0]?.init?.headers) + const operationHeaders = new Headers(calls[1]?.init?.headers) + expect(resolutionHeaders.get('x-tenant-header')).toBe('tenant-value') + expect(resolutionHeaders.get('x-chroma-token')).toBe('secret-token') + expect(resolutionHeaders.get('content-type')).toBe('application/json') + expect([...operationHeaders.entries()]).toEqual([...resolutionHeaders.entries()]) + }) + + it('surfaces collection resolution failures as Chroma HTTP errors', async () => { + const fetch = vi.fn(async () => new Response('missing', { status: 404 })) as unknown as typeof globalThis.fetch + const store = chroma({ url: 'https://chroma.example', collection: 'missing', fetch }) + + await expect(store.search([0.1])).rejects.toThrow(/chroma 404: missing/) + }) + + it('rejects a collection response without an id', async () => { + const fetch = vi.fn(async () => new Response(JSON.stringify({ name: 'docs' }))) as unknown as typeof globalThis.fetch + const store = chroma({ url: 'https://chroma.example', collection: 'docs', fetch }) + + await expect(store.search([0.1])).rejects.toThrow(/collection response did not include an id/) + }) +}) diff --git a/packages/memory/tests/vector-extra.test.ts b/packages/memory/tests/vector-extra.test.ts index 404c8ad4..4bdb1b05 100644 --- a/packages/memory/tests/vector-extra.test.ts +++ b/packages/memory/tests/vector-extra.test.ts @@ -20,15 +20,26 @@ function mockFetch(response: unknown, opts: { status?: number } = {}) { return { fetch: fake as unknown as typeof globalThis.fetch, calls } } +function mockChromaFetch(response: unknown) { + const calls: Array<{ url: string; init?: RequestInit }> = [] + const fake = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.href : (url as Request).url + calls.push({ url: urlStr, init }) + const body = init?.method === 'GET' ? { id: 'collection-id' } : response + return new Response(JSON.stringify(body)) + }) + return { fetch: fake as unknown as typeof globalThis.fetch, calls } +} + // ─── chroma ────────────────────────────────────────────────────────────────── describe('chroma — extra', () => { it('store upserts documents', async () => { - const { fetch, calls } = mockFetch({}) + const { fetch, calls } = mockChromaFetch({}) const store = chroma({ url: 'https://chroma', collection: 'docs', fetch }) await store.store([{ id: 'a', content: 'hello', embedding: [0.1, 0.2] }]) - expect(calls[0]!.url).toContain('/upsert') - expect(calls[0]!.init!.method).toBe('POST') + expect(calls[1]!.url).toContain('/upsert') + expect(calls[1]!.init!.method).toBe('POST') }) it('store is no-op when docs array is empty', async () => { @@ -45,7 +56,7 @@ describe('chroma — extra', () => { }) it('search filters by threshold', async () => { - const { fetch } = mockFetch({ + const { fetch } = mockChromaFetch({ ids: [['a', 'b']], documents: [['doc a', 'doc b']], metadatas: [[{}, {}]], @@ -66,7 +77,7 @@ describe('chroma — extra', () => { }) it('empty response body yields empty result for search', async () => { - const { fetch } = mockFetch({}) + const { fetch } = mockChromaFetch({}) const store = chroma({ url: 'https://chroma', collection: 'docs', fetch }) const out = await store.search([1, 2]) expect(out).toEqual([]) diff --git a/packages/memory/tests/vector.test.ts b/packages/memory/tests/vector.test.ts index c026e360..1a3da761 100644 --- a/packages/memory/tests/vector.test.ts +++ b/packages/memory/tests/vector.test.ts @@ -10,6 +10,16 @@ function mockFetch(response: unknown, opts: { status?: number } = {}) { return { fetch: fake as unknown as typeof globalThis.fetch, calls } } +function mockChromaFetch(response: unknown) { + const calls: Array<{ url: string; init?: RequestInit }> = [] + const fake = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: typeof url === 'string' ? url : url instanceof URL ? url.href : url.url, init }) + const body = init?.method === 'GET' ? { id: 'collection-id' } : response + return new Response(JSON.stringify(body)) + }) + return { fetch: fake as unknown as typeof globalThis.fetch, calls } +} + describe('pgvector', () => { const runner = { query: vi.fn(async () => ({ rows: [] as Array> })), @@ -117,7 +127,7 @@ describe('qdrant', () => { describe('chroma', () => { it('search flattens per-query arrays', async () => { - const { fetch } = mockFetch({ + const { fetch } = mockChromaFetch({ ids: [['a', 'b']], documents: [['first', 'second']], metadatas: [[{ x: 1 }, { x: 2 }]], @@ -130,10 +140,10 @@ describe('chroma', () => { }) it('delete posts ids', async () => { - const { fetch, calls } = mockFetch({}) + const { fetch, calls } = mockChromaFetch({}) const store = chroma({ url: 'https://chroma', collection: 'c', fetch }) await store.delete!(['a']) - expect(calls[0]!.url).toContain('/delete') + expect(calls[1]!.url).toContain('/delete') }) }) diff --git a/readme-standard-v1.json b/readme-standard-v1.json index 9ca939da..0c8262d2 100644 --- a/readme-standard-v1.json +++ b/readme-standard-v1.json @@ -998,7 +998,7 @@ "docs/STABILITY.md", "scripts/readme-fixtures.test.mjs" ], - "sourceHash": "sha256:706d524bdd5fbd9bb750dd9df4bb7c8ac21ee76f99c0b8a60805d71f329c3644" + "sourceHash": "sha256:0231aaac8184e6652431e2ce6fab509552c503d72bd28dd165dd248537bafbf7" }, "exceptions": [] }, From 788f6d333c9c72dd0e1460f1a4534dd8326f5b41 Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Mon, 10 Aug 2026 21:39:24 -0300 Subject: [PATCH 2/2] fix(memory): avoid polynomial URL normalization --- packages/memory/src/vector/chroma.ts | 4 +++- packages/memory/tests/chroma-v2.test.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/memory/src/vector/chroma.ts b/packages/memory/src/vector/chroma.ts index da0aa20c..8fcb0c15 100644 --- a/packages/memory/src/vector/chroma.ts +++ b/packages/memory/src/vector/chroma.ts @@ -45,7 +45,9 @@ async function call( export function chroma(config: ChromaConfig): VectorMemory { const defaultTopK = Math.max(1, config.topK ?? 10) - const resolvedConfig = { ...config, url: config.url.replace(/\/+$/, '') } + let urlEnd = config.url.length + while (urlEnd > 0 && config.url.charCodeAt(urlEnd - 1) === 47) urlEnd-- + const resolvedConfig = { ...config, url: config.url.slice(0, urlEnd) } const tenant = encodeURIComponent(config.tenant ?? 'default_tenant') const database = encodeURIComponent(config.database ?? 'default_database') const collection = encodeURIComponent(config.collection) diff --git a/packages/memory/tests/chroma-v2.test.ts b/packages/memory/tests/chroma-v2.test.ts index 95cc17b9..57283828 100644 --- a/packages/memory/tests/chroma-v2.test.ts +++ b/packages/memory/tests/chroma-v2.test.ts @@ -22,7 +22,7 @@ describe('chroma v2', () => { metadatas: [[{ topic: 'test' }]], distances: [[0.2]], }) - const store = chroma({ url: 'https://chroma.example/', collection: 'docs', fetch }) + const store = chroma({ url: 'https://chroma.example///', collection: 'docs', fetch }) await store.store([{ id: 'doc-1', content: 'hello', embedding: [0.1] }]) const result = await store.search([0.1])