Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/gentle-dragons-listen.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 16 additions & 1 deletion apps/docs-next/content/docs/data/memory/chroma.mdx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 |
Expand All @@ -20,6 +28,13 @@ const store = chroma({
| `collection` | `string` |
| `tenant` | `string?` |
| `database` | `string?` |
| `apiKey` | `string?` |
| `headers` | `Record<string, string>?` |
| `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

Expand Down
2 changes: 1 addition & 1 deletion packages/memory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
53 changes: 49 additions & 4 deletions packages/memory/src/vector/chroma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
topK?: number
fetch?: typeof globalThis.fetch
}
Expand All @@ -16,9 +24,12 @@ async function call<T>(
body?: unknown,
): Promise<T> {
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()
Expand All @@ -34,11 +45,45 @@ async function call<T>(

export function chroma(config: ChromaConfig): VectorMemory {
const defaultTopK = Math.max(1, config.topK ?? 10)
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)
const collectionsPath = `/api/v2/tenants/${tenant}/databases/${database}/collections`
let collectionIdPromise: Promise<string> | undefined

function resolveCollectionId(): Promise<string> {
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<string> {
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),
Expand All @@ -54,7 +99,7 @@ export function chroma(config: ChromaConfig): VectorMemory {
documents?: string[][]
metadatas?: Array<Array<Record<string, unknown>>>
distances?: number[][]
}>(config, 'POST', `/api/v1/collections/${config.collection}/query`, {
}>(resolvedConfig, 'POST', await collectionPath('query'), {
query_embeddings: [embedding],
n_results: topK,
})
Expand All @@ -74,7 +119,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 })
},
}
}
103 changes: 103 additions & 0 deletions packages/memory/tests/chroma-v2.test.ts
Original file line number Diff line number Diff line change
@@ -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/)
})
})
21 changes: 16 additions & 5 deletions packages/memory/tests/vector-extra.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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: [[{}, {}]],
Expand All @@ -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([])
Expand Down
16 changes: 13 additions & 3 deletions packages/memory/tests/vector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> })),
Expand Down Expand Up @@ -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 }]],
Expand All @@ -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')
})
})

Expand Down
2 changes: 1 addition & 1 deletion readme-standard-v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -998,7 +998,7 @@
"docs/STABILITY.md",
"scripts/readme-fixtures.test.mjs"
],
"sourceHash": "sha256:706d524bdd5fbd9bb750dd9df4bb7c8ac21ee76f99c0b8a60805d71f329c3644"
"sourceHash": "sha256:0231aaac8184e6652431e2ce6fab509552c503d72bd28dd165dd248537bafbf7"
},
"exceptions": []
},
Expand Down
Loading