import { CreateEmbeddingResponse } from 'openai/resources'; import { DyFM_AI_Provider } from '@futdevpro/fsm-dynamo/ai'; import { DyFM_Error } from '@futdevpro/fsm-dynamo'; import { DyNTS_LMStudio_Embedding_ControlService } from './lmstudio-embedding.control-service'; import { DyNTS_AI_CostEvent } from '../_models/interfaces/dynts-ai-cost-event.interface'; describe('| DyNTS_LMStudio_Embedding_ControlService', () => { const baseUrl: string = 'http://localhost:1234/v1'; const issuer: string = 'test-issuer'; /** Egy OpenAI-kompatibilis embeddings-válasz JSON-string-é csomagolva, fetch-mockhoz. */ function okResponse(embeddings: number[][], usage?: { prompt_tokens: number; total_tokens: number }): Response { const body: string = JSON.stringify({ object: 'list', data: embeddings.map((embedding, index) => ({ object: 'embedding', embedding: embedding, index: index })), usage: usage, }); return { ok: true, status: 200, text: () => Promise.resolve(body), } as Response; } /** Egy hibás (non-2xx) válasz fetch-mockhoz. */ function errorResponse(status: number, body: string): Response { return { ok: false, status: status, text: () => Promise.resolve(body), } as Response; } let fetchSpy: jasmine.Spy; beforeEach(() => { fetchSpy = spyOn(globalThis, 'fetch'); }); describe('| constructor', () => { it('| should throw a DyFM_Error if baseUrl is empty', () => { expect(() => new DyNTS_LMStudio_Embedding_ControlService({ baseUrl: '' })).toThrowMatching( (error: unknown) => error instanceof DyFM_Error, ); }); it('| should set provider to LocalAI', () => { const service: DyNTS_LMStudio_Embedding_ControlService = new DyNTS_LMStudio_Embedding_ControlService({ baseUrl: baseUrl }); expect(service.aiProvider).toBe(DyFM_AI_Provider.LocalAI); expect(service.capabilities.embeddings).toBe(true); }); }); describe('| createEmbeddings', () => { it('| should POST to ${baseUrl}/embeddings and return vectors in order', async () => { fetchSpy.and.returnValue(Promise.resolve(okResponse([[0.1, 0.2], [0.3, 0.4]]))); const service: DyNTS_LMStudio_Embedding_ControlService = new DyNTS_LMStudio_Embedding_ControlService({ baseUrl: baseUrl }); const result: number[][] | CreateEmbeddingResponse = await service.createEmbeddings({ texts: ['hello', 'world'], model: 'nomic-embed-text-v1.5', issuer: issuer, }); expect(result).toEqual([[0.1, 0.2], [0.3, 0.4]]); expect(fetchSpy).toHaveBeenCalledTimes(1); const url: string = fetchSpy.calls.mostRecent().args[0] as string; expect(url).toBe('http://localhost:1234/v1/embeddings'); const init: RequestInit = fetchSpy.calls.mostRecent().args[1] as RequestInit; expect(init.method).toBe('POST'); const sentBody: { model: string; input: string[] } = JSON.parse(init.body as string); expect(sentBody.model).toBe('nomic-embed-text-v1.5'); expect(sentBody.input).toEqual(['hello', 'world']); }); it('| should trim trailing slashes from baseUrl', async () => { fetchSpy.and.returnValue(Promise.resolve(okResponse([[0.5]]))); const service: DyNTS_LMStudio_Embedding_ControlService = new DyNTS_LMStudio_Embedding_ControlService({ baseUrl: 'http://localhost:1234/v1///', }); await service.createEmbeddings({ texts: ['x'], model: 'm', issuer: issuer }); expect(fetchSpy.calls.mostRecent().args[0]).toBe('http://localhost:1234/v1/embeddings'); }); it('| should add a Bearer header when apiKey is provided', async () => { fetchSpy.and.returnValue(Promise.resolve(okResponse([[0.5]]))); const service: DyNTS_LMStudio_Embedding_ControlService = new DyNTS_LMStudio_Embedding_ControlService({ baseUrl: baseUrl, apiKey: 'secret-token', }); await service.createEmbeddings({ texts: ['x'], model: 'm', issuer: issuer }); const init: RequestInit = fetchSpy.calls.mostRecent().args[1] as RequestInit; const headers: { [key: string]: string } = init.headers as { [key: string]: string }; expect(headers.Authorization).toBe('Bearer secret-token'); }); it('| should throw a DyFM_Error on a non-ok HTTP response', async () => { fetchSpy.and.returnValue(Promise.resolve(errorResponse(500, 'boom'))); const service: DyNTS_LMStudio_Embedding_ControlService = new DyNTS_LMStudio_Embedding_ControlService({ baseUrl: baseUrl }); await expectAsync( service.createEmbeddings({ texts: ['x'], model: 'm', issuer: issuer }), ).toBeRejectedWith(jasmine.any(DyFM_Error)); }); it('| should throw a DyFM_Error if the vector count does not match the text count', async () => { fetchSpy.and.returnValue(Promise.resolve(okResponse([[0.1]]))); const service: DyNTS_LMStudio_Embedding_ControlService = new DyNTS_LMStudio_Embedding_ControlService({ baseUrl: baseUrl }); await expectAsync( service.createEmbeddings({ texts: ['a', 'b'], model: 'm', issuer: issuer }), ).toBeRejectedWith(jasmine.any(DyFM_Error)); }); it('| should emit an embedding-batch cost-event with provider lm-studio', async () => { fetchSpy.and.returnValue(Promise.resolve(okResponse([[0.1], [0.2]], { prompt_tokens: 7, total_tokens: 7 }))); const events: DyNTS_AI_CostEvent[] = []; const service: DyNTS_LMStudio_Embedding_ControlService = new DyNTS_LMStudio_Embedding_ControlService({ baseUrl: baseUrl, onCostEvent: (event: DyNTS_AI_CostEvent) => events.push(event), }); await service.createEmbeddings({ texts: ['a', 'b'], model: 'm', issuer: issuer }); expect(events.length).toBe(1); expect(events[0].callType).toBe('embedding-batch'); expect(events[0].provider).toBe('lm-studio'); expect(events[0].model).toBe('m'); expect(events[0].tokensUsed.input).toBe(7); expect(events[0].issuer).toBe(issuer); }); it('| should estimate tokens when the endpoint provides no usage', async () => { fetchSpy.and.returnValue(Promise.resolve(okResponse([[0.1]]))); const events: DyNTS_AI_CostEvent[] = []; const service: DyNTS_LMStudio_Embedding_ControlService = new DyNTS_LMStudio_Embedding_ControlService({ baseUrl: baseUrl, onCostEvent: (event: DyNTS_AI_CostEvent) => events.push(event), }); await service.createEmbeddings({ texts: ['abcdefgh'], model: 'm', issuer: issuer }); // 8 chars / 4 = 2 estimated tokens. expect(events[0].tokensUsed.input).toBe(2); }); }); describe('| createEmbedding (single)', () => { it('| should emit an embedding-single cost-event and return a single vector', async () => { fetchSpy.and.returnValue(Promise.resolve(okResponse([[0.9, 0.8, 0.7]], { prompt_tokens: 3, total_tokens: 3 }))); const events: DyNTS_AI_CostEvent[] = []; const service: DyNTS_LMStudio_Embedding_ControlService = new DyNTS_LMStudio_Embedding_ControlService({ baseUrl: baseUrl, onCostEvent: (event: DyNTS_AI_CostEvent) => events.push(event), }); const result: number[] | CreateEmbeddingResponse = await service.createEmbedding({ text: 'one', model: 'm', issuer: issuer, }); expect(result).toEqual([0.9, 0.8, 0.7]); expect(events[0].callType).toBe('embedding-single'); }); it('| should throw a DyFM_Error if text is empty', async () => { const service: DyNTS_LMStudio_Embedding_ControlService = new DyNTS_LMStudio_Embedding_ControlService({ baseUrl: baseUrl }); await expectAsync( service.createEmbedding({ text: '', model: 'm', issuer: issuer }), ).toBeRejectedWith(jasmine.any(DyFM_Error)); }); }); describe('| testConnection', () => { it('| should return true when the probe succeeds', async () => { fetchSpy.and.returnValue(Promise.resolve(okResponse([[0.1, 0.2]]))); const service: DyNTS_LMStudio_Embedding_ControlService = new DyNTS_LMStudio_Embedding_ControlService({ baseUrl: baseUrl }); expect(await service.testConnection(issuer)).toBe(true); }); it('| should return false (never throw) when the probe fails', async () => { fetchSpy.and.returnValue(Promise.reject(new Error('connection refused'))); const service: DyNTS_LMStudio_Embedding_ControlService = new DyNTS_LMStudio_Embedding_ControlService({ baseUrl: baseUrl }); expect(await service.testConnection(issuer)).toBe(false); }); }); describe('| getEmbeddingInfo', () => { it('| should report the LocalAI provider and the given model', () => { const service: DyNTS_LMStudio_Embedding_ControlService = new DyNTS_LMStudio_Embedding_ControlService({ baseUrl: baseUrl }); expect(service.getEmbeddingInfo('my-model')).toEqual({ provider: DyFM_AI_Provider.LocalAI, model: 'my-model' }); }); }); });