feat: prepare GoodBuddy 0.8.0
This commit is contained in:
@@ -1,146 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OllamaEmbeddingClient } from './ollama-embedding-client'
|
||||
|
||||
describe('OllamaEmbeddingClient', () => {
|
||||
it('batches bounded embed requests and validates consistent vectors', async () => {
|
||||
const transport = vi.fn<typeof fetch>(async (_input, init) => {
|
||||
const body = JSON.parse(String(init?.body)) as {
|
||||
input: string[]
|
||||
model: string
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
embeddings: body.input.map((_, index) => [index + 1, 2, 3])
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
}
|
||||
)
|
||||
})
|
||||
const client = new OllamaEmbeddingClient({
|
||||
url: 'http://embedding.test:11434',
|
||||
model: 'synthetic-model',
|
||||
batchSize: 2,
|
||||
fetch: transport
|
||||
})
|
||||
|
||||
const result = await client.embed(['alpha', 'beta', 'gamma'])
|
||||
|
||||
expect(result).toEqual([
|
||||
[1, 2, 3],
|
||||
[2, 2, 3],
|
||||
[1, 2, 3]
|
||||
])
|
||||
expect(transport).toHaveBeenCalledTimes(2)
|
||||
expect(transport.mock.calls[0]?.[0]).toBe(
|
||||
'http://embedding.test:11434/api/embed'
|
||||
)
|
||||
expect(JSON.parse(String(transport.mock.calls[0]?.[1]?.body))).toEqual({
|
||||
model: 'synthetic-model',
|
||||
input: ['alpha', 'beta'],
|
||||
truncate: true
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects invalid inputs and malformed or oversized responses', async () => {
|
||||
expect(
|
||||
() =>
|
||||
new OllamaEmbeddingClient({
|
||||
url: 'file:///tmp/ollama.sock',
|
||||
model: 'model'
|
||||
})
|
||||
).toThrow('HTTP or HTTPS')
|
||||
|
||||
const malformed = new OllamaEmbeddingClient({
|
||||
url: 'https://embedding.test',
|
||||
model: 'model',
|
||||
fetch: async () =>
|
||||
new Response(JSON.stringify({ embeddings: [[1, Number.NaN]] }))
|
||||
})
|
||||
await expect(malformed.embed(['safe synthetic input'])).rejects.toThrow(
|
||||
'finite numbers'
|
||||
)
|
||||
|
||||
const oversized = new OllamaEmbeddingClient({
|
||||
url: 'https://embedding.test',
|
||||
model: 'model',
|
||||
fetch: async () =>
|
||||
new Response('ignored', {
|
||||
headers: { 'content-length': String(16 * 1024 * 1024 + 1) }
|
||||
})
|
||||
})
|
||||
await expect(oversized.embed(['safe synthetic input'])).rejects.toThrow(
|
||||
'too large'
|
||||
)
|
||||
await expect(
|
||||
malformed.embed(['x'.repeat(16_001)])
|
||||
).rejects.toThrow('at most 16000')
|
||||
})
|
||||
|
||||
it('honors caller cancellation without exposing request input', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const transport = vi.fn<typeof fetch>()
|
||||
const client = new OllamaEmbeddingClient({
|
||||
url: 'https://embedding.test',
|
||||
model: 'model',
|
||||
fetch: transport
|
||||
})
|
||||
|
||||
await expect(
|
||||
client.embed(['synthetic cancellation text'], controller.signal)
|
||||
).rejects.toBeDefined()
|
||||
expect(transport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.runIf(
|
||||
['1', 'true'].includes(
|
||||
process.env.GOODBUDDY_OLLAMA_INTEGRATION?.toLowerCase() ?? ''
|
||||
)
|
||||
)(
|
||||
'embeds synthetic text against an explicitly configured Ollama instance',
|
||||
async () => {
|
||||
const url = process.env.GOODBUDDY_OLLAMA_URL
|
||||
const model = process.env.GOODBUDDY_OLLAMA_MODEL
|
||||
if (!url || !model) {
|
||||
throw new Error(
|
||||
'GOODBUDDY_OLLAMA_URL and GOODBUDDY_OLLAMA_MODEL are required'
|
||||
)
|
||||
}
|
||||
const client = new OllamaEmbeddingClient({
|
||||
url,
|
||||
model,
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
const vectors = await client.embed([
|
||||
'A cat is sleeping peacefully on a sunny windowsill.',
|
||||
'A database transaction uses indexes and rollback logs.',
|
||||
'Where is the sleeping cat resting?'
|
||||
])
|
||||
const cosine = (left: number[], right: number[]): number => {
|
||||
const dot = left.reduce(
|
||||
(total, value, index) =>
|
||||
total + value * (right[index] ?? 0),
|
||||
0
|
||||
)
|
||||
const magnitude = (vector: number[]): number =>
|
||||
Math.sqrt(
|
||||
vector.reduce(
|
||||
(total, value) => total + value * value,
|
||||
0
|
||||
)
|
||||
)
|
||||
return dot / (magnitude(left) * magnitude(right))
|
||||
}
|
||||
expect(vectors).toHaveLength(3)
|
||||
expect(vectors[0]?.length).toBeGreaterThan(0)
|
||||
expect(vectors[1]?.length).toBe(vectors[0]?.length)
|
||||
expect(vectors[2]?.length).toBe(vectors[0]?.length)
|
||||
expect(cosine(vectors[2]!, vectors[0]!)).toBeGreaterThan(
|
||||
cosine(vectors[2]!, vectors[1]!)
|
||||
)
|
||||
},
|
||||
40_000
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OpenAIEmbeddingClient } from './openai-embedding-client'
|
||||
|
||||
describe('OpenAIEmbeddingClient', () => {
|
||||
it('sends bounded OpenAI-compatible requests with an optional bearer key', async () => {
|
||||
const transport = vi.fn<typeof fetch>(async (_input, init) => {
|
||||
const body = JSON.parse(String(init?.body)) as {
|
||||
input: string[]
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: body.input.map((_, index) => ({
|
||||
index,
|
||||
embedding: [index + 1, 2, 3]
|
||||
}))
|
||||
})
|
||||
)
|
||||
})
|
||||
const client = new OpenAIEmbeddingClient({
|
||||
endpoint: 'https://vectors.example/custom/embeddings',
|
||||
model: 'vendor/embed-large',
|
||||
apiKey: 'vector-secret',
|
||||
batchSize: 2,
|
||||
fetch: transport
|
||||
})
|
||||
|
||||
await expect(client.embed(['alpha', 'beta', 'gamma'])).resolves.toEqual([
|
||||
[1, 2, 3],
|
||||
[2, 2, 3],
|
||||
[1, 2, 3]
|
||||
])
|
||||
expect(transport).toHaveBeenCalledTimes(2)
|
||||
expect(transport.mock.calls[0]?.[0]).toBe(
|
||||
'https://vectors.example/custom/embeddings'
|
||||
)
|
||||
expect(transport.mock.calls[0]?.[1]?.headers).toMatchObject({
|
||||
authorization: 'Bearer vector-secret'
|
||||
})
|
||||
expect(JSON.parse(String(transport.mock.calls[0]?.[1]?.body))).toEqual({
|
||||
model: 'vendor/embed-large',
|
||||
input: ['alpha', 'beta']
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts unauthenticated endpoints and restores response index order', async () => {
|
||||
const transport = vi.fn<typeof fetch>(async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{ index: 1, embedding: [4, 5] },
|
||||
{ index: 0, embedding: [2, 3] }
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
const client = new OpenAIEmbeddingClient({
|
||||
endpoint: 'http://127.0.0.1:11434/v1/embeddings',
|
||||
model: 'nomic-embed-text',
|
||||
fetch: transport
|
||||
})
|
||||
|
||||
await expect(client.embed(['first', 'second'])).resolves.toEqual([
|
||||
[2, 3],
|
||||
[4, 5]
|
||||
])
|
||||
expect(transport.mock.calls[0]?.[1]?.headers).not.toHaveProperty(
|
||||
'authorization'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects unsafe endpoints and malformed vectors', async () => {
|
||||
expect(
|
||||
() =>
|
||||
new OpenAIEmbeddingClient({
|
||||
endpoint: 'https://user:secret@vectors.example/embeddings',
|
||||
model: 'model'
|
||||
})
|
||||
).toThrow('must not contain credentials')
|
||||
|
||||
const malformed = new OpenAIEmbeddingClient({
|
||||
endpoint: 'https://vectors.example/v1/embeddings',
|
||||
model: 'model',
|
||||
fetch: async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [{ index: 0, embedding: [1, Number.NaN] }]
|
||||
})
|
||||
)
|
||||
})
|
||||
await expect(malformed.embed(['safe synthetic input'])).rejects.toThrow(
|
||||
'finite numbers'
|
||||
)
|
||||
})
|
||||
})
|
||||
+95
-65
@@ -11,9 +11,10 @@ const MAX_RESPONSE_BYTES = 16 * 1024 * 1024
|
||||
const MIN_TIMEOUT_MS = 100
|
||||
const MAX_TIMEOUT_MS = 120_000
|
||||
|
||||
export interface OllamaEmbeddingClientOptions {
|
||||
url: string
|
||||
export interface OpenAIEmbeddingClientOptions {
|
||||
endpoint: string
|
||||
model: string
|
||||
apiKey?: string
|
||||
batchSize?: number
|
||||
timeoutMs?: number
|
||||
fetch?: typeof fetch
|
||||
@@ -44,18 +45,22 @@ function requiredString(value: string, field: string, maximum: number): string {
|
||||
return normalized
|
||||
}
|
||||
|
||||
function endpointFor(input: string): string {
|
||||
const value = requiredString(input, 'url', MAX_URL_LENGTH)
|
||||
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('url must use HTTP or HTTPS')
|
||||
throw new RangeError('endpoint must use HTTP or HTTPS')
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
throw new RangeError('url must not contain credentials')
|
||||
if (
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash
|
||||
) {
|
||||
throw new RangeError(
|
||||
'endpoint must not contain credentials, a query, or a fragment'
|
||||
)
|
||||
}
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
url.pathname = `${url.pathname.replace(/\/+$/u, '')}/api/embed`
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
@@ -65,10 +70,10 @@ async function readBoundedJson(response: Response): Promise<unknown> {
|
||||
declaredLength !== null &&
|
||||
Number(declaredLength) > MAX_RESPONSE_BYTES
|
||||
) {
|
||||
throw new RangeError('Ollama embedding response is too large')
|
||||
throw new RangeError('Embedding response is too large')
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error('Ollama embedding response has no body')
|
||||
throw new Error('Embedding response has no body')
|
||||
}
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
@@ -81,7 +86,7 @@ async function readBoundedJson(response: Response): Promise<unknown> {
|
||||
length += result.value.byteLength
|
||||
if (length > MAX_RESPONSE_BYTES) {
|
||||
await reader.cancel()
|
||||
throw new RangeError('Ollama embedding response is too large')
|
||||
throw new RangeError('Embedding response is too large')
|
||||
}
|
||||
chunks.push(result.value)
|
||||
}
|
||||
@@ -94,63 +99,86 @@ async function readBoundedJson(response: Response): Promise<unknown> {
|
||||
try {
|
||||
return JSON.parse(new TextDecoder().decode(bytes)) as unknown
|
||||
} catch {
|
||||
throw new Error('Ollama embedding response is not valid JSON')
|
||||
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 ||
|
||||
!('embeddings' in value) ||
|
||||
!Array.isArray(value.embeddings) ||
|
||||
value.embeddings.length !== expected
|
||||
!('data' in value) ||
|
||||
!Array.isArray(value.data) ||
|
||||
value.data.length !== expected
|
||||
) {
|
||||
throw new Error('Ollama embedding response has an invalid result count')
|
||||
throw new Error('Embedding response has an invalid result count')
|
||||
}
|
||||
let dimensions: number | undefined
|
||||
return value.embeddings.map((candidate, embeddingIndex) => {
|
||||
if (
|
||||
!Array.isArray(candidate) ||
|
||||
candidate.length < 1 ||
|
||||
candidate.length > MAX_DIMENSIONS
|
||||
) {
|
||||
throw new RangeError(
|
||||
`Ollama embedding ${embeddingIndex} has invalid dimensions`
|
||||
)
|
||||
}
|
||||
if (dimensions === undefined) {
|
||||
dimensions = candidate.length
|
||||
} else if (candidate.length !== dimensions) {
|
||||
throw new Error('Ollama embeddings have inconsistent dimensions')
|
||||
}
|
||||
let magnitudeSquared = 0
|
||||
const vector = candidate.map((component) => {
|
||||
if (typeof component !== 'number' || !Number.isFinite(component)) {
|
||||
throw new TypeError('Ollama embeddings must contain finite numbers')
|
||||
}
|
||||
magnitudeSquared += component * component
|
||||
return component
|
||||
})
|
||||
if (!Number.isFinite(magnitudeSquared) || magnitudeSquared <= 0) {
|
||||
throw new RangeError('Ollama embeddings must have a finite non-zero norm')
|
||||
}
|
||||
return vector
|
||||
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 OllamaEmbeddingClient implements EmbeddingProvider {
|
||||
readonly provider = 'ollama'
|
||||
export class OpenAIEmbeddingClient implements EmbeddingProvider {
|
||||
readonly provider = 'openai-compatible'
|
||||
readonly model: string
|
||||
readonly fingerprint: string
|
||||
private readonly endpoint: string
|
||||
private readonly apiKey?: string
|
||||
private readonly batchSize: number
|
||||
private readonly timeoutMs: number
|
||||
private readonly transport: typeof fetch
|
||||
|
||||
constructor(options: OllamaEmbeddingClientOptions) {
|
||||
this.endpoint = endpointFor(options.url)
|
||||
constructor(options: OpenAIEmbeddingClientOptions) {
|
||||
this.endpoint = normalizedEndpoint(options.endpoint)
|
||||
this.model = requiredString(options.model, 'model', MAX_MODEL_LENGTH)
|
||||
this.apiKey = options.apiKey?.trim() || undefined
|
||||
this.fingerprint = `${this.provider}:${this.endpoint}:${this.model}`
|
||||
this.batchSize = boundedInteger(
|
||||
options.batchSize ?? 16,
|
||||
@@ -206,13 +234,15 @@ export class OllamaEmbeddingClient implements EmbeddingProvider {
|
||||
characters += next.length
|
||||
end += 1
|
||||
}
|
||||
const batch = normalized.slice(offset, end)
|
||||
const vectors = await this.embedBatch(batch, signal)
|
||||
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('Ollama embedding batches have inconsistent dimensions')
|
||||
throw new Error('Embedding batches have inconsistent dimensions')
|
||||
}
|
||||
embeddings.push(vector)
|
||||
}
|
||||
@@ -230,32 +260,32 @@ export class OllamaEmbeddingClient implements EmbeddingProvider {
|
||||
}
|
||||
const timeout = AbortSignal.timeout(this.timeoutMs)
|
||||
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout
|
||||
const headers: Record<string, string> = {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
if (this.apiKey) {
|
||||
headers.authorization = `Bearer ${this.apiKey}`
|
||||
}
|
||||
let response: Response
|
||||
try {
|
||||
response = await this.transport(this.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
input,
|
||||
truncate: true
|
||||
}),
|
||||
headers,
|
||||
body: JSON.stringify({ model: this.model, input }),
|
||||
redirect: 'error',
|
||||
signal: requestSignal
|
||||
})
|
||||
} catch (error) {
|
||||
if (requestSignal.aborted) {
|
||||
const abortError = new Error('Ollama embedding request was cancelled')
|
||||
const abortError = new Error('Embedding request was cancelled')
|
||||
abortError.name = 'AbortError'
|
||||
throw abortError
|
||||
}
|
||||
throw new Error('Ollama embedding request failed', { cause: error })
|
||||
throw new Error('Embedding request failed', { cause: error })
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama embedding request failed with HTTP ${response.status}`)
|
||||
throw new Error(`Embedding request failed with HTTP ${response.status}`)
|
||||
}
|
||||
return validateEmbeddings(await readBoundedJson(response), input.length)
|
||||
}
|
||||
Reference in New Issue
Block a user