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

289 lines
10 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { chunkArray } from '@sim/utils/helpers'
import { env, envNumber } from '@/lib/core/config/env'
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
import {
DEFAULT_EMBEDDING_MODEL,
type EmbeddingModelInfo,
getEmbeddingModelInfo,
hasApproximateTokenCount,
resolveDimensions,
} from '@/lib/embeddings/catalog'
import { resolveProviderKey } from '@/lib/embeddings/keys'
import { getAdapterFactory } from '@/lib/embeddings/providers'
import type {
EmbeddingProviderAdapter,
EmbeddingTaskType,
EmbedOptions,
EmbedResult,
} from '@/lib/embeddings/types'
import { isRetryableError, retryWithExponentialBackoff } from '@/lib/knowledge/documents/utils'
import { batchByTokenLimit, estimateTokenCount, truncateToTokenLimit } from '@/lib/tokenization'
const logger = createLogger('EmbeddingClient')
const MAX_CONCURRENT_BATCHES = envNumber(env.KB_CONFIG_CONCURRENCY_LIMIT, 50)
const EMBEDDING_REQUEST_TIMEOUT_MS = 60_000
/**
* Tokens this client aims to put in one request. Not a provider limit — every
* provider accepts at least this much, and OpenAI documents 300,000 — but the
* batch size the knowledge-base indexing path has run on in production.
*
* Kept here rather than raised to each provider's maximum so a request stays
* comfortably inside {@link EMBEDDING_REQUEST_TIMEOUT_MS}: a timed-out batch is
* retried three times, so large batches make a slow provider expensive to fail
* against. Raising this trades fewer round trips for costlier retries.
*/
const BATCH_TOKEN_TARGET = 8192
export class EmbeddingAPIError extends Error {
public status: number
constructor(message: string, status: number) {
super(message)
this.name = 'EmbeddingAPIError'
this.status = status
}
}
interface ResolvedProvider {
adapter: EmbeddingProviderAdapter
info: EmbeddingModelInfo
/** Model name as sent to the provider (an Azure deployment name when Azure is active). */
modelName: string
/** Dimensionality the request will produce, for reporting and billing. */
dimensions: number
isBYOK: boolean
}
/**
* Azure OpenAI takes over for OpenAI models when fully configured, but only
* when the caller has not supplied its own key. A user-pasted OpenAI key must
* always go to OpenAI.
*/
function resolveAzureOverride(info: EmbeddingModelInfo, model: string) {
if (info.provider !== 'openai') return null
const apiKey = env.AZURE_OPENAI_API_KEY
const endpoint = env.AZURE_OPENAI_ENDPOINT
const apiVersion = env.AZURE_OPENAI_API_VERSION
if (!apiKey || !endpoint || !apiVersion) return null
/**
* Azure deployment names default to the embedding model name when
* `KB_OPENAI_MODEL_NAME` is unset — this matches the pre-existing
* convention where deployments are named after the model they host.
*/
return { apiKey, endpoint, apiVersion, deployment: env.KB_OPENAI_MODEL_NAME || model }
}
async function resolveProvider(model: string, options: EmbedOptions): Promise<ResolvedProvider> {
const info = getEmbeddingModelInfo(model)
const dimensions = resolveDimensions(info, options.dimensions)
if (!options.apiKey) {
const azure = resolveAzureOverride(info, model)
if (azure) {
return {
adapter: getAdapterFactory('azure-openai')({
modelName: azure.deployment,
apiKey: azure.apiKey,
nativeDimensions: info.nativeDimensions,
endpoint: azure.endpoint,
apiVersion: azure.apiVersion,
}),
info,
modelName: azure.deployment,
dimensions,
isBYOK: false,
}
}
}
const { apiKey, isBYOK } = options.apiKey
? { apiKey: options.apiKey, isBYOK: true }
: await resolveProviderKey(info.provider, options.workspaceId)
return {
adapter: getAdapterFactory(info.provider)({
modelName: model,
apiKey,
nativeDimensions: info.nativeDimensions,
}),
info,
modelName: model,
dimensions,
isBYOK,
}
}
/** `inputs` are already projected and batched by {@link embed}. */
async function callEmbeddingAPI(
inputs: string[],
provider: ResolvedProvider,
taskType: EmbeddingTaskType,
/**
* The caller's explicit reduction, or undefined when none was requested. Kept
* distinct from `provider.dimensions` because a model without Matryoshka
* support rejects the parameter outright — sending it populated with the
* native size is a 400, not a no-op.
*/
requestedDimensions: number | undefined
): Promise<{ embeddings: number[][]; totalTokens: number }> {
return retryWithExponentialBackoff(
async () => {
const request = provider.adapter.buildRequest({
inputs,
taskType,
dimensions: requestedDimensions,
})
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), EMBEDDING_REQUEST_TIMEOUT_MS)
const response = await fetch(request.apiUrl, {
method: 'POST',
headers: request.headers,
body: JSON.stringify(request.body),
signal: controller.signal,
}).finally(() => clearTimeout(timeout))
if (!response.ok) {
const errorText = await response.text()
throw new EmbeddingAPIError(
`Embedding API failed: ${response.status} ${response.statusText} - ${errorText}`,
response.status
)
}
const json = await response.json()
const embeddings = request.parse(json)
/**
* Fallback for a response that carries no usage block. Estimated with the
* provider's own tokenizer, which is approximate for every non-OpenAI
* model — see {@link hasApproximateTokenCount}.
*/
const totalTokens =
request.parseTokens?.(json) ??
inputs.reduce(
(sum, text) => sum + estimateTokenCount(text, provider.info.tokenizerProvider).count,
0
)
return { embeddings, totalTokens }
},
{
maxRetries: 3,
initialDelayMs: 1000,
maxDelayMs: 10000,
retryCondition: (error: unknown) => {
if (error instanceof EmbeddingAPIError) {
return error.status === 429 || error.status >= 500
}
return isRetryableError(error)
},
}
)
}
/**
* Generates embeddings for a batch of texts with token-aware batching,
* per-provider item caps, bounded concurrency, and retry on transient failures.
*/
export async function embed(texts: string[], options: EmbedOptions): Promise<EmbedResult> {
const model = options.model ?? DEFAULT_EMBEDDING_MODEL
const taskType = options.taskType ?? 'document'
const provider = await resolveProvider(model, options)
/**
* Projected before batching, not after. The projector rewrites resolved-secret
* plaintext to placeholders, which changes length, and `batchByTokenLimit`
* measures and truncates whatever it is handed. Batching the pre-projection
* text would size against a different string than the one actually sent: a
* lengthening projection then exceeds the model's ceiling and the provider
* rejects it, and a shortening one discards content that would have fit.
*
* Doing it here also keeps projection to exactly once per call, so no retry
* can re-project already-projected content.
*/
const modelInputs = options.projectInputs ? options.projectInputs(texts) : texts
/**
* Each input is held to the model's own per-input ceiling, exactly as declared.
* One shared constant sent oversized input to models with a lower limit and
* discarded content models with a higher one accept; discounting the ceiling
* to absorb tokenizer error would reintroduce the second harm.
*
* Truncation happens here rather than inside `batchByTokenLimit` so it occurs
* once, against the right limit, and is always warned about: a shortened
* embedding input is otherwise indistinguishable from a good one, both to the
* caller and in the vector it produces.
*/
const ceiling = provider.info.maxInputTokens
const boundedInputs = modelInputs.map((text) => {
if (estimateTokenCount(text, provider.info.tokenizerProvider).count <= ceiling) return text
logger.warn('Embedding input exceeds the model token limit and will be truncated', {
model,
maxInputTokens: ceiling,
chars: text.length,
approximateTokenCount: hasApproximateTokenCount(provider.info),
})
return truncateToTokenLimit(text, ceiling, model)
})
/**
* How many tokens may share one request — a different limit from the per-input
* ceiling above, and the one that decides how many inputs go in a batch.
*
* Three bounds compose here:
*
* 1. {@link BATCH_TOKEN_TARGET} is what we actually aim for — an operational
* choice, not a provider limit (see its declaration for the reasoning).
* 2. A provider's documented summed-token cap, when it publishes one, is a
* hard ceiling the target can never exceed.
* 3. The per-input ceiling is a floor. A budget below it would make
* `batchByTokenLimit` truncate inputs the provider would have accepted —
* Cohere takes 128k tokens in one text, far above the target.
*/
const requestBudget = Math.max(
Math.min(provider.info.maxTokensPerRequest ?? BATCH_TOKEN_TARGET, BATCH_TOKEN_TARGET),
ceiling
)
const tokenBatches = batchByTokenLimit(boundedInputs, requestBudget, model)
const itemLimit = provider.adapter.maxItemsPerRequest
const batches = itemLimit
? tokenBatches.flatMap((batch) => chunkArray(batch, itemLimit))
: tokenBatches
const batchResults = await mapWithConcurrency(
batches,
MAX_CONCURRENT_BATCHES,
async (batch, i) => {
try {
return await callEmbeddingAPI(batch, provider, taskType, options.dimensions)
} catch (error) {
logger.error(`Failed to generate embeddings for batch ${i + 1}/${batches.length}:`, error)
throw error
}
}
)
const embeddings: number[][] = []
let totalTokens = 0
for (const batch of batchResults) {
for (const vector of batch.embeddings) {
embeddings.push(vector)
}
totalTokens += batch.totalTokens
}
return {
embeddings,
totalTokens,
isBYOK: provider.isBYOK,
modelName: provider.modelName,
pricingId: provider.info.pricingId,
dimensions: provider.dimensions,
}
}