import { CreateEmbeddingResponse } from 'openai/resources'; import { DyFM_Error, DyFM_Log } from '@futdevpro/fsm-dynamo'; import { DyFM_AI_Provider, DyFM_AI_ProviderCapabilities, DyFM_AI_Config } from '@futdevpro/fsm-dynamo/ai'; import { DyFM_DAI_EmbeddingInfo } from '@futdevpro/fsm-dynamo/ai/document-ai'; import { DyNTS_AI_CostEventCallback } from '../_models/interfaces/dynts-ai-cost-event-callback.interface'; import { DyNTS_global_settings } from '../../../_collections/global-settings.const'; import { DyNTS_AI_Embedding_ServiceBase } from './ai-embedding.service-base'; /** * Az LM Studio embedding control-service config-set-je. A `baseUrl` az OpenAI-kompatibilis * lokális endpoint (pl. `http://localhost:1234/v1`), az `apiKey` opcionális (LM Studio default- * ban nem kér Bearer-t). Az `onCostEvent` a per-call cost-event sink (FR-002, BFR-AM-007). */ export interface DyNTS_LMStudio_Embedding_Settings { /** OpenAI-kompatibilis lokális embedding-endpoint base URL-je (pl. `http://localhost:1234/v1`). */ baseUrl: string; /** Opcionális Bearer token (LM Studio default-ban nem kér). */ apiKey?: string; /** Per-call cost-event callback (FR-002 / BFR-AM-007). Non-breaking: ha undefined, nincs emit. */ onCostEvent?: DyNTS_AI_CostEventCallback; } /** * `DyNTS_LMStudio_Embedding_ControlService` (BFR-AM-002) — OpenAI-kompatibilis **lokális** embedding * adapter `fetch`-en (Node 20 global `fetch`, így a Dynamo NEM hoz be provider-specifikus SDK-t a * lokális path-hoz). A FAM `FAM_LMStudio_EmbeddingProvider` workaround-ját emeli bedrock-szintre. * * A `baseUrl` + `modelId` config-ot a constructor-on kapja (mint a `DyNTS_OAI_Embedding_ControlService` * a `DyFM_OAI_Settings`-et). A `${baseUrl}/embeddings` POST-ra a `{ model, input }` body-t küldi, a * `data[].embedding` sorrendje == a `texts` sorrendje. * * **Cost-event (BFR-AM-007):** minden sikeres call után `emitCostEvent`-tel jelez (callType * `embedding-single` / `embedding-batch`, provider `'lm-studio'`). Lokális futás → nincs USD-költség, * a token-fogyasztás a `usage`-ből (ha az endpoint adja), különben becsült (4 char ≈ 1 token). */ export class DyNTS_LMStudio_Embedding_ControlService extends DyNTS_AI_Embedding_ServiceBase { /** A provider-azonosító a `LocalAI` enum-érték (LM Studio = lokális OpenAI-kompatibilis endpoint). */ readonly aiProvider: DyFM_AI_Provider = DyFM_AI_Provider.LocalAI; /** A cost-event provider-string (a `DyNTS_AI_CostEvent.provider` szabad-string mezőjéhez). */ protected readonly costProvider: string = 'lm-studio'; /** LM Studio (OpenAI-kompatibilis lokális) capability-k: csak embedding-et igénylünk innen. */ readonly capabilities: DyFM_AI_ProviderCapabilities = { chat: false, embeddings: true, imageGeneration: false, vision: false, audioGeneration: false, audioAnalysis: false, functionCalling: false, streaming: false, batchOperations: true, supportedModelTypes: [], }; /** Az OpenAI-kompatibilis endpoint base URL-je (trailing slash-mentes). */ protected baseUrl: string; /** Opcionális Bearer token. */ protected apiKey?: string; /** * @param set baseUrl + opc. apiKey + opc. onCostEvent. A `baseUrl` kötelező — ha üres, a * `DyNTS-LMS-ECS-CFG` hibát dobjuk (lokális endpoint nincs konfigurálva). */ constructor(set: DyNTS_LMStudio_Embedding_Settings) { super(); if (!set?.baseUrl?.trim().length) { throw new DyFM_Error({ ...this.getDefaultErrorSettings( 'constructor', new Error('LM Studio baseUrl is required (OpenAI-compatible local endpoint)'), 'DyNTS_LMStudio_Embedding_ControlService', ), errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-LMS-ECS-CFG`, }); } this.baseUrl = this.trimTrailingSlashes(set.baseUrl.trim()); this.apiKey = set.apiKey; if (set.onCostEvent) { this.onCostEvent = set.onCostEvent; } } /** * Az LM Studio client-et nem SDK-val, hanem `fetch`-csel hívjuk; a `setup` a `baseUrl` (és * opcionálisan az `apiKey`) átkonfigurálását teszi lehetővé (a base-szerződés egységessége miatt). */ setup(config: DyFM_AI_Config): void { if (config?.baseURL) { this.baseUrl = this.trimTrailingSlashes(config.baseURL); } if (config?.apiKey) { this.apiKey = config.apiKey; } } /** * Egy-szöveg embedding (a `createEmbeddings` egyelemű alakja). `fullResponse=true` esetén a * teljes (OpenAI-alakú) válasz-objektumot adja vissza, különben a tiszta `number[]`-t. */ async createEmbedding( set: { text: string; model: string; fullResponse?: boolean; issuer: string; } ): Promise { try { if (!set.text) { throw new DyFM_Error({ ...this.getDefaultErrorSettings('createEmbedding', new Error('text is required'), set.issuer), errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-LMS-ECS-CE1`, }); } const start: number = Date.now(); const response: CreateEmbeddingResponse = await this.callEmbeddingsEndpoint(set.model, set.text, set.issuer); const durationMs: number = Date.now() - start; this.emitLmStudioCostEvent('embedding-single', set.model, [ set.text ], response, durationMs, set.issuer); if (set.fullResponse) { return response; } return response.data[0].embedding; } catch (error) { if (error instanceof DyFM_Error) { throw error; } throw new DyFM_Error({ ...this.getDefaultErrorSettings('createEmbedding', error, set.issuer), errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-LMS-ECS-CE0`, }); } } /** * Batch-embedding az OpenAI-kompatibilis `/embeddings` végpontra. A `texts` sorrendje == a * visszaadott `number[][]` sorrendje. `fullResponse=true` esetén a teljes válasz-objektum. */ async createEmbeddings( set: { texts: string[]; model: string; fullResponse?: boolean; issuer: string; } ): Promise { try { if (!set.texts) { throw new DyFM_Error({ ...this.getDefaultErrorSettings('createEmbeddings', new Error('texts is required'), set.issuer), errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-LMS-ECS-CES1`, }); } const start: number = Date.now(); const response: CreateEmbeddingResponse = await this.callEmbeddingsEndpoint(set.model, set.texts, set.issuer); const durationMs: number = Date.now() - start; this.emitLmStudioCostEvent('embedding-batch', set.model, set.texts, response, durationMs, set.issuer); if (set.fullResponse) { return response; } return response.data.map((item) => item.embedding); } catch (error) { if (error instanceof DyFM_Error) { throw error; } throw new DyFM_Error({ ...this.getDefaultErrorSettings('createEmbeddings', error, set.issuer), errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-LMS-ECS-CES0`, }); } } /** * Embedding model-info (a `DyFM_DAI_EmbeddingInfo` szerződés szerint). A provider `LocalAI`, a * model a megadott azonosító (a lokális endpoint natív dimenzióját nem ismerjük előre). */ getEmbeddingInfo(model: string): DyFM_DAI_EmbeddingInfo { return { provider: DyFM_AI_Provider.LocalAI, model: model, }; } /** * Provider-elérhetőség: egy minimál embedding-próba (rövid token). Ha a hívás dob (endpoint down / * model hiányzik), `false`. Soha nem propagál hibát (try/catch → false). */ async testConnection(issuer: string): Promise { try { const vectors: number[][] | CreateEmbeddingResponse = await this.createEmbeddings({ texts: [ 'ping' ], model: this.defaultProbeModel(), fullResponse: false, issuer: issuer, }); return Array.isArray(vectors) && vectors.length === 1 && vectors[0].length > 0; } catch (error) { DyFM_Log.error('DyNTS_LMStudio_Embedding_ControlService', 'testConnection', 'Connection test failed', { error: error, issuer: issuer, }); return false; } } // ========================================================================= // belső: fetch + parse + cost-event // ========================================================================= /** * Az OpenAI-kompatibilis `/embeddings` POST-hívás `fetch`-csel. A `input` lehet egyetlen string * vagy string-tömb (mindkettőt az OpenAI-spec megengedi). A választ `CreateEmbeddingResponse`-alakra * normalizáljuk (a `data[].embedding`-eket kivonatoljuk). HTTP-/parse-hiba deskriptív üzenettel dobódik. */ protected async callEmbeddingsEndpoint( model: string, input: string | string[], issuer: string, ): Promise { const url: string = `${this.baseUrl}/embeddings`; const headers: { [key: string]: string } = { 'Content-Type': 'application/json' }; if (this.apiKey && this.apiKey.trim().length) { headers.Authorization = `Bearer ${this.apiKey.trim()}`; } const response: Response = await fetch(url, { method: 'POST', headers: headers, body: JSON.stringify({ model: model, input: input }), }); const rawText: string = await response.text(); if (!response.ok) { throw new Error( `LM Studio (OpenAI-compatible) embeddings HTTP ${response.status} | url=${url} | ` + `model=${model} | body=${this.snapshot(rawText)} | issuer=${issuer}`, ); } let parsed: unknown; try { parsed = JSON.parse(rawText); } catch { throw new Error( `LM Studio embeddings: non-JSON response | url=${url} | model=${model} | body=${this.snapshot(rawText)}`, ); } const expectedCount: number = Array.isArray(input) ? input.length : 1; return this.normalizeResponse(parsed, expectedCount, url, model); } /** * A nyers (OpenAI-kompatibilis) választ `CreateEmbeddingResponse`-alakra normalizálja: a `data[]`-ból * a `embedding` tömböket emeli ki (csak véges number-eket), a darabszámot ellenőrzi (1:1 a `texts`-szel), * és átveszi a `usage`-t ha van (a cost-event token-számához). Hibás/hiányzó mező → deskriptív hiba. */ protected normalizeResponse(json: unknown, expectedCount: number, url: string, modelId: string): CreateEmbeddingResponse { if (json === null || typeof json !== 'object' || Array.isArray(json)) { throw new Error(`LM Studio embeddings: invalid response object | url=${url} | model=${modelId}`); } const dataRaw: unknown = Reflect.get(json, 'data'); if (!Array.isArray(dataRaw)) { throw new Error(`LM Studio embeddings: missing data array | url=${url} | model=${modelId}`); } const data: CreateEmbeddingResponse['data'] = []; let index: number = 0; for (const item of dataRaw) { if (item === null || typeof item !== 'object' || Array.isArray(item)) { continue; } const embRaw: unknown = Reflect.get(item, 'embedding'); if (!Array.isArray(embRaw)) { continue; } const vec: number[] = embRaw.filter((x: unknown): x is number => typeof x === 'number' && Number.isFinite(x)); if (vec.length) { data.push({ object: 'embedding', embedding: vec, index: index }); index++; } } if (data.length !== expectedCount) { throw new Error( `LM Studio embeddings: expected ${expectedCount} vectors, got ${data.length} | url=${url} | model=${modelId}`, ); } const usageRaw: unknown = Reflect.get(json, 'usage'); const promptTokens: number = this.readNumber(usageRaw, 'prompt_tokens'); const totalTokens: number = this.readNumber(usageRaw, 'total_tokens'); return { object: 'list', model: modelId, data: data, usage: { prompt_tokens: promptTokens, total_tokens: totalTokens || promptTokens, }, }; } /** * Cost-event emit (BFR-AM-007). A token a `usage`-ből (ha az endpoint adta), különben becsült * (4 char ≈ 1 token, defenzív lokális heurisztika). A provider mindig `'lm-studio'` string. */ protected emitLmStudioCostEvent( callType: 'embedding-single' | 'embedding-batch', model: string, texts: string[], response: CreateEmbeddingResponse, durationMs: number, issuer: string, ): void { const reportedInput: number = response.usage?.prompt_tokens ?? 0; const input: number = reportedInput || this.estimateTokens(texts); const total: number = response.usage?.total_tokens || input; this.emitCostEvent({ callType: callType, provider: this.costProvider, model: model, tokensUsed: { input: input, total: total, }, durationMs: durationMs, issuer: issuer, timestamp: new Date(), }); } /** Becsült token-szám lokális endpoint-hoz (ha nincs `usage`): 4 char ≈ 1 token. */ protected estimateTokens(texts: string[]): number { let chars: number = 0; for (const text of texts) { chars += (text ?? '').length; } return Math.max(1, Math.ceil(chars / 4)); } /** A `testConnection` próba-modellje (env-override-olható, default a FAM-mintára). */ protected defaultProbeModel(): string { return process.env.LMSTUDIO_EMBEDDING_MODEL || 'nomic-embed-text-v1.5'; } /** Trailing `/`-ek levágása (kettős slash elkerülése a `/embeddings` join-nál). */ protected trimTrailingSlashes(value: string): string { return value.replace(/\/+$/, ''); } /** Rövid, biztonságos válasz-snapshot a hiba-üzenethez (max 300 char). */ protected snapshot(value: string): string { const trimmed: string = (value ?? '').slice(0, 300); return trimmed.length === 300 ? `${trimmed}…` : trimmed; } /** Egy number mező típusbiztos kiolvasása egy ismeretlen objektumból (hiány → 0). */ protected readNumber(obj: unknown, key: string): number { if (obj === null || typeof obj !== 'object') { return 0; } const value: unknown = Reflect.get(obj, key); return typeof value === 'number' && Number.isFinite(value) ? value : 0; } }