Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

387 lines
14 KiB
TypeScript

/**
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { embed } from '@/lib/embeddings/client'
/**
* Exercises the orchestrator end-to-end against a mocked transport: batching,
* per-provider item caps, input ordering, dimension resolution, and retry.
* Every call passes an explicit `apiKey` so BYOK/env/rotating-pool resolution
* (which needs a database) is bypassed.
*/
const originalFetch = global.fetch
function jsonResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
statusText: String(status),
json: async () => body,
text: async () => JSON.stringify(body),
} as Response
}
function openAIBody(vectors: number[][], totalTokens = 5) {
return {
data: vectors.map((embedding) => ({ embedding })),
usage: { total_tokens: totalTokens },
}
}
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
fetchMock = vi.fn()
global.fetch = fetchMock as unknown as typeof fetch
})
afterEach(() => {
global.fetch = originalFetch
vi.restoreAllMocks()
})
describe('embed', () => {
it('sends one request for a small batch and returns its vectors', async () => {
fetchMock.mockResolvedValue(jsonResponse(openAIBody([[1, 2, 3]], 4)))
const result = await embed(['hello'], {
model: 'text-embedding-3-small',
apiKey: 'sk-test',
})
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('https://api.openai.com/v1/embeddings')
expect(JSON.parse((init as RequestInit).body as string)).toMatchObject({
input: ['hello'],
model: 'text-embedding-3-small',
})
expect(result.embeddings).toEqual([[1, 2, 3]])
expect(result.totalTokens).toBe(4)
expect(result.dimensions).toBe(1536)
expect(result.pricingId).toBe('text-embedding-3-small')
})
it("splits past Gemini's 100-item cap and preserves input order across batches", async () => {
const inputs = Array.from({ length: 250 }, (_, i) => `text-${i}`)
let cursor = 0
fetchMock.mockImplementation(async (_url, init) => {
const body = JSON.parse((init as RequestInit).body as string)
const count = body.requests.length
// Each vector encodes its global input index so ordering is verifiable.
const embeddings = Array.from({ length: count }, (_, i) => ({ values: [cursor + i] }))
cursor += count
return jsonResponse({ embeddings })
})
const result = await embed(inputs, {
model: 'gemini-embedding-001',
apiKey: 'g-test',
taskType: 'document',
})
expect(fetchMock).toHaveBeenCalledTimes(3)
const sentCounts = fetchMock.mock.calls.map(
([, init]) => JSON.parse((init as RequestInit).body as string).requests.length
)
expect(sentCounts).toEqual([100, 100, 50])
expect(result.embeddings).toHaveLength(250)
// Native dimensionality means no reduction, so values pass through unnormalized.
expect(result.embeddings.map((v) => v[0])).toEqual(inputs.map((_, i) => i))
})
it('estimates tokens when the provider omits usage', async () => {
fetchMock.mockResolvedValue(jsonResponse({ embeddings: [{ values: [1, 2] }] }))
const result = await embed(['some text to embed'], {
model: 'gemini-embedding-001',
apiKey: 'g-test',
})
expect(result.totalTokens).toBeGreaterThan(0)
})
it("bills Gemini on its reported token count rather than tiktoken's guess", async () => {
fetchMock.mockResolvedValue(
jsonResponse({
embeddings: [{ values: [1, 2] }],
usageMetadata: { promptTokenCount: 4321 },
})
)
const result = await embed(['some text to embed'], {
model: 'gemini-embedding-001',
apiKey: 'g-test',
})
expect(result.totalTokens).toBe(4321)
})
it('splits a long input list into several bounded requests', async () => {
fetchMock.mockImplementation(async (_url, init) => {
const body = JSON.parse((init as RequestInit).body as string)
return jsonResponse(openAIBody(body.input.map(() => [1])))
})
/**
* 40 inputs of roughly 500 tokens each exceed the batch target several times
* over, so they must be spread across requests rather than sent as one.
* Every input still has to arrive exactly once, in order.
*/
const inputs = Array.from({ length: 40 }, (_, i) => `${i} ${'word '.repeat(500)}`)
const result = await embed(inputs, { model: 'text-embedding-3-small', apiKey: 'sk-test' })
expect(fetchMock.mock.calls.length).toBeGreaterThan(1)
const sent = fetchMock.mock.calls.flatMap(
([, init]) => JSON.parse((init as RequestInit).body as string).input as string[]
)
expect(sent).toEqual(inputs)
expect(result.embeddings).toHaveLength(40)
})
it('keeps a long Cohere input whole rather than cutting it to the batch budget', async () => {
fetchMock.mockImplementation(async (_url, init) => {
const body = JSON.parse((init as RequestInit).body as string)
return jsonResponse({
embeddings: { float: body.texts.map(() => [1]) },
meta: { billed_units: { input_tokens: 1 } },
})
})
/**
* Cohere accepts 128k tokens in one text — far above the conservative
* default request budget. That budget floors at the per-input ceiling, or
* this input would be silently cut to a fraction of its length.
*/
const long = 'word '.repeat(30_000)
await embed([long], { model: 'embed-v4.0', apiKey: 'co-test' })
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string)
expect(body.texts[0]).toBe(long)
})
it('forwards a supported dimension reduction and reports it back', async () => {
fetchMock.mockResolvedValue(jsonResponse(openAIBody([[1, 2]])))
const result = await embed(['hello'], {
model: 'text-embedding-3-large',
apiKey: 'sk-test',
dimensions: 1024,
})
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string)
expect(body.dimensions).toBe(1024)
expect(result.dimensions).toBe(1024)
})
/**
* Regression: the resolved dimensionality is reported back to the caller but
* must not reach the wire unless the caller asked to reduce. `ada-002` and
* `mistral-embed` reject the parameter outright, so sending it populated with
* the native size made every unreduced request to those models a 400.
*/
it('omits the dimension field when no reduction was requested', async () => {
fetchMock.mockResolvedValue(jsonResponse(openAIBody([[1, 2]])))
const result = await embed(['hello'], {
model: 'text-embedding-ada-002',
apiKey: 'sk-test',
})
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string)
expect(body).not.toHaveProperty('dimensions')
expect(result.dimensions).toBe(1536)
})
it('omits the dimension field for a model without Matryoshka support', async () => {
fetchMock.mockResolvedValue(
jsonResponse({
data: [{ embedding: [1, 2], index: 0 }],
usage: { total_tokens: 5 },
})
)
const result = await embed(['hello'], { model: 'mistral-embed', apiKey: 'key-test' })
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string)
expect(body).not.toHaveProperty('output_dimension')
expect(result.dimensions).toBe(1024)
})
it('rejects an unsupported dimension before making a request', async () => {
await expect(
embed(['hello'], { model: 'text-embedding-3-small', apiKey: 'sk-test', dimensions: 999 })
).rejects.toThrow(/does not support 999/)
expect(fetchMock).not.toHaveBeenCalled()
})
it('rejects an unknown model before making a request', async () => {
await expect(embed(['hello'], { model: 'nope', apiKey: 'sk-test' })).rejects.toThrow(
'Unsupported embedding model: nope'
)
expect(fetchMock).not.toHaveBeenCalled()
})
it('surfaces a non-retryable provider error with its status', async () => {
fetchMock.mockResolvedValue(jsonResponse({ error: 'bad key' }, 401))
await expect(
embed(['hello'], { model: 'text-embedding-3-small', apiKey: 'sk-bad' })
).rejects.toThrow(/Embedding API failed: 401/)
// 401 is not retryable, so exactly one attempt is made.
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('retries a rate-limited request and succeeds on a later attempt', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ error: 'slow down' }, 429))
.mockResolvedValueOnce(jsonResponse(openAIBody([[7, 8]])))
const result = await embed(['hello'], {
model: 'text-embedding-3-small',
apiKey: 'sk-test',
})
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(result.embeddings).toEqual([[7, 8]])
})
it('marks a caller-supplied key as BYOK so Sim does not bill for it', async () => {
fetchMock.mockResolvedValue(jsonResponse(openAIBody([[1]])))
const result = await embed(['hello'], {
model: 'text-embedding-3-small',
apiKey: 'sk-user-owned',
})
expect(result.isBYOK).toBe(true)
})
/**
* `batchByTokenLimit` truncates any single text above the limit it is given,
* so the limit has to be the selected model's own. One shared constant sent
* oversized input to the models with a lower ceiling and silently dropped
* content the models with a higher one would have accepted.
*/
describe('per-model token limits', () => {
it("truncates against Gemini's lower ceiling rather than a shared constant", async () => {
fetchMock.mockResolvedValue(jsonResponse({ embeddings: [{ values: [1] }] }))
// ~10k tokens: over Gemini's 2048 ceiling, but under the old 8000 constant,
// so this used to reach the provider whole and come back a 502.
const long = 'word '.repeat(8000)
await embed([long], { model: 'gemini-embedding-001', apiKey: 'g-test' })
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string)
const sent = body.requests[0].content.parts[0].text
expect(sent.length).toBeLessThan(long.length)
})
it("keeps text intact up to Cohere's much higher ceiling", async () => {
fetchMock.mockResolvedValue(
jsonResponse({ embeddings: { float: [[1]] }, meta: { billed_units: { input_tokens: 9 } } })
)
// Over the old 8000 constant, well under Cohere's 128k, so it must survive.
const long = 'word '.repeat(8000)
await embed([long], { model: 'embed-v4.0', apiKey: 'c-test' })
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string)
expect(body.texts[0]).toBe(long)
})
})
/**
* The knowledge-base path rewrites resolved-secret plaintext back to
* placeholders before inputs reach a provider. The block path projects
* earlier, at the tool's HTTP hop, and passes null here so the substitution
* does not run twice over already-projected content.
*/
describe('resolved-secret projection', () => {
it('sends projected inputs, not the originals', async () => {
fetchMock.mockResolvedValue(jsonResponse(openAIBody([[1], [2]])))
await embed(['token is sk-live-123', 'harmless'], {
model: 'text-embedding-3-small',
apiKey: 'sk-test',
projectInputs: (values) => values.map((v) => v.replace('sk-live-123', '{{API_KEY}}')),
})
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string)
expect(body.input).toEqual(['token is {{API_KEY}}', 'harmless'])
expect(JSON.stringify(body)).not.toContain('sk-live-123')
})
it('leaves inputs untouched when the caller passes null', async () => {
fetchMock.mockResolvedValue(jsonResponse(openAIBody([[1]])))
await embed(['already projected'], {
model: 'text-embedding-3-small',
apiKey: 'sk-test',
projectInputs: null,
})
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string)
expect(body.input).toEqual(['already projected'])
})
it('estimates tokens from the projected values, not the originals', async () => {
// Gemini omits usage, so the token count is estimated from what was sent.
fetchMock.mockResolvedValue(jsonResponse({ embeddings: [{ values: [1, 2, 3] }] }))
const result = await embed(['x'.repeat(400)], {
model: 'gemini-embedding-001',
apiKey: 'key-test',
projectInputs: () => ['tiny'],
})
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string)
expect(body.requests[0].content.parts[0].text).toBe('tiny')
// 400 chars would estimate far higher; 'tiny' lands in single digits.
expect(result.totalTokens).toBeLessThan(10)
})
/**
* Projection changes length, and batching truncates whatever it measures.
* Batching the pre-projection text sized against a string that was never
* sent: a lengthening projection then exceeded the model's ceiling, and a
* shortening one discarded content that would have fit.
*/
it('batches the projected text, not the original', async () => {
fetchMock.mockResolvedValue(jsonResponse({ embeddings: [{ values: [1] }] }))
// Under Gemini's 2048 ceiling before projection, far over it after.
const short = 'secret'
await embed([short], {
model: 'gemini-embedding-001',
apiKey: 'g-test',
projectInputs: () => ['word '.repeat(8000)],
})
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string)
const sent = body.requests[0].content.parts[0].text
// Truncated against the model ceiling, so the lengthened text cannot go out whole.
expect(sent.length).toBeLessThan('word '.repeat(8000).length)
})
it('projects once even when the request is retried', async () => {
const projectInputs = vi.fn((values: readonly string[]) => values.map(() => 'projected'))
fetchMock
.mockResolvedValueOnce(jsonResponse({ error: 'rate limited' }, 429))
.mockResolvedValueOnce(jsonResponse(openAIBody([[1]])))
await embed(['secret'], {
model: 'text-embedding-3-small',
apiKey: 'sk-test',
projectInputs,
})
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(projectInputs).toHaveBeenCalledTimes(1)
})
})
})