@memberjunction/ai-prompts 6.1.4 → 6.2.0-edge.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1854 @@
1
+ /**
2
+ * @fileoverview Abstract base runner for MemberJunction AI model executions.
3
+ *
4
+ * Encapsulates modality-agnostic machinery:
5
+ * - Candidate building and selection
6
+ * - Credential resolution and failover
7
+ * - Execution bounds and timeout management
8
+ * - AIPromptRun entity lifecycle tracking and persistence
9
+ * - Retry and failover helpers
10
+ *
11
+ * @module @memberjunction/ai-prompts
12
+ * @author MemberJunction.com
13
+ */
14
+ import { BaseEntitySaveQueue, LogErrorEx, LogStatus, LogStatusEx, IsVerboseLoggingEnabled, Metadata } from '@memberjunction/core';
15
+ import { UUIDsEqual, NormalizeUUID } from '@memberjunction/global';
16
+ import { ResolvePromptRunUserID } from '@memberjunction/ai-core-plus';
17
+ import { ErrorAnalyzer, GetAIAPIKey } from '@memberjunction/ai';
18
+ import { AIEngine } from '@memberjunction/aiengine';
19
+ import { CredentialEngine } from '@memberjunction/credentials';
20
+ import { AIPromptTimeoutError } from './AIPromptTimeoutError.js';
21
+ /**
22
+ * Shared machinery for runners that invoke a model and record the call as an `MJ: AI Prompt Runs`
23
+ * row: candidate model/vendor selection, credential resolution, execution bounds (timeout and
24
+ * cancellation), failover helpers, and the prompt-run save queue.
25
+ *
26
+ * `AIPromptRunner`, the chat runner, is built on it. Subclasses declare the model type they run
27
+ * through {@link BaseModelRunner.RequiredModelType}.
28
+ */
29
+ export class BaseModelRunner {
30
+ constructor() {
31
+ this._provider = null;
32
+ /**
33
+ * Fire-and-forget AIPromptRun persistence. Prompt-run logging never blocks the execution path on a
34
+ * DB round-trip; the shared {@link BaseEntitySaveQueue} sequences saves for the SAME entity (the
35
+ * initial 'Running' INSERT always completes before the finalize UPDATE, and the finalize mutation
36
+ * runs INSIDE the post-INSERT task so a slow INSERT can never clobber the finalized row). Failures
37
+ * stay in this runner's structured log stream via the queue's `onError` hook.
38
+ */
39
+ this._promptRunQueue = new BaseEntitySaveQueue({
40
+ onError: (message) => this.logError(message, { category: 'PromptRunSave' }),
41
+ });
42
+ }
43
+ /**
44
+ * Optional metadata provider override. Callers should set
45
+ * `instance.Provider = providerToUse` before invoking run methods
46
+ * in multi-provider contexts. Falls back to the global default provider when unset.
47
+ */
48
+ get Provider() {
49
+ return this._provider ?? this._metadata;
50
+ }
51
+ set Provider(value) {
52
+ this._provider = value;
53
+ }
54
+ /**
55
+ * Performs robust validation of an API key
56
+ * @returns true if the API key is valid (not null, undefined, or empty/whitespace)
57
+ */
58
+ isValidAPIKey(apiKey) {
59
+ if (apiKey === undefined || apiKey === null) {
60
+ return false;
61
+ }
62
+ // Check if it's just whitespace
63
+ const trimmed = apiKey.trim();
64
+ return trimmed.length > 0;
65
+ }
66
+ /**
67
+ * Internal logging helper that wraps LogStatusEx with verbose control
68
+ * @param message The message to log
69
+ * @param verboseOnly Whether this is a verbose-only message
70
+ * @param params Optional prompt parameters for custom verbose check
71
+ */
72
+ logStatus(message, verboseOnly = false, params) {
73
+ if (verboseOnly) {
74
+ LogStatusEx({
75
+ message,
76
+ verboseOnly: true,
77
+ isVerboseEnabled: () => params?.verbose === true || IsVerboseLoggingEnabled()
78
+ });
79
+ }
80
+ else {
81
+ LogStatus(message);
82
+ }
83
+ }
84
+ /**
85
+ * The category {@link logError} records when the caller passes none. Override it so a runner's
86
+ * uncategorized errors are attributed to that runner rather than to the base.
87
+ */
88
+ get DefaultLogCategory() {
89
+ return 'BaseModelRunner';
90
+ }
91
+ /**
92
+ * Helper method for enhanced error logging with metadata
93
+ */
94
+ logError(error, options) {
95
+ let errorMessage = error instanceof Error ? error.message : error;
96
+ const errorObj = error instanceof Error ? error : undefined;
97
+ // Truncate extremely long error messages (like Groq's failed_generation JSON dumps)
98
+ // Only truncate if maxErrorLength is explicitly set
99
+ if (options?.maxErrorLength !== undefined && errorMessage.length > options.maxErrorLength) {
100
+ errorMessage = errorMessage.substring(0, options.maxErrorLength) + '... [truncated]';
101
+ }
102
+ const metadata = {
103
+ ...options?.metadata
104
+ };
105
+ // Add prompt information if available
106
+ if (options?.prompt) {
107
+ metadata.promptId = options.prompt.ID;
108
+ metadata.promptName = options.prompt.Name;
109
+ }
110
+ // Add model information if available
111
+ if (options?.model) {
112
+ metadata.modelId = options.model.ID;
113
+ metadata.modelName = options.model.Name;
114
+ }
115
+ LogErrorEx({
116
+ message: errorMessage,
117
+ error: errorObj,
118
+ category: options?.category || this.DefaultLogCategory,
119
+ severity: options?.severity || 'error',
120
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined
121
+ });
122
+ }
123
+ /**
124
+ * Checks if a model vendor is configured as an inference provider.
125
+ * Delegates to the memoized {@link AIEngine.IsInferenceProvider} helper so the
126
+ * "Inference Provider" vendor-type lookup happens once per engine load rather than on
127
+ * every candidate in every selection pass.
128
+ * @param modelVendor The model vendor to check
129
+ * @returns true if the vendor is an inference provider
130
+ */
131
+ IsInferenceProvider(modelVendor) {
132
+ return AIEngine.Instance.IsInferenceProvider(modelVendor);
133
+ }
134
+ /**
135
+ * Resolves credentials for AI model execution using a hierarchical resolution system.
136
+ *
137
+ * Resolution priority (highest to lowest):
138
+ * 1. Per-request override: params.credentialId
139
+ * 2. Prompt-Model specific: AIPromptModel.CredentialID
140
+ * 3. Model-Vendor specific: AIModelVendor.CredentialID
141
+ * 4. Vendor default: AIVendor.CredentialID
142
+ * 5. Legacy: params.apiKeys[] array
143
+ * 6. Legacy: AI_VENDOR_API_KEY__<DRIVER> environment variables
144
+ *
145
+ * IMPORTANT: When ANY credential ID is found (priorities 1-4), the system uses
146
+ * the Credentials path and ignores legacy methods (priorities 5-6).
147
+ *
148
+ * @param driverClass - The driver class name (e.g., 'OpenAILLM')
149
+ * @param promptId - The prompt ID for looking up AIPromptModel credentials
150
+ * @param modelId - The model ID for looking up AIPromptModel and AIModelVendor credentials
151
+ * @param vendorId - The vendor ID for looking up AIModelVendor and AIVendor credentials
152
+ * @param params - The prompt execution parameters containing contextUser and optional credentialId
153
+ * @returns The API key/configuration string to pass to the LLM constructor
154
+ */
155
+ async ResolveCredentialForExecution(driverClass, promptId, modelId, vendorId, params) {
156
+ const verbose = params.verbose === true || IsVerboseLoggingEnabled();
157
+ // Priority 1: Per-request override - no failover, explicit choice
158
+ if (params.credentialId) {
159
+ return await this.resolveCredentialById(params.credentialId, 'per-request override', params, verbose);
160
+ }
161
+ // Ensure CredentialEngine is configured for binding lookups
162
+ await CredentialEngine.Instance.Config(false, params.contextUser);
163
+ // Priority 2: PromptModel bindings (most specific) - with failover
164
+ if (promptId && modelId) {
165
+ const promptModel = AIEngine.Instance.PromptModels.find(pm => UUIDsEqual(pm.PromptID, promptId) && UUIDsEqual(pm.ModelID, modelId));
166
+ if (promptModel) {
167
+ const bindings = AIEngine.Instance.GetCredentialBindingsForTarget('PromptModel', promptModel.ID);
168
+ const result = await this.tryCredentialBindingsWithFailover(bindings, 'AICredentialBinding(PromptModel)', params, verbose);
169
+ if (result)
170
+ return result;
171
+ }
172
+ }
173
+ // Priority 3: ModelVendor bindings - with failover
174
+ if (modelId && vendorId) {
175
+ const modelVendor = AIEngine.Instance.ModelVendorsByModelID.get(NormalizeUUID(modelId))
176
+ ?.find(mv => UUIDsEqual(mv.VendorID, vendorId) && mv.Status === 'Active');
177
+ if (modelVendor) {
178
+ const bindings = AIEngine.Instance.GetCredentialBindingsForTarget('ModelVendor', modelVendor.ID);
179
+ const result = await this.tryCredentialBindingsWithFailover(bindings, 'AICredentialBinding(ModelVendor)', params, verbose);
180
+ if (result)
181
+ return result;
182
+ }
183
+ }
184
+ // Priority 4: Vendor bindings - with failover
185
+ if (vendorId) {
186
+ const bindings = AIEngine.Instance.GetCredentialBindingsForTarget('Vendor', vendorId);
187
+ const result = await this.tryCredentialBindingsWithFailover(bindings, 'AICredentialBinding(Vendor)', params, verbose);
188
+ if (result)
189
+ return result;
190
+ }
191
+ // Priority 5: Type-based default credential
192
+ // If the vendor declares a CredentialTypeID, try to find a default credential of that type
193
+ if (vendorId) {
194
+ const vendor = AIEngine.Instance.VendorsByID.get(NormalizeUUID(vendorId));
195
+ if (vendor?.CredentialTypeID) {
196
+ const defaultCredential = this.findDefaultCredentialByType(vendor.CredentialTypeID);
197
+ if (defaultCredential) {
198
+ const result = await this.tryResolveCredential(defaultCredential, 'type-based default', params, verbose);
199
+ if (result)
200
+ return result;
201
+ }
202
+ }
203
+ }
204
+ // No credential bindings found - fall back to legacy methods
205
+ if (verbose) {
206
+ this.logStatus(` Using legacy API key resolution for driver ${driverClass}`, true, params);
207
+ }
208
+ // Priority 6 & 7: Legacy apiKeys array and environment variables
209
+ return GetAIAPIKey(driverClass, params.apiKeys, verbose);
210
+ }
211
+ /**
212
+ * Attempts to resolve credentials from bindings with priority-based failover.
213
+ * Tries each binding in priority order until one succeeds.
214
+ */
215
+ async tryCredentialBindingsWithFailover(bindings, source, params, verbose) {
216
+ if (bindings.length === 0)
217
+ return null;
218
+ for (let i = 0; i < bindings.length; i++) {
219
+ const binding = bindings[i];
220
+ const credential = CredentialEngine.Instance.getCredentialById(binding.CredentialID);
221
+ if (!credential) {
222
+ if (verbose) {
223
+ this.logStatus(` ⚠️ Credential ${binding.CredentialID} not found (priority ${binding.Priority}), trying next...`, true, params);
224
+ }
225
+ continue;
226
+ }
227
+ const result = await this.tryResolveCredential(credential, `${source} priority ${binding.Priority}`, params, verbose, i < bindings.length - 1 // hasMoreBindings
228
+ );
229
+ if (result)
230
+ return result;
231
+ }
232
+ return null;
233
+ }
234
+ /**
235
+ * Attempts to resolve a single credential, returning null on failure for failover support.
236
+ */
237
+ async tryResolveCredential(credential, source, params, verbose, hasMoreBindings = false) {
238
+ try {
239
+ // Check if credential is active and not expired
240
+ if (!credential.IsActive) {
241
+ if (verbose) {
242
+ this.logStatus(` ⚠️ Credential "${credential.Name}" is inactive, trying next...`, true, params);
243
+ }
244
+ return null;
245
+ }
246
+ if (credential.ExpiresAt && new Date(credential.ExpiresAt) < new Date()) {
247
+ if (verbose) {
248
+ this.logStatus(` ⚠️ Credential "${credential.Name}" has expired, trying next...`, true, params);
249
+ }
250
+ return null;
251
+ }
252
+ // Resolve the credential values
253
+ const resolved = await CredentialEngine.Instance.getCredential(credential.Name, {
254
+ credentialId: credential.ID,
255
+ contextUser: params.contextUser,
256
+ subsystem: 'AIPromptRunner'
257
+ });
258
+ if (verbose) {
259
+ this.logStatus(` 🔐 Using credential from ${source}: "${credential.Name}"`, true, params);
260
+ }
261
+ return JSON.stringify(resolved.values);
262
+ }
263
+ catch (error) {
264
+ if (hasMoreBindings) {
265
+ // More bindings to try - log warning and continue
266
+ if (verbose) {
267
+ this.logStatus(` ⚠️ Failed to resolve credential "${credential.Name}" from ${source}: ${error instanceof Error ? error.message : String(error)}, trying next...`, true, params);
268
+ }
269
+ return null;
270
+ }
271
+ else {
272
+ // No more bindings - log error but still return null for legacy fallback
273
+ this.logError(error instanceof Error ? error : new Error(String(error)), {
274
+ category: 'CredentialResolution',
275
+ severity: 'warning',
276
+ metadata: {
277
+ credentialId: credential.ID,
278
+ credentialName: credential.Name,
279
+ source
280
+ },
281
+ maxErrorLength: params.maxErrorLength
282
+ });
283
+ return null;
284
+ }
285
+ }
286
+ }
287
+ /**
288
+ * Resolves a credential by its explicit ID (used for per-request override).
289
+ * This does not support failover since it's an explicit choice.
290
+ */
291
+ async resolveCredentialById(credentialId, source, params, verbose) {
292
+ await CredentialEngine.Instance.Config(false, params.contextUser);
293
+ const credential = CredentialEngine.Instance.getCredentialById(credentialId);
294
+ if (!credential) {
295
+ throw new Error(`Credential with ID ${credentialId} not found`);
296
+ }
297
+ const resolved = await CredentialEngine.Instance.getCredential(credential.Name, {
298
+ credentialId,
299
+ contextUser: params.contextUser,
300
+ subsystem: 'AIPromptRunner'
301
+ });
302
+ if (verbose) {
303
+ this.logStatus(` 🔐 Using credential from ${source}: "${credential.Name}"`, true, params);
304
+ }
305
+ return JSON.stringify(resolved.values);
306
+ }
307
+ /**
308
+ * Finds a default credential matching a specific credential type.
309
+ */
310
+ findDefaultCredentialByType(credentialTypeId) {
311
+ const credentials = CredentialEngine.Instance.Credentials;
312
+ return credentials.find(c => UUIDsEqual(c.CredentialTypeID, credentialTypeId) &&
313
+ c.IsDefault === true &&
314
+ c.IsActive === true &&
315
+ (!c.ExpiresAt || new Date(c.ExpiresAt) > new Date())) || null;
316
+ }
317
+ /**
318
+ * Checks if credentials are available for a given model-vendor combination.
319
+ * This is a pre-flight check used during model selection to determine which
320
+ * candidates have valid authentication configured.
321
+ *
322
+ * Checks the credential hierarchy:
323
+ * 1. Per-request override: params.credentialId
324
+ * 2. PromptModel bindings: AICredentialBinding WHERE BindingType='PromptModel'
325
+ * 3. ModelVendor bindings: AICredentialBinding WHERE BindingType='ModelVendor'
326
+ * 4. Vendor bindings: AICredentialBinding WHERE BindingType='Vendor'
327
+ * 5. Type-based default: Credential.IsDefault=1 matching AIVendor.CredentialTypeID
328
+ * 6. Legacy: params.apiKeys[] array
329
+ * 7. Legacy: AI_VENDOR_API_KEY__<DRIVER> environment variables
330
+ *
331
+ * @param driverClass - The driver class name (e.g., 'OpenAILLM')
332
+ * @param promptId - The prompt ID for looking up AIPromptModel bindings
333
+ * @param modelId - The model ID for looking up AIPromptModel and AIModelVendor bindings
334
+ * @param vendorId - The vendor ID for looking up AIModelVendor and AIVendor bindings
335
+ * @param params - The prompt execution parameters
336
+ * @returns true if credentials are available, false otherwise
337
+ */
338
+ HasCredentialsAvailable(driverClass, promptId, modelId, vendorId, params) {
339
+ // Priority 1: Per-request override
340
+ if (params?.credentialId) {
341
+ // Assume valid if credential ID is provided - will be validated at execution time
342
+ return true;
343
+ }
344
+ // Priority 2: PromptModel bindings
345
+ if (promptId && modelId) {
346
+ const promptModel = AIEngine.Instance.PromptModels.find(pm => UUIDsEqual(pm.PromptID, promptId) && UUIDsEqual(pm.ModelID, modelId));
347
+ if (promptModel && AIEngine.Instance.HasCredentialBindings('PromptModel', promptModel.ID)) {
348
+ return true;
349
+ }
350
+ }
351
+ // Priority 3: ModelVendor bindings
352
+ if (modelId && vendorId) {
353
+ const modelVendor = AIEngine.Instance.ModelVendorsByModelID.get(NormalizeUUID(modelId))
354
+ ?.find(mv => UUIDsEqual(mv.VendorID, vendorId) && mv.Status === 'Active');
355
+ if (modelVendor && AIEngine.Instance.HasCredentialBindings('ModelVendor', modelVendor.ID)) {
356
+ return true;
357
+ }
358
+ }
359
+ // Priority 4: Vendor bindings
360
+ if (vendorId) {
361
+ if (AIEngine.Instance.HasCredentialBindings('Vendor', vendorId)) {
362
+ return true;
363
+ }
364
+ }
365
+ // Priority 5: Type-based default credential
366
+ if (vendorId) {
367
+ const vendor = AIEngine.Instance.VendorsByID.get(NormalizeUUID(vendorId));
368
+ if (vendor?.CredentialTypeID) {
369
+ const defaultCredential = this.findDefaultCredentialByType(vendor.CredentialTypeID);
370
+ if (defaultCredential) {
371
+ return true;
372
+ }
373
+ }
374
+ }
375
+ // Priority 6 & 7: Legacy methods - check if API key is available
376
+ const apiKey = GetAIAPIKey(driverClass, params?.apiKeys, params?.verbose);
377
+ return this.isValidAPIKey(apiKey);
378
+ }
379
+ /**
380
+ * Resolves the runner's {@link RequiredModelType} name against `AIEngine.Instance.ModelTypes`
381
+ * (case-insensitive, trimmed) and returns its UUID. Callers compare models' `AIModelTypeID` with it
382
+ * using `UUIDsEqual`.
383
+ * Throws a descriptive error if the model type is not found in the engine metadata.
384
+ */
385
+ RequiredModelTypeID() {
386
+ const requiredType = this.RequiredModelType?.trim().toLowerCase();
387
+ if (!requiredType) {
388
+ throw new Error(`Runner requires a model type, but RequiredModelType is empty`);
389
+ }
390
+ const match = AIEngine.Instance.ModelTypes.find(mt => mt.Name?.trim().toLowerCase() === requiredType);
391
+ if (!match) {
392
+ throw new Error(`Required model type "${this.RequiredModelType}" was not found in AIEngine.Instance.ModelTypes`);
393
+ }
394
+ return match.ID;
395
+ }
396
+ /** The name of a model type for messages, or its ID when the engine doesn't know it. */
397
+ modelTypeName(modelTypeId) {
398
+ return AIEngine.Instance.ModelTypesByID.get(NormalizeUUID(modelTypeId))?.Name ?? modelTypeId;
399
+ }
400
+ /**
401
+ * Asserts that the prompt's configured model type matches the runner's required model type.
402
+ * If `prompt.AIModelTypeID` is null or undefined, does not throw (the runner's required type applies).
403
+ * If `prompt.AIModelTypeID` is set and does not match {@link RequiredModelTypeID}, throws a descriptive
404
+ * error naming the prompt, the prompt's configured type (resolved to name if possible), and the runner's
405
+ * required type.
406
+ */
407
+ AssertPromptMatchesRequiredType(prompt) {
408
+ if (!prompt.AIModelTypeID) {
409
+ return;
410
+ }
411
+ const requiredTypeId = this.RequiredModelTypeID();
412
+ if (!UUIDsEqual(prompt.AIModelTypeID, requiredTypeId)) {
413
+ const promptTypeName = this.modelTypeName(prompt.AIModelTypeID);
414
+ throw new Error(`Prompt "${prompt.Name}" requires model type "${promptTypeName}" (${prompt.AIModelTypeID}), but this runner requires "${this.RequiredModelType}" (${requiredTypeId})`);
415
+ }
416
+ }
417
+ /**
418
+ * Builds a unified, ordered list of model-vendor candidates based on all selection criteria.
419
+ * Uses a 3-phase approach to properly handle SelectionStrategy='Specific' with AIPromptModel priorities.
420
+ *
421
+ * Phase 1: Handle explicit model ID (highest priority)
422
+ * Phase 2: Check if SelectionStrategy='Specific' with AIPromptModel entries - use ONLY those with AIPromptModel priorities
423
+ * Phase 3: Use general selection strategy (fallback) - blended priorities from legacy behavior
424
+ *
425
+ * @param prompt - The AI prompt with selection criteria
426
+ * @param explicitModelId - Explicitly specified model ID (highest priority)
427
+ * @param configurationId - Configuration ID for filtering
428
+ * @param preferredVendorId - Preferred vendor ID
429
+ * @returns Ordered array of model-vendor candidates (highest priority first)
430
+ */
431
+ BuildModelVendorCandidates(prompt, explicitModelId, configurationId, preferredVendorId, verbose) {
432
+ this.AssertPromptMatchesRequiredType(prompt);
433
+ // PHASE 1: Handle explicit model ID (highest priority)
434
+ if (explicitModelId) {
435
+ return this.buildCandidatesForExplicitModel(explicitModelId, prompt, preferredVendorId);
436
+ }
437
+ // PHASE 2: SelectionStrategy='Specific' - Use explicit AIPromptModel configuration
438
+ if (prompt.SelectionStrategy === 'Specific') {
439
+ return this.buildCandidatesForSpecificStrategy(prompt, configurationId, verbose);
440
+ }
441
+ // PHASE 3: Build candidates with configuration-aware fallback hierarchy
442
+ // (SelectionStrategy='Default' or 'ByPower')
443
+ return this.buildCandidatesForGeneralSelection(prompt, configurationId, preferredVendorId, verbose);
444
+ }
445
+ /**
446
+ * PHASE 1: Build candidates for explicitly specified model ID.
447
+ * Returns candidates for the single model if it's active. Throws if the model is of a different type
448
+ * than the runner requires: the caller asked for that model by ID, so running a different one instead
449
+ * would be wrong, and an empty result would surface only a generic "no candidates" message.
450
+ */
451
+ buildCandidatesForExplicitModel(explicitModelId, prompt, preferredVendorId) {
452
+ const model = AIEngine.Instance.ModelsByID.get(NormalizeUUID(explicitModelId));
453
+ if (!model || !model.IsActive) {
454
+ return [];
455
+ }
456
+ // Check model type compatibility against runner's required model type
457
+ const requiredTypeId = this.RequiredModelTypeID();
458
+ if (!UUIDsEqual(model.AIModelTypeID, requiredTypeId)) {
459
+ throw new Error(`Model override "${model.Name}" is type "${this.modelTypeName(model.AIModelTypeID)}", but this runner requires "${this.RequiredModelType}"`);
460
+ }
461
+ const candidates = this.createCandidatesForModel(model, 20000, 'explicit', preferredVendorId);
462
+ candidates.sort((a, b) => b.priority - a.priority);
463
+ return candidates;
464
+ }
465
+ /**
466
+ * PHASE 2: Build candidates for 'Specific' selection strategy.
467
+ * Uses AIPromptModel configuration with clean ranking:
468
+ * 1. Config-matching models first (by priority DESC)
469
+ * 2. Then universal (null config) models (by priority DESC)
470
+ */
471
+ buildCandidatesForSpecificStrategy(prompt, configurationId, verbose) {
472
+ // Get all active AIPromptModel records for this prompt
473
+ const allPromptModels = AIEngine.Instance.PromptModels.filter(pm => UUIDsEqual(pm.PromptID, prompt.ID) && (pm.Status === 'Active' || pm.Status === 'Preview'));
474
+ // Filter by configuration matching rules
475
+ const promptModels = this.filterPromptModelsByConfiguration(allPromptModels, configurationId);
476
+ // Sort: config-specific before universal, then by priority DESC within each group
477
+ const sortedPromptModels = this.sortPromptModelsForSpecificStrategy(promptModels, configurationId);
478
+ // Build candidates maintaining order
479
+ const candidates = this.buildCandidatesFromPromptModels(sortedPromptModels, prompt);
480
+ // If RequireSpecificModels is true (or no candidates at all), enforce strict behavior
481
+ if (candidates.length === 0 && prompt.RequireSpecificModels) {
482
+ const configInfo = configurationId ? ` with configuration "${configurationId}"` : '';
483
+ throw new Error(`SelectionStrategy is 'Specific' but no valid AIPromptModel candidates found for prompt "${prompt.Name}"${configInfo}. ` +
484
+ `Please configure AIPromptModel records for this prompt.`);
485
+ }
486
+ // When RequireSpecificModels is false, append power-matched fallback candidates
487
+ // so that if none of the specific models have valid credentials, the system
488
+ // gracefully falls back to other available models at a similar power level.
489
+ if (!prompt.RequireSpecificModels) {
490
+ this.appendPowerMatchedFallbackCandidates(candidates, prompt, sortedPromptModels, verbose);
491
+ }
492
+ if (candidates.length === 0) {
493
+ const configInfo = configurationId ? ` with configuration "${configurationId}"` : '';
494
+ throw new Error(`SelectionStrategy is 'Specific' but no valid AIPromptModel candidates found for prompt "${prompt.Name}"${configInfo}. ` +
495
+ `Please configure AIPromptModel records for this prompt.`);
496
+ }
497
+ if (verbose) {
498
+ LogStatus(`Using SelectionStrategy='Specific' with ${sortedPromptModels.length} AIPromptModel entries, generated ${candidates.length} candidates`);
499
+ }
500
+ return candidates;
501
+ }
502
+ /**
503
+ * Appends fallback candidates from the global model pool, sorted by proximity to the
504
+ * average power rank of the originally configured models. This ensures that when
505
+ * specific models lack credentials, the fallback uses models of similar capability
506
+ * rather than defaulting to the most or least powerful available model.
507
+ *
508
+ * Fallback candidates are given lower priority than any specific candidate so
509
+ * configured models are always preferred when their credentials are available.
510
+ */
511
+ appendPowerMatchedFallbackCandidates(candidates, prompt, configuredPromptModels, verbose) {
512
+ // Compute target power rank from the configured models
513
+ const targetPowerRank = this.computeTargetPowerRank(configuredPromptModels);
514
+ const requiredTypeId = this.RequiredModelTypeID();
515
+ // Get all active models matching the runner's required model type, excluding already-present models
516
+ const existingModelIds = new Set(candidates.map(c => c.model.ID));
517
+ const fallbackPool = AIEngine.Instance.Models.filter(m => m.IsActive &&
518
+ !existingModelIds.has(m.ID) &&
519
+ UUIDsEqual(m.AIModelTypeID, requiredTypeId));
520
+ if (fallbackPool.length === 0)
521
+ return;
522
+ // Sort by proximity to the target power rank
523
+ const sorted = this.sortByPowerProximity(fallbackPool, targetPowerRank);
524
+ // Assign priorities below the lowest specific candidate
525
+ const lowestSpecificPriority = candidates.length > 0
526
+ ? Math.min(...candidates.map(c => c.priority))
527
+ : 1000;
528
+ const fallbackBasePriority = lowestSpecificPriority - 100;
529
+ sorted.forEach((model, index) => {
530
+ const modelCandidates = this.createCandidatesForModel(model, fallbackBasePriority - index * 10, 'power-match-fallback');
531
+ candidates.push(...modelCandidates);
532
+ });
533
+ if (verbose && sorted.length > 0) {
534
+ LogStatus(`Appended ${sorted.length} power-matched fallback models (target PowerRank: ${targetPowerRank}) ` +
535
+ `for prompt "${prompt.Name}" since RequireSpecificModels is false`);
536
+ }
537
+ }
538
+ /**
539
+ * Computes the target power rank from configured AIPromptModel records.
540
+ * Uses the weighted average (by priority) of the configured models' power ranks,
541
+ * so higher-priority models have more influence on the target.
542
+ * Falls back to simple average if priorities are all zero.
543
+ */
544
+ computeTargetPowerRank(promptModels) {
545
+ if (promptModels.length === 0)
546
+ return 0;
547
+ const modelsWithPower = promptModels
548
+ .map(pm => {
549
+ const model = AIEngine.Instance.ModelsByID.get(NormalizeUUID(pm.ModelID));
550
+ return { powerRank: model?.PowerRank ?? 0, priority: pm.Priority || 1 };
551
+ });
552
+ const totalWeight = modelsWithPower.reduce((sum, m) => sum + m.priority, 0);
553
+ if (totalWeight === 0) {
554
+ // All priorities are 0, use simple average
555
+ return Math.round(modelsWithPower.reduce((sum, m) => sum + m.powerRank, 0) / modelsWithPower.length);
556
+ }
557
+ const weightedSum = modelsWithPower.reduce((sum, m) => sum + m.powerRank * m.priority, 0);
558
+ return Math.round(weightedSum / totalWeight);
559
+ }
560
+ /**
561
+ * Sorts models by proximity to a target power rank (closest first).
562
+ * When two models are equidistant, the higher-powered one is preferred.
563
+ */
564
+ sortByPowerProximity(models, targetPowerRank) {
565
+ return [...models].sort((a, b) => {
566
+ const distA = Math.abs((a.PowerRank ?? 0) - targetPowerRank);
567
+ const distB = Math.abs((b.PowerRank ?? 0) - targetPowerRank);
568
+ if (distA !== distB)
569
+ return distA - distB; // Closer to target first
570
+ return (b.PowerRank ?? 0) - (a.PowerRank ?? 0); // Tie-break: higher power first
571
+ });
572
+ }
573
+ /**
574
+ * PHASE 3: Build candidates for general selection strategies ('Default' or 'ByPower').
575
+ * Uses configuration-aware fallback hierarchy with legacy blended priority calculation.
576
+ */
577
+ buildCandidatesForGeneralSelection(prompt, configurationId, preferredVendorId, verbose) {
578
+ const preferredVendorName = preferredVendorId ?
579
+ AIEngine.Instance.VendorsByID.get(NormalizeUUID(preferredVendorId))?.Name : undefined;
580
+ // Get prompt models for configuration
581
+ const promptModels = this.getPromptModelsForConfiguration(prompt, configurationId);
582
+ const candidates = [];
583
+ if (promptModels.length > 0) {
584
+ // Use prompt-specific models with blended priorities
585
+ this.addPromptSpecificCandidates(candidates, promptModels, preferredVendorId, prompt);
586
+ // Add configuration fallback candidates if needed
587
+ if (configurationId) {
588
+ this.addConfigurationFallbackCandidates(candidates, prompt, configurationId, preferredVendorId, verbose);
589
+ }
590
+ }
591
+ else if (this.hasAnyPromptModelBindings(prompt)) {
592
+ // Bindings exist for this prompt but none are Active/Preview (e.g. deliberately deactivated) —
593
+ // do NOT silently fall back to the global model pool, which would mask an intentional
594
+ // "no model available for this prompt" state. Leave candidates empty so the caller surfaces
595
+ // a "no suitable model found" failure instead of succeeding against an unrelated model.
596
+ }
597
+ else {
598
+ // No prompt-specific bindings were ever configured, use the general selection strategy
599
+ this.addStrategyBasedCandidates(candidates, prompt, preferredVendorName);
600
+ }
601
+ // Sort all candidates by priority (highest first)
602
+ candidates.sort((a, b) => b.priority - a.priority);
603
+ return candidates;
604
+ }
605
+ /**
606
+ * Helper: Filter prompt models by configuration matching rules.
607
+ * Supports configuration inheritance - includes models from the entire inheritance chain.
608
+ */
609
+ filterPromptModelsByConfiguration(allPromptModels, configurationId) {
610
+ if (configurationId) {
611
+ // Get the configuration inheritance chain
612
+ const chain = AIEngine.Instance.GetConfigurationChain(configurationId);
613
+ const chainIds = new Set(chain.map(c => NormalizeUUID(c.ID)));
614
+ // Include models matching any config in the chain, plus null-config (universal fallback)
615
+ return allPromptModels.filter(pm => (pm.ConfigurationID && chainIds.has(NormalizeUUID(pm.ConfigurationID))) ||
616
+ pm.ConfigurationID === null);
617
+ }
618
+ else {
619
+ // No config specified - only include null-config models
620
+ return allPromptModels.filter(pm => pm.ConfigurationID === null);
621
+ }
622
+ }
623
+ /**
624
+ * Helper: Sort prompt models for 'Specific' strategy.
625
+ * Respects configuration inheritance chain - child configs first, then parents, then null-config.
626
+ * Within each config level, sorts by priority DESC.
627
+ */
628
+ sortPromptModelsForSpecificStrategy(promptModels, configurationId) {
629
+ if (!configurationId) {
630
+ // No config specified - just sort by priority
631
+ return promptModels.sort((a, b) => (b.Priority || 0) - (a.Priority || 0));
632
+ }
633
+ // Get the configuration inheritance chain and create position map
634
+ const chain = AIEngine.Instance.GetConfigurationChain(configurationId);
635
+ const chainOrder = new Map(chain.map((c, index) => [c.ID, index]));
636
+ return promptModels.sort((a, b) => {
637
+ // Primary: Chain position (lower index = higher priority, null config = last)
638
+ const aChainPos = a.ConfigurationID ? (chainOrder.get(a.ConfigurationID) ?? 999) : 1000;
639
+ const bChainPos = b.ConfigurationID ? (chainOrder.get(b.ConfigurationID) ?? 999) : 1000;
640
+ if (aChainPos !== bChainPos) {
641
+ return aChainPos - bChainPos; // Lower chain position first (child before parent)
642
+ }
643
+ // Secondary: Higher priority first within same config level
644
+ return (b.Priority || 0) - (a.Priority || 0);
645
+ });
646
+ }
647
+ /**
648
+ * Helper: Build candidates from sorted AIPromptModel records.
649
+ * Expands VendorID=null to all vendors for that model.
650
+ */
651
+ buildCandidatesFromPromptModels(promptModels, prompt) {
652
+ const candidates = [];
653
+ const requiredTypeId = this.RequiredModelTypeID();
654
+ for (let i = 0; i < promptModels.length; i++) {
655
+ const pm = promptModels[i];
656
+ // Compute priority as inverse of array position so highest-priority (first) gets the largest number
657
+ const computedPriority = promptModels.length - i;
658
+ const model = AIEngine.Instance.ModelsByID.get(NormalizeUUID(pm.ModelID));
659
+ if (!model || !model.IsActive)
660
+ continue;
661
+ if (!UUIDsEqual(model.AIModelTypeID, requiredTypeId)) {
662
+ const modelTypeName = this.modelTypeName(model.AIModelTypeID);
663
+ LogStatus(`Skipping model "${model.Name}" for prompt "${prompt?.Name ?? pm.PromptID}": model type "${modelTypeName}" does not match runner required type "${this.RequiredModelType}"`);
664
+ continue;
665
+ }
666
+ if (pm.VendorID) {
667
+ // Specific vendor specified - create single candidate
668
+ const candidate = this.createCandidateForSpecificVendor(model, pm, computedPriority);
669
+ if (candidate) {
670
+ candidates.push(candidate);
671
+ }
672
+ }
673
+ else {
674
+ // No vendor specified - create candidates for all vendors
675
+ const vendorCandidates = this.createCandidatesForAllVendors(model, computedPriority);
676
+ candidates.push(...vendorCandidates);
677
+ }
678
+ }
679
+ return candidates;
680
+ }
681
+ /**
682
+ * Helper: Create candidate for specific vendor from AIPromptModel.
683
+ */
684
+ createCandidateForSpecificVendor(model, promptModel, computedPriority = 0) {
685
+ // Use the model's precomputed ModelVendors (grouped at engine load) instead of scanning
686
+ // the global ModelVendors array — model.ID === promptModel.ModelID here.
687
+ const modelVendor = model.ModelVendors.find(mv => UUIDsEqual(mv.VendorID, promptModel.VendorID) &&
688
+ mv.Status === 'Active' &&
689
+ this.IsInferenceProvider(mv));
690
+ if (!modelVendor)
691
+ return null;
692
+ return {
693
+ model,
694
+ vendorId: modelVendor.VendorID,
695
+ vendorName: modelVendor.Vendor,
696
+ driverClass: modelVendor.DriverClass || model.DriverClass,
697
+ apiName: modelVendor.APIName || model.APIName,
698
+ supportsEffortLevel: modelVendor.SupportsEffortLevel ?? model.SupportsEffortLevel ?? false,
699
+ effortLevel: promptModel.EffortLevel ?? undefined, // Model-specific effort level override
700
+ promptModelConfiguration: promptModel.PromptConfigurationObject,
701
+ isPreferredVendor: false,
702
+ priority: computedPriority,
703
+ source: 'prompt-model'
704
+ };
705
+ }
706
+ /**
707
+ * Helper: Create candidates for all vendors of a model, sorted by vendor priority.
708
+ */
709
+ createCandidatesForAllVendors(model, computedPriority = 0) {
710
+ const vendors = model.ModelVendors
711
+ .filter(mv => mv.Status === 'Active' &&
712
+ this.IsInferenceProvider(mv))
713
+ .sort((a, b) => (b.Priority || 0) - (a.Priority || 0));
714
+ const candidates = [];
715
+ for (const vendor of vendors) {
716
+ candidates.push({
717
+ model,
718
+ vendorId: vendor.VendorID,
719
+ vendorName: vendor.Vendor,
720
+ driverClass: vendor.DriverClass || model.DriverClass,
721
+ apiName: vendor.APIName || model.APIName,
722
+ supportsEffortLevel: vendor.SupportsEffortLevel ?? model.SupportsEffortLevel ?? false,
723
+ isPreferredVendor: false,
724
+ priority: computedPriority,
725
+ source: 'prompt-model'
726
+ });
727
+ }
728
+ // If no vendors found, use model defaults
729
+ if (candidates.length === 0 && model.DriverClass) {
730
+ candidates.push({
731
+ model,
732
+ driverClass: model.DriverClass,
733
+ apiName: model.APIName,
734
+ supportsEffortLevel: model.SupportsEffortLevel ?? false,
735
+ isPreferredVendor: false,
736
+ priority: computedPriority,
737
+ source: 'prompt-model'
738
+ });
739
+ }
740
+ return candidates;
741
+ }
742
+ /**
743
+ * Helper: true if this prompt has any AIPromptModel bindings at all, regardless of Status or
744
+ * ConfigurationID. Distinguishes "no bindings were ever configured" (general selection strategy
745
+ * should apply) from "bindings exist but are all Inactive" (no model should be selected).
746
+ */
747
+ hasAnyPromptModelBindings(prompt) {
748
+ return AIEngine.Instance.PromptModels.some(pm => UUIDsEqual(pm.PromptID, prompt.ID));
749
+ }
750
+ /**
751
+ * Helper: Get prompt models for configuration with inheritance chain fallback.
752
+ * Walks the configuration inheritance chain looking for prompt models.
753
+ * Returns models from the first config in the chain that has any, or falls back to null-config.
754
+ */
755
+ getPromptModelsForConfiguration(prompt, configurationId) {
756
+ if (configurationId) {
757
+ // Get the configuration inheritance chain (child -> parent -> grandparent -> ...)
758
+ const chain = AIEngine.Instance.GetConfigurationChain(configurationId);
759
+ // Walk the chain looking for prompt models
760
+ for (const config of chain) {
761
+ const promptModels = AIEngine.Instance.PromptModels.filter(pm => UUIDsEqual(pm.PromptID, prompt.ID) &&
762
+ (pm.Status === 'Active' || pm.Status === 'Preview') &&
763
+ UUIDsEqual(pm.ConfigurationID, config.ID));
764
+ if (promptModels.length > 0) {
765
+ return promptModels;
766
+ }
767
+ }
768
+ // No match in chain, fall back to NULL config models
769
+ LogStatus(`No models found in configuration chain for "${configurationId}", falling back to default models`);
770
+ }
771
+ // Return null-config (universal) models
772
+ return AIEngine.Instance.PromptModels.filter(pm => UUIDsEqual(pm.PromptID, prompt.ID) &&
773
+ (pm.Status === 'Active' || pm.Status === 'Preview') &&
774
+ !pm.ConfigurationID);
775
+ }
776
+ /**
777
+ * Helper: Add prompt-specific candidates with blended priorities (legacy behavior).
778
+ */
779
+ addPromptSpecificCandidates(candidates, promptModels, preferredVendorId, prompt) {
780
+ const requiredTypeId = this.RequiredModelTypeID();
781
+ for (const pm of promptModels) {
782
+ const model = AIEngine.Instance.ModelsByID.get(NormalizeUUID(pm.ModelID));
783
+ if (model && model.IsActive) {
784
+ if (!UUIDsEqual(model.AIModelTypeID, requiredTypeId)) {
785
+ const modelTypeName = this.modelTypeName(model.AIModelTypeID);
786
+ LogStatus(`Skipping model "${model.Name}" for prompt "${prompt?.Name ?? pm.PromptID}": model type "${modelTypeName}" does not match runner required type "${this.RequiredModelType}"`);
787
+ continue;
788
+ }
789
+ const modelCandidates = this.createCandidatesForModel(model, 5000, 'prompt-model', preferredVendorId, pm.Priority);
790
+ candidates.push(...modelCandidates);
791
+ }
792
+ }
793
+ }
794
+ /**
795
+ * Helper: Add configuration fallback candidates from the inheritance chain.
796
+ * Adds models from parent configs (with decreasing priority) and null-config models as final fallback.
797
+ */
798
+ addConfigurationFallbackCandidates(candidates, prompt, configurationId, preferredVendorId, verbose) {
799
+ const chain = AIEngine.Instance.GetConfigurationChain(configurationId);
800
+ const requiredTypeId = this.RequiredModelTypeID();
801
+ // Add models from parent configs (skip index 0 which is the direct config, already handled)
802
+ for (let i = 1; i < chain.length; i++) {
803
+ const parentConfig = chain[i];
804
+ const parentModels = AIEngine.Instance.PromptModels.filter(pm => UUIDsEqual(pm.PromptID, prompt.ID) &&
805
+ (pm.Status === 'Active' || pm.Status === 'Preview') &&
806
+ UUIDsEqual(pm.ConfigurationID, parentConfig.ID));
807
+ if (parentModels.length > 0 && verbose) {
808
+ LogStatus(`Adding ${parentModels.length} models from parent config "${parentConfig.Name}" as fallback`);
809
+ }
810
+ for (const pm of parentModels) {
811
+ const model = AIEngine.Instance.ModelsByID.get(NormalizeUUID(pm.ModelID));
812
+ if (model && model.IsActive) {
813
+ if (!UUIDsEqual(model.AIModelTypeID, requiredTypeId)) {
814
+ const modelTypeName = this.modelTypeName(model.AIModelTypeID);
815
+ LogStatus(`Skipping model "${model.Name}" for prompt "${prompt.Name}": model type "${modelTypeName}" does not match runner required type "${this.RequiredModelType}"`);
816
+ continue;
817
+ }
818
+ // Decrease base priority for each level up the chain (3000, 2500, 2000, etc.)
819
+ const basePriority = 3000 - (i * 500);
820
+ const modelCandidates = this.createCandidatesForModel(model, basePriority, 'prompt-model', preferredVendorId, pm.Priority);
821
+ candidates.push(...modelCandidates);
822
+ }
823
+ }
824
+ }
825
+ // Finally add NULL config models (universal fallback) with lowest priority
826
+ const nullConfigModels = AIEngine.Instance.PromptModels.filter(pm => UUIDsEqual(pm.PromptID, prompt.ID) &&
827
+ (pm.Status === 'Active' || pm.Status === 'Preview') &&
828
+ !pm.ConfigurationID);
829
+ if (nullConfigModels.length > 0 && verbose) {
830
+ LogStatus(`Adding ${nullConfigModels.length} NULL configuration models as universal fallback`);
831
+ }
832
+ for (const pm of nullConfigModels) {
833
+ const model = AIEngine.Instance.ModelsByID.get(NormalizeUUID(pm.ModelID));
834
+ if (model && model.IsActive) {
835
+ if (!UUIDsEqual(model.AIModelTypeID, requiredTypeId)) {
836
+ const modelTypeName = this.modelTypeName(model.AIModelTypeID);
837
+ LogStatus(`Skipping model "${model.Name}" for prompt "${prompt.Name}": model type "${modelTypeName}" does not match runner required type "${this.RequiredModelType}"`);
838
+ continue;
839
+ }
840
+ const modelCandidates = this.createCandidatesForModel(model, 1000, // Lowest priority tier
841
+ 'prompt-model', preferredVendorId, pm.Priority);
842
+ candidates.push(...modelCandidates);
843
+ }
844
+ }
845
+ }
846
+ /**
847
+ * Helper: Add strategy-based candidates when no prompt models exist.
848
+ */
849
+ addStrategyBasedCandidates(candidates, prompt, preferredVendorName) {
850
+ let modelPool = this.getModelPoolForStrategy(prompt, preferredVendorName);
851
+ modelPool = this.sortModelPoolByStrategy(modelPool, prompt);
852
+ // Create candidates for each model in the pool
853
+ modelPool.forEach((model, index) => {
854
+ const basePriority = 1000 - index * 10; // Decrease priority by position
855
+ const source = prompt.SelectionStrategy === 'ByPower' ? 'power-rank' : 'model-type';
856
+ candidates.push(...this.createCandidatesForModel(model, basePriority, source));
857
+ });
858
+ }
859
+ /**
860
+ * Helper: Get model pool filtered for strategy.
861
+ */
862
+ getModelPoolForStrategy(prompt, preferredVendorName) {
863
+ const requiredTypeId = this.RequiredModelTypeID();
864
+ return AIEngine.Instance.Models.filter(m => m.IsActive &&
865
+ UUIDsEqual(m.AIModelTypeID, requiredTypeId) &&
866
+ (!preferredVendorName ||
867
+ m.ModelVendors.some(mv => mv.Status === 'Active' &&
868
+ mv.Vendor === preferredVendorName &&
869
+ this.IsInferenceProvider(mv))));
870
+ }
871
+ /**
872
+ * Helper: Sort model pool by selection strategy.
873
+ */
874
+ sortModelPoolByStrategy(modelPool, prompt) {
875
+ if (prompt.SelectionStrategy === 'ByPower') {
876
+ return this.sortByPowerPreference(modelPool, prompt.PowerPreference);
877
+ }
878
+ else {
879
+ // Default strategy
880
+ const minPowerRank = prompt.MinPowerRank || 0;
881
+ return modelPool
882
+ .filter(m => m.PowerRank >= minPowerRank)
883
+ .sort((a, b) => b.PowerRank - a.PowerRank);
884
+ }
885
+ }
886
+ /**
887
+ * Helper: Sort models by power preference.
888
+ */
889
+ sortByPowerPreference(modelPool, powerPreference) {
890
+ const pool = [...modelPool];
891
+ switch (powerPreference) {
892
+ case 'Highest':
893
+ return pool.sort((a, b) => b.PowerRank - a.PowerRank);
894
+ case 'Lowest':
895
+ return pool.sort((a, b) => a.PowerRank - b.PowerRank);
896
+ case 'Balanced':
897
+ const avgPower = pool.reduce((sum, m) => sum + m.PowerRank, 0) / pool.length;
898
+ return pool.sort((a, b) => Math.abs(a.PowerRank - avgPower) - Math.abs(b.PowerRank - avgPower));
899
+ default:
900
+ return pool.sort((a, b) => b.PowerRank - a.PowerRank);
901
+ }
902
+ }
903
+ /**
904
+ * Helper: Create candidates for a model with AIModelVendor priorities (legacy behavior).
905
+ */
906
+ createCandidatesForModel(model, basePriority, source, preferredVendorId, promptModelPriority) {
907
+ const modelCandidates = [];
908
+ // Get all vendors for this model - filter for inference providers only.
909
+ // Uses the model's precomputed ModelVendors (grouped at engine load) rather than scanning
910
+ // the global ModelVendors array.
911
+ const modelVendors = model.ModelVendors
912
+ .filter(mv => mv.Status === 'Active' && this.IsInferenceProvider(mv))
913
+ .sort((a, b) => b.Priority - a.Priority);
914
+ // First, add preferred vendor if it exists
915
+ if (preferredVendorId) {
916
+ const preferredVendor = modelVendors.find(mv => UUIDsEqual(mv.VendorID, preferredVendorId));
917
+ if (preferredVendor) {
918
+ modelCandidates.push({
919
+ model,
920
+ vendorId: preferredVendor.VendorID,
921
+ vendorName: preferredVendor.Vendor,
922
+ driverClass: preferredVendor.DriverClass || model.DriverClass,
923
+ apiName: preferredVendor.APIName || model.APIName,
924
+ supportsEffortLevel: preferredVendor.SupportsEffortLevel ?? model.SupportsEffortLevel ?? false,
925
+ isPreferredVendor: true,
926
+ priority: basePriority + 1000, // Boost priority for preferred vendor
927
+ source
928
+ });
929
+ }
930
+ }
931
+ // Then add other vendors in priority order
932
+ for (const vendor of modelVendors) {
933
+ if (!UUIDsEqual(vendor.VendorID, preferredVendorId)) {
934
+ modelCandidates.push({
935
+ model,
936
+ vendorId: vendor.VendorID,
937
+ vendorName: vendor.Vendor,
938
+ driverClass: vendor.DriverClass || model.DriverClass,
939
+ apiName: vendor.APIName || model.APIName,
940
+ supportsEffortLevel: vendor.SupportsEffortLevel ?? model.SupportsEffortLevel ?? false,
941
+ isPreferredVendor: false,
942
+ priority: basePriority + (vendor.Priority || 0),
943
+ source
944
+ });
945
+ }
946
+ }
947
+ // If no vendors found, add model with its default driver
948
+ if (modelCandidates.length === 0 && model.DriverClass) {
949
+ modelCandidates.push({
950
+ model,
951
+ driverClass: model.DriverClass,
952
+ apiName: model.APIName,
953
+ supportsEffortLevel: model.SupportsEffortLevel ?? false,
954
+ isPreferredVendor: false,
955
+ priority: basePriority,
956
+ source
957
+ });
958
+ }
959
+ // Apply prompt model priority if provided (legacy blended approach)
960
+ if (promptModelPriority !== undefined) {
961
+ modelCandidates.forEach(c => c.priority += promptModelPriority * 10);
962
+ }
963
+ return modelCandidates;
964
+ }
965
+ /**
966
+ * Awaits all in-flight prompt-run saves queued by this runner instance. The normal execution path
967
+ * does NOT call this — prompt-run persistence is intentionally fire-and-forget. Exposed for tests
968
+ * and for callers that need the AIPromptRun rows durably written before proceeding.
969
+ */
970
+ async WaitForPendingPromptRunSaves() {
971
+ await this._promptRunQueue.Flush();
972
+ }
973
+ /**
974
+ * Creates the `MJ: AI Prompt Runs` record for one model call, fills the fields every runner shares,
975
+ * lets the subclass add its own request fields, then queues the INSERT (fire-and-forget).
976
+ *
977
+ * For prompt-based runners: the record is always tied to an `AIPrompt` and its `AIPromptParams`.
978
+ *
979
+ * @param applyRequestFields Sets the runner's own request columns. It runs once, after the shared
980
+ * fields are set and before the INSERT is queued, so everything it sets is on the inserted row.
981
+ * **It must be synchronous.** An `async` callback type-checks against this signature, but any
982
+ * field it sets after its first `await` races the INSERT and can miss the row.
983
+ */
984
+ async CreateRunRecord(prompt, model, params, startTime, vendorId, modelSelectionInfo, applyRequestFields) {
985
+ const provider = params.provider ?? Metadata.Provider;
986
+ const promptRun = await provider.GetEntityObject('MJ: AI Prompt Runs', params.contextUser);
987
+ try {
988
+ promptRun.NewRecord();
989
+ promptRun.PromptID = prompt.ID;
990
+ promptRun.ModelID = model.ID;
991
+ // Attribute the run to the agent that caused it, when there is one. PromptID alone cannot do
992
+ // this: agents share agent-type-level prompts, so a parent and its sub-agent produce runs of
993
+ // the SAME prompt. See AIPromptParams.agentId for why this was previously always null.
994
+ if (params.agentId) {
995
+ promptRun.AgentID = params.agentId;
996
+ }
997
+ // The user is recorded on the run itself: for a direct (non-agent) run it is the only record
998
+ // of who caused it. The agent run a prompt run belongs to is NOT stored here — the agent layer
999
+ // owns that link (AIAgentRunStep.TargetLogID).
1000
+ promptRun.UserID = ResolvePromptRunUserID({ UserID: params.UserID, ContextUser: params.contextUser });
1001
+ if (params.ExecutionOrder !== undefined) {
1002
+ promptRun.ExecutionOrder = params.ExecutionOrder;
1003
+ }
1004
+ if (params.RunType) {
1005
+ promptRun.RunType = params.RunType;
1006
+ }
1007
+ // Set initial status and tracking fields
1008
+ promptRun.Status = 'Running';
1009
+ promptRun.Cancelled = false;
1010
+ promptRun.CacheHit = false;
1011
+ promptRun.WasSelectedResult = false;
1012
+ // Set model selection tracking fields
1013
+ if (modelSelectionInfo) {
1014
+ // Convert the rich entity objects to simple IDs/names for database storage
1015
+ const dbSelectionInfo = {
1016
+ configurationId: modelSelectionInfo.aiConfiguration?.ID,
1017
+ configurationName: modelSelectionInfo.aiConfiguration?.Name,
1018
+ modelsConsidered: modelSelectionInfo.modelsConsidered.map(mc => ({
1019
+ modelId: mc.model.ID,
1020
+ modelName: mc.model.Name,
1021
+ vendorId: mc.vendor?.ID,
1022
+ vendorName: mc.vendor?.Name || 'default',
1023
+ priority: mc.priority,
1024
+ available: mc.available,
1025
+ unavailableReason: mc.unavailableReason
1026
+ })),
1027
+ modelSelected: modelSelectionInfo.modelSelected?.ID,
1028
+ vendorSelected: modelSelectionInfo.vendorSelected?.ID,
1029
+ selectionReason: modelSelectionInfo.selectionReason,
1030
+ fallbackUsed: modelSelectionInfo.fallbackUsed,
1031
+ selectionStrategy: modelSelectionInfo.selectionStrategy
1032
+ };
1033
+ promptRun.ModelSelection = JSON.stringify(dbSelectionInfo);
1034
+ promptRun.SelectionStrategy = modelSelectionInfo.selectionStrategy || 'Default';
1035
+ // Set ModelPowerRank if available
1036
+ if (model.PowerRank != null) {
1037
+ promptRun.ModelPowerRank = model.PowerRank;
1038
+ }
1039
+ }
1040
+ // Set original model tracking for failover
1041
+ promptRun.OriginalModelID = model.ID;
1042
+ promptRun.OriginalRequestStartTime = startTime;
1043
+ // Initialize failover tracking fields
1044
+ promptRun.FailoverAttempts = 0;
1045
+ promptRun.FailoverErrors = null;
1046
+ promptRun.FailoverDurations = null;
1047
+ promptRun.TotalFailoverDuration = 0;
1048
+ // Check if model has pre-selected vendor info from selectModel
1049
+ const modelWithVendor = model;
1050
+ if (modelSelectionInfo) {
1051
+ promptRun.VendorID = modelSelectionInfo.vendorSelected?.ID || vendorId || modelWithVendor._selectedVendorId;
1052
+ }
1053
+ else if (vendorId) {
1054
+ // Explicit vendor ID provided
1055
+ promptRun.VendorID = vendorId;
1056
+ }
1057
+ else if (modelWithVendor._selectedVendorId) {
1058
+ // Use vendor selected during model selection (with API key verification)
1059
+ promptRun.VendorID = modelWithVendor._selectedVendorId;
1060
+ }
1061
+ else {
1062
+ // Fallback: grab the highest priority AI Model Vendor record for this model (inference providers only)
1063
+ const modelVendors = model.ModelVendors
1064
+ .filter((mv) => mv.Status === 'Active' && this.IsInferenceProvider(mv))
1065
+ .sort((a, b) => b.Priority - a.Priority);
1066
+ if (modelVendors.length > 0) {
1067
+ promptRun.VendorID = modelVendors[0].VendorID;
1068
+ }
1069
+ }
1070
+ promptRun.ConfigurationID = params.configurationId;
1071
+ promptRun.RunAt = startTime;
1072
+ // Set ParentID for hierarchical prompt execution tracking
1073
+ if (params.parentPromptRunId) {
1074
+ promptRun.ParentID = params.parentPromptRunId;
1075
+ }
1076
+ // Set RerunFromPromptRunID if this is a rerun
1077
+ if (params.rerunFromPromptRunID) {
1078
+ promptRun.RerunFromPromptRunID = params.rerunFromPromptRunID;
1079
+ }
1080
+ applyRequestFields?.(promptRun);
1081
+ // Persist the initial 'Running' record fire-and-forget. The ID was already assigned by
1082
+ // NewRecord() above, so callers (and the onPromptRunCreated callback) have it immediately —
1083
+ // we don't block the model call on the INSERT. The finalize UPDATE chains after this INSERT
1084
+ // via the instance-keyed save queue, so ordering is guaranteed.
1085
+ this._promptRunQueue.Insert(promptRun);
1086
+ // Invoke callback if provided. The ID is available without awaiting the save (client-generated
1087
+ // by NewRecord()), so agent-run/step linking that depends on it works immediately.
1088
+ if (params.onPromptRunCreated) {
1089
+ try {
1090
+ await params.onPromptRunCreated(promptRun.ID);
1091
+ }
1092
+ catch (callbackError) {
1093
+ LogStatus(`Error in onPromptRunCreated callback: ${callbackError.message}`);
1094
+ // Don't fail the execution if callback fails
1095
+ }
1096
+ }
1097
+ return promptRun;
1098
+ }
1099
+ catch (error) {
1100
+ const msg = `Error creating prompt run record: ${error.message} - ${promptRun?.LatestResult?.CompleteMessage} - ${promptRun?.LatestResult?.Errors[0]?.Message}`;
1101
+ this.logError(msg, {
1102
+ category: 'PromptRunSave',
1103
+ metadata: {
1104
+ promptRunId: promptRun.ID,
1105
+ saveError: promptRun.LatestResult?.CompleteMessage
1106
+ },
1107
+ maxErrorLength: params.maxErrorLength
1108
+ });
1109
+ throw new Error(msg);
1110
+ }
1111
+ }
1112
+ /**
1113
+ * Updates prompt run with successful failover tracking data
1114
+ */
1115
+ updatePromptRunWithFailoverSuccess(promptRun, failoverAttempts, currentModel, currentVendorId) {
1116
+ promptRun.FailoverAttempts = failoverAttempts.length;
1117
+ promptRun.FailoverErrors = JSON.stringify(failoverAttempts.map(a => ({
1118
+ model: a.modelId,
1119
+ vendor: a.vendorId,
1120
+ error: a.error.message,
1121
+ errorType: a.errorType
1122
+ })));
1123
+ promptRun.FailoverDurations = JSON.stringify(failoverAttempts.map(a => a.duration));
1124
+ promptRun.TotalFailoverDuration = failoverAttempts.reduce((sum, a) => sum + a.duration, 0);
1125
+ // Update ModelID if we ended up using a different model
1126
+ if (!UUIDsEqual(currentModel.ID, promptRun.OriginalModelID)) {
1127
+ promptRun.ModelID = currentModel.ID;
1128
+ }
1129
+ if (currentVendorId && !UUIDsEqual(currentVendorId, promptRun.VendorID)) {
1130
+ promptRun.VendorID = currentVendorId;
1131
+ }
1132
+ }
1133
+ /**
1134
+ * Updates prompt run with failover failure tracking data
1135
+ */
1136
+ updatePromptRunWithFailoverFailure(promptRun, failoverAttempts) {
1137
+ promptRun.FailoverAttempts = failoverAttempts.length;
1138
+ promptRun.FailoverErrors = JSON.stringify(failoverAttempts.map(a => ({
1139
+ model: a.modelId,
1140
+ vendor: a.vendorId,
1141
+ error: a.error.message,
1142
+ errorType: a.errorType
1143
+ })));
1144
+ promptRun.FailoverDurations = JSON.stringify(failoverAttempts.map(a => a.duration));
1145
+ promptRun.TotalFailoverDuration = failoverAttempts.reduce((sum, a) => sum + a.duration, 0);
1146
+ }
1147
+ /**
1148
+ * Runs one model call across the failover candidates, in priority order: skips candidates with no
1149
+ * credentials, lets processFailoverError decide retry / next candidate / stop, and records failover
1150
+ * success or failure on the prompt run. The model call itself and the final error result are
1151
+ * supplied by the subclass, so the loop works for any result type that extends BaseResult.
1152
+ *
1153
+ * For prompt-based runners: it takes the `AIPrompt` and `AIPromptParams` the call is for, and
1154
+ * candidates that carry prompt-model fields (effort level, the `AIPromptModel` configuration).
1155
+ *
1156
+ * @param executeOnCandidate Makes the call on one candidate. It must use the candidate's own model,
1157
+ * vendor, driver and prompt-model fields, not those of the first candidate.
1158
+ * @param createErrorResult Builds the result returned when every candidate has failed.
1159
+ */
1160
+ async ExecuteWithFailover(prompt, params, allCandidates, failoverConfig, executeOnCandidate, createErrorResult, promptRun, credentialAvailability) {
1161
+ // Track failover attempts
1162
+ const failoverAttempts = [];
1163
+ let lastError = null;
1164
+ // Cache credential availability per driver:model:vendor for the duration of this failover
1165
+ // scan so we don't repeat env-var / binding lookups while walking the candidate list.
1166
+ //
1167
+ // PERF: seed it with the probes model SELECTION already performed (same key format). Selection
1168
+ // walks the priority list until it finds the first credentialed candidate, so this map holds
1169
+ // the prefix it rejected (known false) PLUS the selected candidate (known true) — which is
1170
+ // exactly the segment failover re-walks on the happy path. Reusing those results means the
1171
+ // common case (and any caller looping failover) does ZERO redundant HasCredentialsAvailable
1172
+ // calls. The not-evaluated tail is intentionally absent, so failover still lazily probes it
1173
+ // only if a real failure forces it to walk down there.
1174
+ const failoverCredentialCache = credentialAvailability
1175
+ ? new Map(credentialAvailability)
1176
+ : new Map();
1177
+ const candidateHasCredentials = (c) => {
1178
+ const key = `${c.driverClass}:${c.model.ID}:${c.vendorId || 'default'}`;
1179
+ let has = failoverCredentialCache.get(key);
1180
+ if (has === undefined) {
1181
+ has = this.HasCredentialsAvailable(c.driverClass, prompt.ID, c.model.ID, c.vendorId, params);
1182
+ failoverCredentialCache.set(key, has);
1183
+ }
1184
+ return has;
1185
+ };
1186
+ let skippedForCredentials = 0;
1187
+ // Iterate through all candidates in priority order with instant failover
1188
+ for (let i = 0; i < allCandidates.length; i++) {
1189
+ const candidate = allCandidates[i];
1190
+ const attemptStartTime = Date.now();
1191
+ // Skip candidates with no credentials configured. `allCandidates` is intentionally the
1192
+ // FULL priority-ordered list (see the DECISION note in selectModelWithAPIKeyTracked),
1193
+ // so it can include vendors that have no API key in this environment. Firing a live
1194
+ // request at one of those produces a misleading "401 invalid API key" — and because an
1195
+ // Authentication error is treated as fatal, it would halt failover before any
1196
+ // credentialed candidate is ever reached. Skipping here makes failover land on the
1197
+ // first candidate that can actually authenticate (mirroring model selection's own
1198
+ // highest-priority-with-credentials rule).
1199
+ if (!candidateHasCredentials(candidate)) {
1200
+ skippedForCredentials++;
1201
+ continue;
1202
+ }
1203
+ try {
1204
+ // Log the attempt if not the first one
1205
+ if (i > 0) {
1206
+ const vendorName = candidate.vendorName || 'default';
1207
+ LogStatusEx({
1208
+ message: `🔄 Trying candidate ${i + 1}/${allCandidates.length}: ${candidate.model.Name} via ${vendorName}`,
1209
+ category: 'AI',
1210
+ additionalArgs: [{
1211
+ promptId: prompt.ID,
1212
+ modelId: candidate.model.ID,
1213
+ model: candidate.model.Name,
1214
+ vendorId: candidate.vendorId,
1215
+ vendor: candidate.vendorName,
1216
+ attemptNumber: i + 1
1217
+ }]
1218
+ });
1219
+ }
1220
+ // Execute the model with this candidate
1221
+ const result = await executeOnCandidate(candidate);
1222
+ // CRITICAL FIX: Check if result failed but is retriable (network errors, rate limits, etc.)
1223
+ // Provider drivers (GeminiLLM, OpenAILLM, etc.) catch errors internally and return ChatResult{success: false}
1224
+ // instead of throwing, so we must check result.success here.
1225
+ if (!result.success && result.errorInfo?.canFailover) {
1226
+ lastError = result.exception || new Error(result.errorMessage || 'Model execution failed');
1227
+ // Use shared failover error handling logic
1228
+ const decision = await this.processFailoverError(lastError, result.errorInfo, candidate, attemptStartTime, i, allCandidates, failoverAttempts, prompt, failoverConfig);
1229
+ // Update candidates list (may have been filtered)
1230
+ allCandidates = decision.updatedCandidates;
1231
+ if (decision.shouldRetry) {
1232
+ i--; // Retry same model/vendor
1233
+ continue;
1234
+ }
1235
+ if (decision.shouldContinue) {
1236
+ continue; // Try next candidate
1237
+ }
1238
+ // Otherwise break (fatal error or last candidate)
1239
+ break;
1240
+ }
1241
+ // A failure that is not eligible for failover (structural error, or none diagnosed) is
1242
+ // returned as-is — but never silently: callers often see only an empty result.
1243
+ if (!result.success) {
1244
+ this.logError(`Model call failed and is not eligible for failover (${result.errorInfo?.errorType ?? 'undiagnosed'}): ${result.errorMessage ?? 'no error message'}`, { prompt, model: candidate.model, metadata: { vendorId: candidate.vendorId, driverClass: candidate.driverClass } });
1245
+ }
1246
+ // Update promptRun with failover information if we had prior failures
1247
+ if (failoverAttempts.length > 0 && promptRun) {
1248
+ this.updatePromptRunWithFailoverSuccess(promptRun, failoverAttempts, candidate.model, candidate.vendorId || null);
1249
+ }
1250
+ return result;
1251
+ }
1252
+ catch (error) {
1253
+ lastError = error;
1254
+ // Analyze error to get error info
1255
+ const errorInfo = ErrorAnalyzer.analyzeError(lastError);
1256
+ // Use shared failover error handling logic
1257
+ const decision = await this.processFailoverError(lastError, errorInfo, candidate, attemptStartTime, i, allCandidates, failoverAttempts, prompt, failoverConfig);
1258
+ // Update candidates list (may have been filtered)
1259
+ allCandidates = decision.updatedCandidates;
1260
+ if (decision.shouldRetry) {
1261
+ i--; // Retry same model/vendor
1262
+ continue;
1263
+ }
1264
+ if (decision.shouldContinue) {
1265
+ continue; // Try next candidate
1266
+ }
1267
+ // Otherwise break (fatal error or last candidate)
1268
+ break;
1269
+ }
1270
+ }
1271
+ // All candidates failed
1272
+ if (promptRun && failoverAttempts.length > 0) {
1273
+ this.updatePromptRunWithFailoverFailure(promptRun, failoverAttempts);
1274
+ }
1275
+ // If every candidate was skipped for missing credentials we never attempted a call and
1276
+ // have no underlying error to report — surface an actionable message instead of null.
1277
+ if (!lastError && failoverAttempts.length === 0 && skippedForCredentials > 0) {
1278
+ lastError = new Error(`No API credentials configured for any of the ${skippedForCredentials} candidate model-vendor combination(s) for prompt "${prompt.Name}".`);
1279
+ }
1280
+ return createErrorResult(lastError, failoverAttempts);
1281
+ }
1282
+ /**
1283
+ * Engine-level default model-call timeout, in milliseconds, applied when the caller supplies no
1284
+ * `AIPromptParams.timeoutMS`. `undefined` (the default) means NO implicit bound — a prompt run
1285
+ * with neither a timeout nor a cancellation token stays unbounded, exactly as before, so this
1286
+ * change is behavior-preserving for existing callers.
1287
+ *
1288
+ * Subclasses (or a host application's runner subclass) can override this to impose a global
1289
+ * safety ceiling on every prompt call.
1290
+ */
1291
+ get DefaultPromptTimeoutMS() {
1292
+ return undefined;
1293
+ }
1294
+ /**
1295
+ * Resolves the per-model-call timeout: the caller's `AIPromptParams.timeoutMS`, else the runner's
1296
+ * {@link DefaultPromptTimeoutMS}. Non-positive / non-numeric values mean "no timeout".
1297
+ *
1298
+ * The bound is applied PER MODEL CALL (not per prompt execution), which mirrors the parallel
1299
+ * path's existing `taskTimeoutMS` semantics: each failover candidate / validation retry gets a
1300
+ * fresh budget rather than sharing one wall-clock window.
1301
+ *
1302
+ * NOTE (issue #3064): there is deliberately NO prompt-entity source here yet — the `AIPrompt`
1303
+ * table has no `TimeoutMS` column today. Once a migration adds one and CodeGen regenerates the
1304
+ * entity, this becomes `prompt.TimeoutMS ?? params.timeoutMS ?? this.DefaultPromptTimeoutMS`
1305
+ * and every bound below starts honoring the per-prompt configuration with no other change.
1306
+ */
1307
+ getEffectiveTimeoutMS(params) {
1308
+ const timeoutMS = params.timeoutMS ?? this.DefaultPromptTimeoutMS;
1309
+ return typeof timeoutMS === 'number' && timeoutMS > 0 ? timeoutMS : undefined;
1310
+ }
1311
+ /**
1312
+ * Composes the caller-supplied cancellation token with the resolved model-call timeout into a
1313
+ * single {@link AbortSignal} that bounds one model call. NEITHER bound is discarded:
1314
+ *
1315
+ * - caller token only → the caller's signal is used directly (behavior unchanged)
1316
+ * - timeout only → an internal controller aborts after the timeout elapses
1317
+ * - both → an internal controller relays the caller's abort AND fires on timeout;
1318
+ * whichever happens first wins
1319
+ * - neither → `Signal` is undefined and the call runs unbounded (legacy behavior)
1320
+ *
1321
+ * Implemented with an AbortController + relay listener rather than `AbortSignal.any()` so it works
1322
+ * on Node 18 (where `AbortSignal.any` does not exist — it landed in Node 20.3).
1323
+ */
1324
+ createExecutionBound(prompt, params, cancellationToken) {
1325
+ const timeoutMS = this.getEffectiveTimeoutMS(params);
1326
+ if (timeoutMS === undefined) {
1327
+ // No prompt timeout: use the caller's token as-is (or nothing at all).
1328
+ return { Signal: cancellationToken, TimeoutMS: undefined, TimedOut: () => false, Dispose: () => { } };
1329
+ }
1330
+ const controller = new AbortController();
1331
+ let timedOut = false;
1332
+ const relayCallerAbort = () => {
1333
+ if (!controller.signal.aborted) {
1334
+ controller.abort(cancellationToken?.reason ?? 'Chat completion was cancelled');
1335
+ }
1336
+ };
1337
+ if (cancellationToken) {
1338
+ if (cancellationToken.aborted) {
1339
+ relayCallerAbort();
1340
+ }
1341
+ else {
1342
+ cancellationToken.addEventListener('abort', relayCallerAbort, { once: true });
1343
+ }
1344
+ }
1345
+ const timer = setTimeout(() => {
1346
+ if (!controller.signal.aborted) {
1347
+ timedOut = true;
1348
+ controller.abort(new AIPromptTimeoutError(prompt.Name, timeoutMS));
1349
+ }
1350
+ }, timeoutMS);
1351
+ return {
1352
+ Signal: controller.signal,
1353
+ TimeoutMS: timeoutMS,
1354
+ TimedOut: () => timedOut,
1355
+ Dispose: () => {
1356
+ clearTimeout(timer);
1357
+ cancellationToken?.removeEventListener('abort', relayCallerAbort);
1358
+ },
1359
+ };
1360
+ }
1361
+ /**
1362
+ * Applies retry delay based on the prompt's retry strategy
1363
+ */
1364
+ /**
1365
+ * Calculates retry delay for rate limit and other retriable errors.
1366
+ * Uses the prompt's RetryStrategy and can respect suggested delays from provider.
1367
+ */
1368
+ calculateRetryDelay(prompt, attemptNumber, suggestedDelaySeconds) {
1369
+ // Use provider's suggested delay if available
1370
+ if (suggestedDelaySeconds && suggestedDelaySeconds > 0) {
1371
+ return suggestedDelaySeconds * 1000; // Convert to milliseconds
1372
+ }
1373
+ const baseDelay = prompt.RetryDelayMS || 1000; // Default 1 second
1374
+ let delay = baseDelay;
1375
+ switch (prompt.RetryStrategy) {
1376
+ case 'Fixed':
1377
+ delay = baseDelay;
1378
+ break;
1379
+ case 'Linear':
1380
+ delay = baseDelay * attemptNumber;
1381
+ break;
1382
+ case 'Exponential':
1383
+ delay = baseDelay * Math.pow(2, attemptNumber - 1);
1384
+ break;
1385
+ default:
1386
+ delay = baseDelay;
1387
+ }
1388
+ return delay;
1389
+ }
1390
+ async ApplyRetryDelay(prompt, attemptNumber, suggestedDelaySeconds) {
1391
+ const delay = this.calculateRetryDelay(prompt, attemptNumber, suggestedDelaySeconds);
1392
+ const delaySeconds = (delay / 1000).toFixed(1);
1393
+ LogStatus(` Waiting ${delaySeconds}s before retry (strategy: ${prompt.RetryStrategy || 'Fixed'})...`);
1394
+ await new Promise(resolve => setTimeout(resolve, delay));
1395
+ }
1396
+ /**
1397
+ * Filters out all candidates from a vendor when a vendor-level error occurs.
1398
+ * Vendor-level errors affect all models from that vendor:
1399
+ * - Authentication: Invalid API key
1400
+ * - VendorValidationError: API schema/validation requirements
1401
+ */
1402
+ filterVendorCandidates(errorType, currentVendorId, allCandidates) {
1403
+ if (errorType !== 'Authentication' && errorType !== 'VendorValidationError') {
1404
+ return allCandidates; // No filtering needed for non-vendor-level errors
1405
+ }
1406
+ const failedVendorId = currentVendorId || 'default';
1407
+ const beforeCount = allCandidates.length;
1408
+ // Filter out ALL candidates from this vendor
1409
+ const filteredCandidates = allCandidates.filter(c => (c.vendorId || 'default') !== failedVendorId);
1410
+ const removedCount = beforeCount - filteredCandidates.length;
1411
+ if (removedCount > 0) {
1412
+ const vendorName = AIEngine.Instance.VendorsByID.get(NormalizeUUID(failedVendorId))?.Name || failedVendorId;
1413
+ const remainingCount = filteredCandidates.length;
1414
+ // Log appropriate message based on error type
1415
+ let reason;
1416
+ let icon;
1417
+ if (errorType === 'Authentication') {
1418
+ reason = 'Invalid API key';
1419
+ icon = '🔒';
1420
+ }
1421
+ else if (errorType === 'VendorValidationError') {
1422
+ reason = 'API schema incompatibility';
1423
+ icon = '⚠️';
1424
+ }
1425
+ else {
1426
+ reason = 'Vendor-level error';
1427
+ icon = '❌';
1428
+ }
1429
+ this.logStatus(` ${icon} ${reason} for ${vendorName} - excluding ${removedCount} model${removedCount === 1 ? '' : 's'} from this vendor (${remainingCount} remaining)`, true);
1430
+ }
1431
+ return filteredCandidates;
1432
+ }
1433
+ /**
1434
+ * Handles rate limit errors by retrying the same model/vendor with backoff.
1435
+ * Returns true if the caller should continue (retry), false if should proceed to failover.
1436
+ */
1437
+ async handleRateLimitRetry(errorAnalysis, currentModel, currentVendorId, failoverAttempts, prompt, attemptNumber, maxAttempts, failoverAttempt) {
1438
+ const isRateLimit = errorAnalysis.errorType === 'RateLimit';
1439
+ if (!isRateLimit) {
1440
+ return false; // Not a rate limit error
1441
+ }
1442
+ // Count how many times we've retried this specific model/vendor for rate limits
1443
+ const rateLimitRetryCount = failoverAttempts.filter(a => UUIDsEqual(a.modelId, currentModel.ID) &&
1444
+ UUIDsEqual(a.vendorId, currentVendorId) &&
1445
+ a.errorType === 'RateLimit').length;
1446
+ // Use MaxRetries from prompt configuration, default to 3 if not set
1447
+ const maxRetries = prompt.MaxRetries ?? 3;
1448
+ // Retry up to MaxRetries times before giving up and failing over
1449
+ const shouldRetry = rateLimitRetryCount <= maxRetries;
1450
+ if (shouldRetry) {
1451
+ const modelName = currentModel.Name;
1452
+ const vendorName = currentVendorId
1453
+ ? AIEngine.Instance.VendorsByID.get(NormalizeUUID(currentVendorId))?.Name || 'default'
1454
+ : 'default';
1455
+ this.logStatus(` ⏳ Rate limit hit - retrying ${modelName} (${vendorName}) with backoff (attempt ${rateLimitRetryCount}/${maxRetries})`, true);
1456
+ this.logFailoverAttempt(prompt.ID, failoverAttempt, true);
1457
+ // Apply backoff delay before retry
1458
+ if (attemptNumber < maxAttempts) {
1459
+ await this.ApplyRetryDelay(prompt, rateLimitRetryCount, errorAnalysis.suggestedRetryDelaySeconds);
1460
+ }
1461
+ return true; // Signal to continue with same model/vendor
1462
+ }
1463
+ return false; // Too many retries, proceed to failover
1464
+ }
1465
+ /**
1466
+ * Processes a failover error (either from catch block or from failed ChatResult).
1467
+ * Handles vendor filtering, rate limit retries, fatal error detection, and failover logic.
1468
+ *
1469
+ * @returns Decision object indicating whether to retry same model, continue to next candidate, or stop
1470
+ */
1471
+ async processFailoverError(error, errorInfo, candidate, attemptStartTime, attemptIndex, allCandidates, failoverAttempts, prompt, failoverConfig) {
1472
+ const attemptDuration = Date.now() - attemptStartTime;
1473
+ // Create failover attempt record
1474
+ const failoverAttempt = {
1475
+ attemptNumber: attemptIndex + 1,
1476
+ modelId: candidate.model.ID,
1477
+ vendorId: candidate.vendorId,
1478
+ error: error,
1479
+ errorType: errorInfo.errorType,
1480
+ duration: attemptDuration,
1481
+ timestamp: new Date()
1482
+ };
1483
+ failoverAttempts.push(failoverAttempt);
1484
+ // Vendor-level errors: filter out all candidates from this vendor
1485
+ let updatedCandidates = allCandidates;
1486
+ if (errorInfo.errorType === 'Authentication' || errorInfo.errorType === 'VendorValidationError') {
1487
+ updatedCandidates = this.filterVendorCandidates(errorInfo.errorType, candidate.vendorId, allCandidates);
1488
+ }
1489
+ const isLastCandidate = attemptIndex === updatedCandidates.length - 1;
1490
+ // Fatal errors: stop immediately
1491
+ if (errorInfo.severity === 'Fatal') {
1492
+ const errorMessage = error?.message || 'Unknown error';
1493
+ LogErrorEx(`Stopping failover: Fatal error (${errorInfo.errorType}): ${errorMessage}`);
1494
+ this.logFailoverAttempt(prompt.ID, failoverAttempt, false);
1495
+ return { shouldRetry: false, shouldContinue: false, updatedCandidates };
1496
+ }
1497
+ // Check errorScope filter if configured
1498
+ if (failoverConfig.errorScope && failoverConfig.errorScope !== 'All') {
1499
+ const matchesScope = this.errorMatchesScope(errorInfo.errorType, failoverConfig.errorScope);
1500
+ if (!matchesScope) {
1501
+ this.logFailoverAttempt(prompt.ID, failoverAttempt, false);
1502
+ return { shouldRetry: false, shouldContinue: false, updatedCandidates };
1503
+ }
1504
+ }
1505
+ // Rate limit errors: check if we should retry the same model before failing over
1506
+ if (errorInfo.errorType === 'RateLimit') {
1507
+ const shouldRetry = await this.handleRateLimitRetry(errorInfo, candidate.model, candidate.vendorId, failoverAttempts, prompt, attemptIndex, updatedCandidates.length, failoverAttempt);
1508
+ if (shouldRetry) {
1509
+ return { shouldRetry: true, shouldContinue: false, updatedCandidates };
1510
+ }
1511
+ }
1512
+ // If this is the last candidate, we're done
1513
+ if (isLastCandidate) {
1514
+ this.logFailoverAttempt(prompt.ID, failoverAttempt, false);
1515
+ return { shouldRetry: false, shouldContinue: false, updatedCandidates };
1516
+ }
1517
+ // Log and signal to continue to next candidate
1518
+ this.logFailoverAttempt(prompt.ID, failoverAttempt, true);
1519
+ return { shouldRetry: false, shouldContinue: true, updatedCandidates };
1520
+ }
1521
+ /**
1522
+ * Queues the finalize UPDATE for a prompt-run record. Inside the queued task it sets, in order:
1523
+ * the completion timing (`CompletedAt`, `ExecutionTimeMS`); the outcome (`Success`, and `Status` =
1524
+ * `'Completed'` or `'Failed'` from it); then the subclass's result fields; then the token rollups and
1525
+ * `TotalCost`.
1526
+ *
1527
+ * The outcome is a parameter, not left to the callback, so every runner's row leaves `'Running'`.
1528
+ * It is written before the callback runs, so the row reaches its final status even if the callback
1529
+ * throws (the error is logged under `PromptRunUpdate` and the rollups are skipped).
1530
+ *
1531
+ * For prompt-based runners, like {@link CreateRunRecord}.
1532
+ *
1533
+ * @param success Whether the call succeeded, as the runner judges it (the chat runner also requires
1534
+ * its output to pass validation).
1535
+ * @param applyResultFields Sets the runner's own result columns. It can read the timing and outcome
1536
+ * already set, and may refine them (for example `ErrorDetails`). **It must be synchronous**: it runs
1537
+ * inside the queued save task, and anything it sets after an `await` can miss the UPDATE.
1538
+ */
1539
+ async FinalizeRunRecord(promptRun, success, endTime, executionTimeMS, applyResultFields) {
1540
+ // Fire-and-forget finalize UPDATE. The field mutations run INSIDE the post-INSERT task (after the
1541
+ // 'Running' INSERT + its finalizeSave reload land), so the reload can never revert them and the
1542
+ // chained UPDATE persists the finalized state — the "stuck at Running" race is structurally
1543
+ // impossible. The execution flow does NOT await the save.
1544
+ this._promptRunQueue.Update(promptRun, () => {
1545
+ try {
1546
+ promptRun.CompletedAt = endTime;
1547
+ promptRun.ExecutionTimeMS = executionTimeMS;
1548
+ promptRun.Success = success;
1549
+ promptRun.Status = success ? 'Completed' : 'Failed';
1550
+ applyResultFields(promptRun);
1551
+ // Note: Failover tracking fields are now updated directly in executeModelWithFailover
1552
+ // The promptRun entity already has the failover information set
1553
+ // With template composition, we only execute once so rollup equals regular fields
1554
+ promptRun.TokensPromptRollup = promptRun.TokensPrompt;
1555
+ promptRun.TokensCompletionRollup = promptRun.TokensCompletion;
1556
+ promptRun.TokensUsedRollup = promptRun.TokensUsed;
1557
+ promptRun.TokensCacheReadRollup = promptRun.TokensCacheRead;
1558
+ promptRun.TokensCacheWriteRollup = promptRun.TokensCacheWrite;
1559
+ // A parallel parent carries no own Cost (its arms do); its TotalCost is their sum.
1560
+ if (promptRun.RunType !== 'ParallelParent' && promptRun.Cost !== undefined) {
1561
+ promptRun.TotalCost = promptRun.Cost;
1562
+ }
1563
+ }
1564
+ catch (error) {
1565
+ this.logError(error, {
1566
+ category: 'PromptRunUpdate',
1567
+ metadata: {
1568
+ promptRunId: promptRun.ID
1569
+ }
1570
+ });
1571
+ }
1572
+ });
1573
+ }
1574
+ // ==================== CONTEXT LENGTH METHODS ====================
1575
+ /**
1576
+ * Estimates the number of tokens in a rendered prompt and conversation messages.
1577
+ * This is a rough estimation based on character count and typical token ratios.
1578
+ *
1579
+ * @param renderedPrompt - The rendered prompt text
1580
+ * @param conversationMessages - Optional conversation messages
1581
+ * @returns Estimated token count
1582
+ */
1583
+ // ==================== FAILOVER METHODS ====================
1584
+ /**
1585
+ * Retrieves failover configuration from the prompt entity.
1586
+ *
1587
+ * @param prompt - The AI prompt entity containing failover settings
1588
+ * @returns FailoverConfiguration object with strategy and settings
1589
+ *
1590
+ * @remarks
1591
+ * This method extracts failover configuration from the prompt entity and provides
1592
+ * default values when configuration is not specified. Override this method to
1593
+ * implement custom failover configuration logic.
1594
+ */
1595
+ getFailoverConfiguration(prompt) {
1596
+ return {
1597
+ strategy: prompt.FailoverStrategy || 'None',
1598
+ maxAttempts: prompt.FailoverMaxAttempts || 3,
1599
+ delaySeconds: prompt.FailoverDelaySeconds || 1,
1600
+ modelStrategy: prompt.FailoverModelStrategy || 'PreferSameModel',
1601
+ errorScope: prompt.FailoverErrorScope || 'All'
1602
+ };
1603
+ }
1604
+ /**
1605
+ * Determines whether a failover attempt should be made based on the error and configuration.
1606
+ *
1607
+ * @param error - The error that occurred during execution
1608
+ * @param config - The failover configuration
1609
+ * @param attemptNumber - The current attempt number (1-based)
1610
+ * @returns True if failover should be attempted, false otherwise
1611
+ *
1612
+ * @remarks
1613
+ * This method uses the ErrorAnalyzer to classify errors and determine if they are
1614
+ * eligible for failover based on the configured error scope. Override this method
1615
+ * to implement custom failover decision logic.
1616
+ */
1617
+ shouldAttemptFailover(error, config, attemptNumber) {
1618
+ // Don't failover if strategy is None or we've exceeded max attempts
1619
+ if (config.strategy === 'None' || attemptNumber > config.maxAttempts) {
1620
+ return false;
1621
+ }
1622
+ // Analyze the error to determine if it's eligible for failover
1623
+ const errorAnalysis = ErrorAnalyzer.analyzeError(error);
1624
+ // Check if error analysis allows failover
1625
+ if (!errorAnalysis.canFailover) {
1626
+ return false;
1627
+ }
1628
+ // Check error scope configuration
1629
+ switch (config.errorScope) {
1630
+ case 'NetworkOnly':
1631
+ return errorAnalysis.errorType === 'NetworkError';
1632
+ case 'RateLimitOnly':
1633
+ return errorAnalysis.errorType === 'RateLimit';
1634
+ case 'ServiceErrorOnly':
1635
+ return errorAnalysis.errorType === 'ServiceUnavailable' ||
1636
+ errorAnalysis.errorType === 'InternalServerError';
1637
+ case 'All':
1638
+ default:
1639
+ return true;
1640
+ }
1641
+ }
1642
+ /**
1643
+ * Checks if an error type matches the configured error scope
1644
+ *
1645
+ * @param errorType - The error type from ErrorAnalyzer
1646
+ * @param scope - The configured error scope
1647
+ * @returns True if the error matches the scope
1648
+ */
1649
+ errorMatchesScope(errorType, scope) {
1650
+ switch (scope) {
1651
+ case 'NetworkOnly':
1652
+ return errorType === 'NetworkError';
1653
+ case 'RateLimitOnly':
1654
+ return errorType === 'RateLimit';
1655
+ case 'ServiceErrorOnly':
1656
+ return errorType === 'ServiceUnavailable' || errorType === 'InternalServerError';
1657
+ case 'All':
1658
+ default:
1659
+ return true;
1660
+ }
1661
+ }
1662
+ /**
1663
+ * Calculates the delay before the next failover attempt.
1664
+ *
1665
+ * @param attemptNumber - The current attempt number (1-based)
1666
+ * @param baseDelaySeconds - The base delay in seconds from configuration
1667
+ * @param previousError - The error from the previous attempt
1668
+ * @returns Delay in milliseconds before the next attempt
1669
+ *
1670
+ * @remarks
1671
+ * Implements exponential backoff with jitter by default. The delay increases
1672
+ * exponentially with each attempt and includes random jitter to prevent
1673
+ * thundering herd problems. Override this method to implement custom delay logic.
1674
+ */
1675
+ calculateFailoverDelay(attemptNumber, baseDelaySeconds) {
1676
+ // Exponential backoff: delay = base * 2^(attempt-1)
1677
+ const exponentialDelay = baseDelaySeconds * Math.pow(2, attemptNumber - 1);
1678
+ // Add jitter (0-25% of delay) to prevent thundering herd
1679
+ const jitter = exponentialDelay * 0.25 * Math.random();
1680
+ // Cap at 30 seconds to prevent excessive delays
1681
+ const totalDelay = Math.min(exponentialDelay + jitter, 30);
1682
+ return totalDelay * 1000; // Convert to milliseconds
1683
+ }
1684
+ /**
1685
+ * Selects candidate models for failover based on the strategy and current failure.
1686
+ *
1687
+ * @param currentModel - The model that just failed
1688
+ * @param currentVendorId - The vendor ID that just failed
1689
+ * @param strategy - The failover strategy to use
1690
+ * @param modelStrategy - The model selection preference
1691
+ * @param allCandidates - All available model-vendor candidates
1692
+ * @param attemptHistory - History of previous failover attempts
1693
+ * @returns Array of candidates sorted by priority (highest first)
1694
+ *
1695
+ * @remarks
1696
+ * This method implements different strategies for selecting failover candidates:
1697
+ * - SameModelDifferentVendor: Try the same model with different vendors
1698
+ * - NextBestModel: Try different models in order of preference
1699
+ * - PowerRank: Use the global power ranking of models
1700
+ *
1701
+ * Override this method to implement custom candidate selection logic.
1702
+ */
1703
+ selectFailoverCandidates(currentModel, currentVendorId, strategy, modelStrategy, allCandidates, attemptHistory) {
1704
+ // Filter out candidates that have already failed
1705
+ // Note: Authentication errors are already filtered from allCandidates upstream,
1706
+ // so we only need to filter out specific model/vendor pairs that have failed
1707
+ const failedPairs = new Set(attemptHistory.map(a => `${a.modelId}:${a.vendorId || 'default'}`));
1708
+ const availableCandidates = allCandidates.filter(c => {
1709
+ const key = `${c.model.ID}:${c.vendorId || 'default'}`;
1710
+ return !failedPairs.has(key);
1711
+ });
1712
+ // Check if we have context length exceeded errors in the attempt history
1713
+ const hasContextLengthError = attemptHistory.some(a => a.errorType === 'ContextLengthExceeded' ||
1714
+ ErrorAnalyzer.analyzeError(a.error).errorType === 'ContextLengthExceeded');
1715
+ // Apply strategy-specific filtering and sorting
1716
+ let candidates;
1717
+ switch (strategy) {
1718
+ case 'SameModelDifferentVendor':
1719
+ // Only consider same model with different vendors
1720
+ candidates = availableCandidates.filter(c => UUIDsEqual(c.model.ID, currentModel.ID) && !UUIDsEqual(c.vendorId, currentVendorId));
1721
+ break;
1722
+ case 'NextBestModel':
1723
+ // Consider all models, apply model strategy preference
1724
+ candidates = availableCandidates;
1725
+ if (modelStrategy === 'RequireSameModel') {
1726
+ candidates = candidates.filter(c => UUIDsEqual(c.model.ID, currentModel.ID));
1727
+ }
1728
+ else if (modelStrategy === 'PreferSameModel') {
1729
+ // Sort to put same model first
1730
+ candidates.sort((a, b) => {
1731
+ const aSameModel = UUIDsEqual(a.model.ID, currentModel.ID) ? 1 : 0;
1732
+ const bSameModel = UUIDsEqual(b.model.ID, currentModel.ID) ? 1 : 0;
1733
+ return bSameModel - aSameModel;
1734
+ });
1735
+ }
1736
+ else if (modelStrategy === 'PreferDifferentModel') {
1737
+ // Sort to put different models first
1738
+ candidates.sort((a, b) => {
1739
+ const aDiffModel = !UUIDsEqual(a.model.ID, currentModel.ID) ? 1 : 0;
1740
+ const bDiffModel = !UUIDsEqual(b.model.ID, currentModel.ID) ? 1 : 0;
1741
+ return bDiffModel - aDiffModel;
1742
+ });
1743
+ }
1744
+ break;
1745
+ case 'PowerRank':
1746
+ // Use all candidates, they're already sorted by power rank
1747
+ candidates = availableCandidates;
1748
+ break;
1749
+ default:
1750
+ candidates = [];
1751
+ }
1752
+ // If we have context length errors, prioritize models with larger context windows
1753
+ if (hasContextLengthError) {
1754
+ const currentMaxTokens = currentModel.ModelVendors?.length > 0 ?
1755
+ Math.max(...currentModel.ModelVendors.map(mv => mv.MaxInputTokens || 0)) : 0;
1756
+ // Filter out models with same or smaller context windows
1757
+ candidates = candidates.filter(c => {
1758
+ const candidateMaxTokens = c.model.ModelVendors?.length > 0 ?
1759
+ Math.max(...c.model.ModelVendors.map(mv => mv.MaxInputTokens || 0)) : 0;
1760
+ return candidateMaxTokens > currentMaxTokens;
1761
+ });
1762
+ // If no larger models exist, this is a fatal error - return empty to stop retrying
1763
+ if (candidates.length === 0) {
1764
+ LogStatusEx({
1765
+ message: `❌ Context length exceeded and no models with larger context windows available. Current model: ${currentModel.Name} (${currentMaxTokens} max tokens). This is a fatal error.`,
1766
+ category: 'AI',
1767
+ additionalArgs: [{
1768
+ currentModel: currentModel.Name,
1769
+ currentMaxTokens,
1770
+ availableModels: allCandidates.map(c => c.model.Name).join(', '),
1771
+ reason: 'No models with larger context windows available for failover'
1772
+ }]
1773
+ });
1774
+ // Return empty array - caller will see no candidates and stop retrying
1775
+ return [];
1776
+ }
1777
+ // Sort by priority first (existing algorithm), then by context window size as tiebreaker
1778
+ candidates.sort((a, b) => {
1779
+ // Primary sort: priority (higher is better) - maintains existing algorithm
1780
+ if (a.priority !== b.priority) {
1781
+ return b.priority - a.priority;
1782
+ }
1783
+ // Secondary sort: context window size (largest first) - only as tiebreaker
1784
+ const aMaxTokens = a.model.ModelVendors?.length > 0 ?
1785
+ Math.max(...a.model.ModelVendors.map((mv) => mv.MaxInputTokens || 0)) : 0;
1786
+ const bMaxTokens = b.model.ModelVendors?.length > 0 ?
1787
+ Math.max(...b.model.ModelVendors.map((mv) => mv.MaxInputTokens || 0)) : 0;
1788
+ return bMaxTokens - aMaxTokens;
1789
+ });
1790
+ // Log context-aware failover selection
1791
+ const bestCandidate = candidates[0];
1792
+ const bestCandidateMaxTokens = bestCandidate.model.ModelVendors?.length > 0 ?
1793
+ Math.max(...bestCandidate.model.ModelVendors.map((mv) => mv.MaxInputTokens || 0)) : 0;
1794
+ LogStatusEx({
1795
+ message: `🔄 Context-aware failover: Selected model ${bestCandidate.model.Name} with ${bestCandidateMaxTokens} max input tokens (vs ${currentMaxTokens} for failed model)`,
1796
+ category: 'AI',
1797
+ additionalArgs: [{
1798
+ currentModel: currentModel.Name,
1799
+ currentMaxTokens,
1800
+ selectedModel: bestCandidate.model.Name,
1801
+ selectedMaxTokens: bestCandidateMaxTokens,
1802
+ candidateCount: candidates.length
1803
+ }]
1804
+ });
1805
+ }
1806
+ else {
1807
+ // Final sort by priority (higher is better) for non-context-length errors
1808
+ candidates.sort((a, b) => b.priority - a.priority);
1809
+ }
1810
+ return candidates;
1811
+ }
1812
+ /**
1813
+ * Logs a failover attempt for tracking and debugging.
1814
+ *
1815
+ * @param promptId - The ID of the prompt being executed
1816
+ * @param attempt - The failover attempt details
1817
+ * @param willRetry - Whether another attempt will be made
1818
+ *
1819
+ * @remarks
1820
+ * This method logs detailed information about each failover attempt to help with
1821
+ * debugging and monitoring. Override this method to implement custom logging or
1822
+ * integrate with external monitoring systems.
1823
+ */
1824
+ logFailoverAttempt(promptId, attempt, willRetry) {
1825
+ const message = `Failover attempt ${attempt.attemptNumber} for prompt ${promptId}`;
1826
+ const metadata = {
1827
+ promptId,
1828
+ attemptNumber: attempt.attemptNumber,
1829
+ modelId: attempt.modelId,
1830
+ vendorId: attempt.vendorId,
1831
+ errorType: attempt.errorType,
1832
+ duration: attempt.duration,
1833
+ willRetry,
1834
+ error: attempt.error.message
1835
+ };
1836
+ if (willRetry) {
1837
+ LogStatusEx({
1838
+ message: `⚡ ${message}`,
1839
+ category: 'AI',
1840
+ additionalArgs: [metadata]
1841
+ });
1842
+ }
1843
+ else {
1844
+ LogErrorEx({
1845
+ message: message,
1846
+ error: attempt.error,
1847
+ category: 'AI',
1848
+ severity: 'error',
1849
+ metadata: metadata
1850
+ });
1851
+ }
1852
+ }
1853
+ }
1854
+ //# sourceMappingURL=BaseModelRunner.js.map