Files
goodbuddy/src/main/knowledge/openai-embedding-client.ts
T
mesalogo 076b3aaa7b feat: add managed Granite embedding models
GoodBuddy previously required an external OpenAI-compatible endpoint for vector retrieval. It can now install, verify, import, remove, and run the built-in Granite multilingual embedding model in an isolated local inference process, while retaining custom embedding connections and migrating existing settings.

The model connection UI now manages built-in and custom vector connections consistently, reports the actual retrieval path, preserves credentials across failed saves, and renders the vector tab synchronously. Related settings copy, switch styling, metadata typography, and renderer bundle guardrails are aligned with the resulting workflow.

Release note: 新增可管理的内置 Granite 多语言向量模型,支持本地下载、离线导入、连接切换与诊断;同时优化向量设置及相关界面的文案、开关和状态显示。
2026-08-23 11:26:44 +08:00

466 lines
14 KiB
TypeScript

import type { EmbeddingProvider } from './types'
import { embeddingProviderFingerprint } from './embedding-provider-key'
const MAX_INPUTS = 256
const MAX_BATCH_SIZE = 32
const MAX_INPUT_LENGTH = 16_000
const MAX_BATCH_CHARACTERS = 128_000
const MAX_MODEL_LENGTH = 256
const MAX_URL_LENGTH = 2_048
const MAX_DIMENSIONS = 8_192
const MAX_RESPONSE_BYTES = 16 * 1024 * 1024
const MIN_TIMEOUT_MS = 100
const DEFAULT_TIMEOUT_MS = 120_000
const MAX_TIMEOUT_MS = DEFAULT_TIMEOUT_MS
export interface OpenAIEmbeddingClientOptions {
endpoint: string
model: string
apiKey?: string
dimensions?: number
encodingRecipe?: Partial<OpenAIEmbeddingEncodingRecipe>
batchSize?: number
timeoutMs?: number
fetch?: typeof fetch
}
export type EmbeddingInputRole = 'query' | 'document'
export interface OpenAIEmbeddingEncodingRecipe {
recipeId: string
tokenizerDigest?: string
pooling: 'provider-managed'
normalization: 'provider-managed'
queryTemplate: string
documentTemplate: string
maximumSequenceTokens?: number
}
const DEFAULT_ENCODING_RECIPE: OpenAIEmbeddingEncodingRecipe = {
recipeId: 'symmetric',
pooling: 'provider-managed',
normalization: 'provider-managed',
queryTemplate: '{text}',
documentTemplate: '{text}'
}
function boundedInteger(
value: number,
field: string,
minimum: number,
maximum: number
): number {
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
throw new RangeError(
`${field} must be an integer between ${minimum} and ${maximum}`
)
}
return value
}
function requiredString(value: string, field: string, maximum: number): string {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new TypeError(`${field} must be a non-empty string`)
}
const normalized = value.trim()
if (normalized.length > maximum) {
throw new RangeError(`${field} must be at most ${maximum} characters`)
}
return normalized
}
function normalizedEndpoint(input: string): string {
const value = requiredString(input, 'endpoint', MAX_URL_LENGTH)
const url = new URL(value)
if (!['http:', 'https:'].includes(url.protocol)) {
throw new RangeError('endpoint must use HTTP or HTTPS')
}
url.hash = ''
return url.toString()
}
function endpointScope(endpoint: string): 'loopback' | 'network' {
const hostname = new URL(endpoint).hostname.toLowerCase()
return hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '[::1]'
? 'loopback'
: 'network'
}
function normalizedTemplate(value: string, field: string): string {
const template = requiredString(value, field, MAX_INPUT_LENGTH)
if (template.split('{text}').length !== 2) {
throw new RangeError(`${field} must contain exactly one {text} placeholder`)
}
return template
}
function normalizedEncodingRecipe(
input: Partial<OpenAIEmbeddingEncodingRecipe> | undefined
): OpenAIEmbeddingEncodingRecipe {
const recipe = { ...DEFAULT_ENCODING_RECIPE, ...input }
if (recipe.pooling !== 'provider-managed') {
throw new RangeError(
'encodingRecipe.pooling must be provider-managed'
)
}
if (recipe.normalization !== 'provider-managed') {
throw new RangeError(
'encodingRecipe.normalization must be provider-managed'
)
}
return {
recipeId: requiredString(recipe.recipeId, 'encodingRecipe.recipeId', 256),
...(recipe.tokenizerDigest
? {
tokenizerDigest: requiredString(
recipe.tokenizerDigest,
'encodingRecipe.tokenizerDigest',
256
)
}
: {}),
pooling: 'provider-managed',
normalization: 'provider-managed',
queryTemplate: normalizedTemplate(
recipe.queryTemplate,
'encodingRecipe.queryTemplate'
),
documentTemplate: normalizedTemplate(
recipe.documentTemplate,
'encodingRecipe.documentTemplate'
),
...(recipe.maximumSequenceTokens !== undefined
? {
maximumSequenceTokens: boundedInteger(
recipe.maximumSequenceTokens,
'encodingRecipe.maximumSequenceTokens',
1,
1_000_000
)
}
: {})
}
}
function embeddingAbortError(
requestSignal: AbortSignal,
timeoutError: Error
): Error {
if (requestSignal.reason === timeoutError) {
return timeoutError
}
const error = new Error('Embedding request was cancelled')
error.name = 'AbortError'
return error
}
async function readBoundedJson(response: Response): Promise<unknown> {
const declaredLength = response.headers.get('content-length')
if (
declaredLength !== null &&
Number(declaredLength) > MAX_RESPONSE_BYTES
) {
throw new RangeError('Embedding response is too large')
}
if (!response.body) {
throw new Error('Embedding response has no body')
}
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let length = 0
while (true) {
const result = await reader.read()
if (result.done) {
break
}
length += result.value.byteLength
if (length > MAX_RESPONSE_BYTES) {
await reader.cancel()
throw new RangeError('Embedding response is too large')
}
chunks.push(result.value)
}
const bytes = new Uint8Array(length)
let offset = 0
for (const chunk of chunks) {
bytes.set(chunk, offset)
offset += chunk.byteLength
}
try {
return JSON.parse(new TextDecoder().decode(bytes)) as unknown
} catch {
throw new Error('Embedding response is not valid JSON')
}
}
function validateVector(value: unknown, index: number): number[] {
if (
!Array.isArray(value) ||
value.length < 1 ||
value.length > MAX_DIMENSIONS
) {
throw new RangeError(`Embedding ${index} has invalid dimensions`)
}
let magnitudeSquared = 0
const vector = value.map((component) => {
if (typeof component !== 'number' || !Number.isFinite(component)) {
throw new TypeError('Embeddings must contain finite numbers')
}
magnitudeSquared += component * component
return component
})
if (!Number.isFinite(magnitudeSquared) || magnitudeSquared <= 0) {
throw new RangeError('Embeddings must have a finite non-zero norm')
}
return vector
}
function validateEmbeddings(value: unknown, expected: number): number[][] {
if (
typeof value !== 'object' ||
value === null ||
!('data' in value) ||
!Array.isArray(value.data) ||
value.data.length !== expected
) {
throw new Error('Embedding response has an invalid result count')
}
const vectors: Array<number[] | undefined> = Array.from({
length: expected
})
for (const [position, item] of value.data.entries()) {
if (
typeof item !== 'object' ||
item === null ||
!('embedding' in item)
) {
throw new Error(`Embedding response item ${position} is invalid`)
}
const index =
'index' in item && Number.isSafeInteger(item.index)
? (item.index as number)
: position
if (index < 0 || index >= expected || vectors[index]) {
throw new Error('Embedding response contains invalid indexes')
}
vectors[index] = validateVector(item.embedding, index)
}
const dimensions = vectors[0]?.length
if (
dimensions === undefined ||
vectors.some((vector) => vector?.length !== dimensions)
) {
throw new Error('Embeddings have inconsistent dimensions')
}
return vectors as number[][]
}
export class OpenAIEmbeddingClient implements EmbeddingProvider {
readonly provider = 'openai-compatible'
readonly model: string
readonly fingerprint: string
private readonly endpoint: string
private readonly dimensions?: number
private readonly encodingRecipe: OpenAIEmbeddingEncodingRecipe
private readonly apiKey?: string
private readonly batchSize: number
private readonly timeoutMs: number
private readonly transport: typeof fetch
constructor(options: OpenAIEmbeddingClientOptions) {
this.endpoint = normalizedEndpoint(options.endpoint)
this.model = requiredString(options.model, 'model', MAX_MODEL_LENGTH)
this.apiKey = options.apiKey?.trim() || undefined
this.dimensions =
options.dimensions === undefined
? undefined
: boundedInteger(options.dimensions, 'dimensions', 1, MAX_DIMENSIONS)
this.encodingRecipe = normalizedEncodingRecipe(options.encodingRecipe)
this.fingerprint = embeddingProviderFingerprint({
provider: this.provider,
endpoint: this.endpoint,
dataPath: {
kind: 'endpoint',
scope: endpointScope(this.endpoint)
},
model: this.model,
dimensions: this.dimensions,
encodingRecipe: this.encodingRecipe
})
this.batchSize = boundedInteger(
options.batchSize ?? 16,
'batchSize',
1,
MAX_BATCH_SIZE
)
this.timeoutMs = boundedInteger(
options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
'timeoutMs',
MIN_TIMEOUT_MS,
MAX_TIMEOUT_MS
)
this.transport = options.fetch ?? globalThis.fetch
if (typeof this.transport !== 'function') {
throw new Error('A Fetch API implementation is required')
}
}
async embed(
input: readonly string[],
signal?: AbortSignal
): Promise<number[][]> {
return this.embedRole(input, 'document', signal)
}
async embedQuery(
input: readonly string[],
signal?: AbortSignal
): Promise<number[][]> {
return this.embedRole(input, 'query', signal)
}
async embedDocuments(
input: readonly string[],
signal?: AbortSignal
): Promise<number[][]> {
return this.embedRole(input, 'document', signal)
}
async embedRole(
input: readonly string[],
role: EmbeddingInputRole,
signal?: AbortSignal
): Promise<number[][]> {
if (role !== 'query' && role !== 'document') {
throw new RangeError('role must be query or document')
}
if (!Array.isArray(input) || input.length < 1 || input.length > MAX_INPUTS) {
throw new RangeError(`input must contain between 1 and ${MAX_INPUTS} items`)
}
const normalized = input.map((item, index) => {
if (typeof item !== 'string' || item.length < 1) {
throw new TypeError(`input[${index}] must be a non-empty string`)
}
if (item.length > MAX_INPUT_LENGTH) {
throw new RangeError(
`input[${index}] must be at most ${MAX_INPUT_LENGTH} characters`
)
}
const template =
role === 'query'
? this.encodingRecipe.queryTemplate
: this.encodingRecipe.documentTemplate
const encoded = template.replace('{text}', item)
if (encoded.length > MAX_INPUT_LENGTH) {
throw new RangeError(
`encoded input[${index}] must be at most ${MAX_INPUT_LENGTH} characters`
)
}
return encoded
})
const embeddings: number[][] = []
let offset = 0
let expectedDimensions: number | undefined
while (offset < normalized.length) {
let end = offset
let characters = 0
while (end < normalized.length && end - offset < this.batchSize) {
const next = normalized[end]
if (next === undefined) {
break
}
if (end > offset && characters + next.length > MAX_BATCH_CHARACTERS) {
break
}
characters += next.length
end += 1
}
const vectors = await this.embedBatch(
normalized.slice(offset, end),
signal
)
for (const vector of vectors) {
if (expectedDimensions === undefined) {
expectedDimensions = vector.length
} else if (vector.length !== expectedDimensions) {
throw new Error('Embedding batches have inconsistent dimensions')
}
embeddings.push(vector)
}
offset = end
}
return embeddings
}
private async embedBatch(
input: readonly string[],
signal?: AbortSignal
): Promise<number[][]> {
const timeoutError = new Error('Embedding request timed out')
timeoutError.name = 'TimeoutError'
const timeoutController = new AbortController()
const timeoutId = setTimeout(() => {
timeoutController.abort(timeoutError)
}, this.timeoutMs)
const requestSignal = signal
? AbortSignal.any([signal, timeoutController.signal])
: timeoutController.signal
if (requestSignal.aborted) {
clearTimeout(timeoutId)
throw embeddingAbortError(requestSignal, timeoutError)
}
const headers: Record<string, string> = {
accept: 'application/json',
'content-type': 'application/json'
}
if (this.apiKey) {
headers.authorization = `Bearer ${this.apiKey}`
}
let response: Response | undefined
try {
response = await this.transport(this.endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: this.model,
input,
...(this.dimensions === undefined
? {}
: { dimensions: this.dimensions })
}),
redirect: 'error',
signal: requestSignal
})
if (!response.ok) {
throw new Error(
`Embedding request failed with HTTP ${response.status}`
)
}
const vectors = validateEmbeddings(
await readBoundedJson(response),
input.length
)
if (
this.dimensions !== undefined &&
vectors.some((vector) => vector.length !== this.dimensions)
) {
throw new Error('Embeddings do not match configured dimensions')
}
return vectors
} catch (error) {
if (requestSignal.aborted) {
throw embeddingAbortError(requestSignal, timeoutError)
}
if (response) {
throw error
}
throw new Error('Embedding request failed', { cause: error })
} finally {
clearTimeout(timeoutId)
}
}
}