import { DyNTS_AI_Provider_ServiceBase } from './ai-provider.service-base'; import { DyFM_AI_CallSettings, DyFM_AI_Message, DyFM_AI_LLM_Response, DyFM_AI_MessageRole, DyFM_AI_Tool, DyFM_AI_ToolCall, DyFM_AI_ToolResult, DyFM_AI_ToolHandlers, DyFM_AI_ModelInfo, DyFM_AI_ModelRegistry_Util, DyFM_AI_ModelSettingsSchema, DyFM_AI_SettingConstraint, } from '@futdevpro/fsm-dynamo/ai'; import { DyFM_Error, DyFM_Error_Settings, DyFM_getLocalStackLocation, DyFM_Log, DyFM_Object } from '@futdevpro/fsm-dynamo'; import { DyFM_AI_GenericSelect_Input, DyFM_AI_GenericMultiSelect_Input, DyFM_AI_JSONKeysDescription_Input, DyFM_AI_JSONExactKeys_Input, DyFM_AI_Message_Input, } from '../_models/ai-input-interfaces'; import { Items } from 'openai/resources/conversations/items'; /** * Abstract base class for LLM services * Defines all methods that must be implemented by AI providers */ export abstract class DyNTS_AI_LLM_ServiceBase< T_AISettings extends DyFM_AI_CallSettings = DyFM_AI_CallSettings > extends DyNTS_AI_Provider_ServiceBase { /** Default settings for LLM calls */ abstract readonly defaultSettings: T_AISettings; /** Default model to use for LLM calls */ /* abstract readonly defaultModel: string; */ /** Provider-specific predefined requests */ abstract readonly predefinedRequests: any; _debugLog: boolean = false; get debugLog(): boolean { return this.defaultSettings?.debugLog ?? this._debugLog; } get defaultSystemPrompt(): string { return this.defaultSettings.systemPrompt; } get defaultModel(): string { return this.defaultSettings.useModel; } defaultLogReplacer: string = '...long-context...'; ////////////////////////////////////////////////////////////////////////////////////////// // FUNCTION CALLING (TOOL USE) — FR-047 // ////////////////////////////////////////////////////////////////////////////////////////// // Provider-agnostic agent-loop. The loop logic lives here ONCE (ported from the legacy // FDPNTS_GPT_ControlService.getAnswerWithTools); each provider only overrides // `callModelWithTools` (one provider turn) and `getModelRegistry` (capability honesty). // Tools are a REQUEST parameter (not on settings), per the FR-047 design. /** * A provider tool-kepes modell-registry-je (override providerenkent — pl. DyFM_OAI_Models). * Ures default → a modell tool-kepessege ismeretlen → a precheck elutasitja (honesty). */ protected getModelRegistry(): DyFM_AI_ModelInfo[] { return []; } /** * Ellenorzi, hogy a feloldott modell tamogatja-e a function calling-ot; ha nem, beszedes * hibaval elhasal MIELOTT barmilyen API-hivas tortenne (kritikus pl. a Local providernel). */ protected assertToolsSupported(modelId: string): void { if (!DyFM_AI_ModelRegistry_Util.modelSupportsTools(this.getModelRegistry(), modelId)) { throw new DyFM_Error({ message: `Model '${modelId}' does not support function calling (provider: ${this.aiProvider})`, userMessage: `The selected AI model does not support tools.`, errorCode: 'DyNTS-AILSB-TLC0', }); } } ////////////////////////////////////////////////////////////////////////////////////////// // PER-MODEL SETTINGS RECONCILER — FR-048 // ////////////////////////////////////////////////////////////////////////////////////////// /** * FR-048 reconciler — a resolved modell `settingsSchema`-ja szerint egyezteti a call-settings-et a * tenyleges provider-hivas ELOTT: DROPolja az `unsupported` kulcsokat (debug-log, SOHA nem csendben), * CLAMP-eli a `constraints` (min/max) szerint. Ismeretlen modell / schema-mentes registry-bejegyzes → * permisszi­v passthrough + log (best-effort, honesty). * * Ezzel altalanosul a FR-047 Phase-4 lokalis param-drop (Anthropic Opus 4.7+ temperature/topP/topK) es a * FR-047 `isReasoningModel` sampling-drop: a tudas mostantol a registry `settingsSchema`-jaban (SSoT) el, * nem provider-translator-onkent duplikalva. * * NON-BREAKING: ha a registry ures vagy a modellnek nincs schemaja, a bemenet valtozatlanul ter vissza — * igy a meglevo providerek viselkedese nem valtozik, amig a schema nincs feltoltve. * @param modelId - a feloldott modell azonositoja * @param settings - a hivas-settings (opcionalis) */ protected reconcileCallSettings(modelId: string, settings?: T_AISettings): T_AISettings { if (!settings) { return settings; } const modelInfo: DyFM_AI_ModelInfo = DyFM_AI_ModelRegistry_Util.findModelInfo(this.getModelRegistry(), modelId); const schema: DyFM_AI_ModelSettingsSchema = modelInfo?.settingsSchema; if (!schema) { if (this.debugLog) { DyFM_Log.info(`[FR-048] no settingsSchema for '${modelId}' — passthrough (best-effort)`); } return settings; } const reconciled: T_AISettings = { ...settings }; // A generikus `T_AISettings` csak OLVASASRA indexelheto (TS2862), ezert a mutaciot egy nem-generikus // aliason vegezzuk — UGYANARRA az objektumra mutat, igy cast nelkul is tipus-helyes marad. const mutable: Record = reconciled; for (const key of schema.unsupported ?? []) { if (mutable[key] !== undefined) { if (this.debugLog) { DyFM_Log.info(`[FR-048] drop unsupported '${key}' for model '${modelId}'`); } delete mutable[key]; } } const constraints: Record = schema.constraints ?? {}; for (const key of Object.keys(constraints)) { const constraint: DyFM_AI_SettingConstraint = constraints[key]; const value: unknown = mutable[key]; if (typeof value !== 'number') { continue; } let next: number = value; if (typeof constraint.min === 'number' && next < constraint.min) { next = constraint.min; } if (typeof constraint.max === 'number' && next > constraint.max) { next = constraint.max; } if (next !== value) { if (this.debugLog) { DyFM_Log.info(`[FR-048] clamp '${key}' ${value} → ${next} for model '${modelId}'`); } mutable[key] = next; } } return reconciled; } /** * GPT valasz Function Calling Agent Tool-okkal — provider-agnosztikus agent-loop. * @description A `tools` definiciok + `toolHandlers` alapjan tool-loop-ot futtat: hivja a * modellt (callModelWithTools) → ha a valaszban tool-call van, lefuttatja a regisztralt * handler-t (runToolCall, never-throw) es az eredmenyt visszafuzi → ismetli, amig a model * vegso (tool-call nelkuli) valaszt ad, vagy a maxIterations limitet eleri. A tool-ok a * REQUEST-parameterben jonnek, nem a settings-ben. */ async requestWithTools( set: { conversation: DyFM_AI_Message[]; tools: DyFM_AI_Tool[]; toolHandlers: DyFM_AI_ToolHandlers; settings?: T_AISettings; issuer: string; maxIterations?: number; } ): Promise { const modelId: string = (set.settings?.useModel ?? this.defaultModel) as string; if (!modelId) { throw new DyFM_Error({ message: `No model configured for function calling (provider: ${this.aiProvider})`, userMessage: `No AI model is configured for tools.`, errorCode: 'DyNTS-AILSB-TLM0', }); } this.assertToolsSupported(modelId); return this.runToolLoop(set); } /** * Egy provider-kor a tool-loop-ban: elkuldi a beszelgetest + tool-okat, normalizalt valaszt ad. * @description Override-olando providerenkent (OpenAI / Anthropic / Google / …). A default * elhasal — egy provider, ami nem implementalja, tool-use-t nem tud kiszolgalni. */ protected async callModelWithTools( _set: { conversation: DyFM_AI_Message[]; tools: DyFM_AI_Tool[]; settings?: T_AISettings; issuer: string; } ): Promise { throw new DyFM_Error({ message: `callModelWithTools is not implemented for provider '${this.aiProvider}'`, userMessage: `Function calling is not available for this AI provider yet.`, errorCode: 'DyNTS-AILSB-TLN0', }); } /** * A provider-agnosztikus agent-loop torzse. A bemeno `conversation` ele a provider teszi a * system-message-et (callModelWithTools); a loop a tool-call/tool-result uzeneteket fuzi hozza. */ protected async runToolLoop( set: { conversation: DyFM_AI_Message[]; tools: DyFM_AI_Tool[]; toolHandlers: DyFM_AI_ToolHandlers; settings?: T_AISettings; issuer: string; maxIterations?: number; } ): Promise { const maxIterations: number = set.maxIterations ?? 8; const conversation: DyFM_AI_Message[] = [...set.conversation]; for (let iteration: number = 0; iteration < maxIterations; iteration++) { const response: DyFM_AI_LLM_Response = await this.callModelWithTools({ conversation: conversation, tools: set.tools, settings: set.settings, issuer: set.issuer, }); // nincs tool-hivas → ez a vegso valasz if (!response.toolCalls?.length) { return response; } // a model tool-hivo (assistant) uzenetet visszatesszuk a kontextusba conversation.push({ role: DyFM_AI_MessageRole.assistant, content: response.content ?? '', toolCalls: response.toolCalls, }); // minden tool-hivast lefuttatunk (never-throw), es az eredmenyt visszaadjuk a modellnek const results: DyFM_AI_ToolResult[] = await Promise.all( response.toolCalls.map((call: DyFM_AI_ToolCall) => this.runToolCall(call, set.toolHandlers)) ); results.forEach((result: DyFM_AI_ToolResult) => { conversation.push({ role: DyFM_AI_MessageRole.tool, content: result.content, toolCallId: result.toolCallId, }); }); } // a tool-loop nem konvergalt a limiten belul throw new DyFM_Error({ message: `Tool loop did not converge within ${maxIterations} iterations`, userMessage: `We encountered an error while running AI tools, please contact the responsible development team.`, errorCode: 'DyNTS-AILSB-TL0', }); } /** * Egy tool-hivas lefuttatasa a regisztralt handler-rel. SOHA nem dob — a tool-hiba string-kent * megy vissza a modellnek (hogy korrigalhasson), hianyzo handler eseten is informativ uzenet * (soha nem [object Object]). */ protected async runToolCall( call: DyFM_AI_ToolCall, toolHandlers: DyFM_AI_ToolHandlers ): Promise { const handler = toolHandlers[call.name]; if (!handler) { return { toolCallId: call.id, content: `ERROR: no handler registered for tool '${call.name}'`, isError: true, }; } try { return { toolCallId: call.id, content: await handler(call.arguments), }; } catch (error) { return { toolCallId: call.id, content: `ERROR executing tool '${call.name}': ` + `${error instanceof Error ? error.message : String(error)}`, isError: true, }; } } // Core abstract methods /** * Call LLM with system and user messages */ /* abstract callLLM( systemMessage: string, userMessage: string, settings?: DyFM_AI_CallSettings, issuer?: string ): Promise; */ /** * Call LLM with message history */ /* abstract callLLMWithHistory( messages: DyFM_AI_Message[], settings?: DyFM_AI_CallSettings, issuer?: string ): Promise; */ /** * Call LLM and return raw response */ /* abstract callLLMRaw( messages: DyFM_AI_Message[], settings?: DyFM_AI_CallSettings, issuer?: string ): Promise; */ // Question methods (from OAI_LLM_ServiceBase) abstract requestSimpleMessage(set: DyFM_AI_Message_Input): Promise; abstract requestYesNo(set: DyFM_AI_Message_Input): Promise; abstract requestPercentage(set: DyFM_AI_Message_Input): Promise; /* abstract askSelectQuestion(set: DyFM_AI_ListSelect_Input): Promise; */ abstract requestSelect( set: DyFM_AI_GenericSelect_Input ): Promise; /* abstract askMultipleSelectQuestionWithOptions(set: DyFM_AI_MultiSelect_Input): Promise; */ abstract requestMultiselect( set: DyFM_AI_GenericMultiSelect_Input ): Promise; abstract requestJSON(set: DyFM_AI_Message_Input): Promise; abstract requestJSONQuestionWithKeysDescription( set: DyFM_AI_JSONKeysDescription_Input ): Promise; /* abstract askJSONQuestionWithExactKeys(set: DyFM_AI_JSONExactKeys_Input): Promise; */ abstract requestJSONWithExactKeys( set: DyFM_AI_JSONExactKeys_Input ): Promise; /* abstract sendMessage(set: DyFM_AI_SimpleMessage_Input): Promise; */ /* abstract requestStringList(set: DyFM_AI_Base_Input): Promise; */ abstract requestList(set: DyFM_AI_Message_Input): Promise; // Helper methods /* protected abstract getDefaultErrorSettings( method: string, error: any, issuer?: string ): DyFM_Error_Settings; */ /* protected abstract getTextListAsText(list: string[]): string; */ /* protected abstract logQuestion(set: DyFM_AI_Base_Input): void; */ protected convertAnswerToBoolean(answer: string): boolean { return answer.toUpperCase().includes(this.predefinedRequests.yesNo.upperCaseYes); } protected convertAnswerToNumber(answer: string, message: string): number { if (this.isAnswerValid(answer, message)) { return null; } if (isNaN(+answer)) { DyFM_Log.T_error( 'DyNTS_AI_LLMChat_ServiceBase.convertAnswerToNumber got an invalid answer', { question: message, answer: answer, } ); return null; } return +answer; } protected convertAnswerToSelectOption(answer: string, message: string, options: T[]): T { if (this.isAnswerValid(answer, message)) { return null; } answer = answer.toLocaleUpperCase(); const stringifiedOptions: string[] = this.stringifySelectOptions(options); for (const stringifiedItem of stringifiedOptions) { if (answer.includes(stringifiedItem.toLocaleUpperCase())) { const parsedItem: T | { unparsableResult: string } = DyFM_Object.safeParseJSON(stringifiedItem); if ((parsedItem as { unparsableResult: string }).unparsableResult) { DyFM_Log.T_error( 'DyNTS_AI_LLMChat_ServiceBase.convertAnswerToSelectOption got an invalid answer', { question: message, answer: answer, } ); return stringifiedItem as T; } else { return parsedItem as T; } } } return null; } protected convertAnswerToSelectOptions(answer: string, message: string, options: T[]): T[] { if (this.isAnswerValid(answer, message)) { return null; } const enrichedOptions: { stringifiedOption: string, parsedOption: T }[] = options.map( (option: T) => ({ stringifiedOption: this.stringifySelectOption(option), parsedOption: option }) ); const result: T[] = []; for (const item of enrichedOptions) { if (answer.includes(item.stringifiedOption)) { result.push(item.parsedOption); } } return result; } protected convertAnswerToJSON(answer: string, message: string): T | { unparsableResult: string } { if (this.isAnswerValid(answer, message)) { return { unparsableResult: answer }; } const parsedItem: T | { unparsableResult: string } = DyFM_Object.safeParseJSON(answer); if ((parsedItem as { unparsableResult: string }).unparsableResult) { DyFM_Log.T_error( 'DyNTS_AI_LLMChat_ServiceBase.convertAnswerToJSON got an invalid answer', { question: message, answer: answer, } ); return { unparsableResult: answer }; } return parsedItem as T; } protected convertAnswerToList(answer: string, message: string): T[] | { unparsableResult: string } { if (this.isAnswerValid(answer, message)) { return { unparsableResult: answer }; } // Check if safeParseJSON returns unparsableResult before calling safeParseList // because safeParseList doesn't properly handle unparsableResult const parsedCheck: T[] | { unparsableResult: string } = DyFM_Object.safeParseJSON(answer, true); if ((parsedCheck as { unparsableResult: string }).unparsableResult) { DyFM_Log.T_error( 'DyNTS_AI_LLMChat_ServiceBase.convertAnswerToList got an invalid answer', { question: message, answer: answer, } ); return { unparsableResult: answer }; } return DyFM_Object.safeParseList(answer); } protected stringifySelectOptions(options: T[]): string[] { return options.map(item => this.stringifySelectOption(item)); } protected stringifySelectOption(option: T): string { return JSON.stringify(option); } protected isAnswerValid(answer: string, message: string): boolean { if (!answer?.trim?.()?.length) { DyFM_Log.T_error( 'DyNTS_AI_LLMChat_ServiceBase.convertAnswerToSelectOption got an invalid answer', { question: message, answer: answer, } ); return true; } return false; } /** * olvasható mondatszerű-listaszerű formába teszi a listaelemeket * pl.: ['a', 'b', 'c'] -> '"a", "b" or "c"' */ protected getTextListAsText(list: string[]): string { list = list.filter(item => item?.trim()).map(item => `"${item}"`); /* list = list.map(item => item.toLocaleLowerCase()); */ list.push(list.pop() + ' or ' + list.pop()); return list.join(', '); } protected logQuestion( set: DyFM_AI_Message_Input ): void { if (set.settings?.debugLog ?? this._debugLog) { console.log('\n - ', set.message); } } protected getDefaultSystemMessage(settings: T_AISettings): DyFM_AI_Message { return { role: DyFM_AI_MessageRole.system, content: settings?.systemPrompt || this.defaultSystemPrompt, }; } protected validateConversation(conversation: DyFM_AI_Message[]): void { conversation.forEach((message: DyFM_AI_Message, index: number) => { if (!message.role) { throw new DyFM_Error({ message: `Message has no role at index ${index}`, additionalContent: { invalidMessage: message, conversation: conversation, } }); } }); conversation = conversation.filter(message => message.content); } protected logAnswer(answer: string): void { if (this._debugLog) { console.log(' - answer: ', answer); } } ////////////////////////////////////////////////////////////////////////////////////////// // LLM CHAT METHODS // ////////////////////////////////////////////////////////////////////////////////////////// protected logConversation( set: { conversation: DyFM_AI_Message[], debugLog?: boolean, /** this is used to readably replace too long contents to eg '...' in logs */ replaceThisInLog?: string, } ) { if (set.debugLog || this._debugLog) { DyFM_Log.info('Conversation log at', DyFM_getLocalStackLocation()); set.conversation.forEach(message => { console.log( ` - ${message.role}: ${message.content.replace(set.replaceThisInLog, this.defaultLogReplacer)}` ); }); } } }