libre-webui 0.15.1 → 0.16.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.
Files changed (205) hide show
  1. package/README.md +18 -8
  2. package/backend/dist/db.d.ts.map +1 -1
  3. package/backend/dist/db.js +63 -0
  4. package/backend/dist/db.js.map +1 -1
  5. package/backend/dist/index.d.ts.map +1 -1
  6. package/backend/dist/index.js +4 -4
  7. package/backend/dist/index.js.map +1 -1
  8. package/backend/dist/routes/chat.d.ts.map +1 -1
  9. package/backend/dist/routes/chat.js +93 -10
  10. package/backend/dist/routes/chat.js.map +1 -1
  11. package/backend/dist/routes/imageGen.d.ts.map +1 -1
  12. package/backend/dist/routes/imageGen.js +52 -29
  13. package/backend/dist/routes/imageGen.js.map +1 -1
  14. package/backend/dist/routes/plugins.d.ts.map +1 -1
  15. package/backend/dist/routes/plugins.js +226 -42
  16. package/backend/dist/routes/plugins.js.map +1 -1
  17. package/backend/dist/routes/preferences.d.ts.map +1 -1
  18. package/backend/dist/routes/preferences.js +5 -4
  19. package/backend/dist/routes/preferences.js.map +1 -1
  20. package/backend/dist/routes/tts.js +2 -2
  21. package/backend/dist/routes/tts.js.map +1 -1
  22. package/backend/dist/routes/work.d.ts.map +1 -1
  23. package/backend/dist/routes/work.js +2 -1
  24. package/backend/dist/routes/work.js.map +1 -1
  25. package/backend/dist/services/assistantCompletionService.d.ts +2 -1
  26. package/backend/dist/services/assistantCompletionService.d.ts.map +1 -1
  27. package/backend/dist/services/assistantCompletionService.js +3 -1
  28. package/backend/dist/services/assistantCompletionService.js.map +1 -1
  29. package/backend/dist/services/chatGenerationService.d.ts +5 -3
  30. package/backend/dist/services/chatGenerationService.d.ts.map +1 -1
  31. package/backend/dist/services/chatGenerationService.js +18 -6
  32. package/backend/dist/services/chatGenerationService.js.map +1 -1
  33. package/backend/dist/services/chatRequestService.d.ts +6 -4
  34. package/backend/dist/services/chatRequestService.d.ts.map +1 -1
  35. package/backend/dist/services/chatRequestService.js +10 -2
  36. package/backend/dist/services/chatRequestService.js.map +1 -1
  37. package/backend/dist/services/chatService.d.ts +2 -2
  38. package/backend/dist/services/chatService.d.ts.map +1 -1
  39. package/backend/dist/services/chatService.js +29 -17
  40. package/backend/dist/services/chatService.js.map +1 -1
  41. package/backend/dist/services/embeddingService.js +1 -1
  42. package/backend/dist/services/embeddingService.js.map +1 -1
  43. package/backend/dist/services/pluginActivationService.d.ts +11 -0
  44. package/backend/dist/services/pluginActivationService.d.ts.map +1 -0
  45. package/backend/dist/services/pluginActivationService.js +163 -0
  46. package/backend/dist/services/pluginActivationService.js.map +1 -0
  47. package/backend/dist/services/pluginCapabilityRegistryService.d.ts +1 -1
  48. package/backend/dist/services/pluginCapabilityRegistryService.d.ts.map +1 -1
  49. package/backend/dist/services/pluginCapabilityRegistryService.js +1 -1
  50. package/backend/dist/services/pluginCapabilityRegistryService.js.map +1 -1
  51. package/backend/dist/services/pluginCredentialsService.d.ts +16 -8
  52. package/backend/dist/services/pluginCredentialsService.d.ts.map +1 -1
  53. package/backend/dist/services/pluginCredentialsService.js +47 -14
  54. package/backend/dist/services/pluginCredentialsService.js.map +1 -1
  55. package/backend/dist/services/pluginEmbeddingService.d.ts +2 -2
  56. package/backend/dist/services/pluginEmbeddingService.d.ts.map +1 -1
  57. package/backend/dist/services/pluginEmbeddingService.js +33 -13
  58. package/backend/dist/services/pluginEmbeddingService.js.map +1 -1
  59. package/backend/dist/services/pluginImageGenerationService.d.ts +11 -7
  60. package/backend/dist/services/pluginImageGenerationService.d.ts.map +1 -1
  61. package/backend/dist/services/pluginImageGenerationService.js +265 -81
  62. package/backend/dist/services/pluginImageGenerationService.js.map +1 -1
  63. package/backend/dist/services/pluginService.d.ts +97 -26
  64. package/backend/dist/services/pluginService.d.ts.map +1 -1
  65. package/backend/dist/services/pluginService.js +797 -178
  66. package/backend/dist/services/pluginService.js.map +1 -1
  67. package/backend/dist/services/pluginTTSService.d.ts +4 -4
  68. package/backend/dist/services/pluginTTSService.d.ts.map +1 -1
  69. package/backend/dist/services/pluginTTSService.js +24 -28
  70. package/backend/dist/services/pluginTTSService.js.map +1 -1
  71. package/backend/dist/services/pluginVariablesService.d.ts +6 -1
  72. package/backend/dist/services/pluginVariablesService.d.ts.map +1 -1
  73. package/backend/dist/services/pluginVariablesService.js +59 -1
  74. package/backend/dist/services/pluginVariablesService.js.map +1 -1
  75. package/backend/dist/services/preferencesService.d.ts +2 -2
  76. package/backend/dist/services/preferencesService.d.ts.map +1 -1
  77. package/backend/dist/services/preferencesService.js +78 -10
  78. package/backend/dist/services/preferencesService.js.map +1 -1
  79. package/backend/dist/services/titleGenerationService.d.ts +6 -4
  80. package/backend/dist/services/titleGenerationService.d.ts.map +1 -1
  81. package/backend/dist/services/titleGenerationService.js +9 -5
  82. package/backend/dist/services/titleGenerationService.js.map +1 -1
  83. package/backend/dist/services/workAgentService.d.ts +5 -2
  84. package/backend/dist/services/workAgentService.d.ts.map +1 -1
  85. package/backend/dist/services/workAgentService.js +315 -44
  86. package/backend/dist/services/workAgentService.js.map +1 -1
  87. package/backend/dist/services/workModelProviderService.d.ts +6 -3
  88. package/backend/dist/services/workModelProviderService.d.ts.map +1 -1
  89. package/backend/dist/services/workModelProviderService.js +243 -19
  90. package/backend/dist/services/workModelProviderService.js.map +1 -1
  91. package/backend/dist/services/workRuntimeService.d.ts +18 -1
  92. package/backend/dist/services/workRuntimeService.d.ts.map +1 -1
  93. package/backend/dist/services/workRuntimeService.js +62 -11
  94. package/backend/dist/services/workRuntimeService.js.map +1 -1
  95. package/backend/dist/services/workTaskService.d.ts +2 -0
  96. package/backend/dist/services/workTaskService.d.ts.map +1 -1
  97. package/backend/dist/services/workTaskService.js +89 -14
  98. package/backend/dist/services/workTaskService.js.map +1 -1
  99. package/backend/dist/storage.d.ts.map +1 -1
  100. package/backend/dist/storage.js +9 -6
  101. package/backend/dist/storage.js.map +1 -1
  102. package/backend/dist/storageMappers.d.ts +3 -0
  103. package/backend/dist/storageMappers.d.ts.map +1 -1
  104. package/backend/dist/storageMappers.js +3 -0
  105. package/backend/dist/storageMappers.js.map +1 -1
  106. package/backend/dist/types/index.d.ts +54 -1
  107. package/backend/dist/types/index.d.ts.map +1 -1
  108. package/backend/dist/types/index.js.map +1 -1
  109. package/backend/dist/types/work.d.ts +1 -1
  110. package/backend/dist/types/work.d.ts.map +1 -1
  111. package/backend/dist/utils/chatContext.d.ts +13 -1
  112. package/backend/dist/utils/chatContext.d.ts.map +1 -1
  113. package/backend/dist/utils/chatContext.js +52 -0
  114. package/backend/dist/utils/chatContext.js.map +1 -1
  115. package/backend/dist/utils/chatProviderSelection.d.ts +15 -0
  116. package/backend/dist/utils/chatProviderSelection.d.ts.map +1 -0
  117. package/backend/dist/utils/chatProviderSelection.js +59 -0
  118. package/backend/dist/utils/chatProviderSelection.js.map +1 -0
  119. package/backend/dist/utils/imageGenerationValidation.d.ts +3 -0
  120. package/backend/dist/utils/imageGenerationValidation.d.ts.map +1 -0
  121. package/backend/dist/utils/imageGenerationValidation.js +55 -0
  122. package/backend/dist/utils/imageGenerationValidation.js.map +1 -0
  123. package/backend/dist/utils/openAIResponsesAdapter.d.ts +88 -0
  124. package/backend/dist/utils/openAIResponsesAdapter.d.ts.map +1 -0
  125. package/backend/dist/utils/openAIResponsesAdapter.js +521 -0
  126. package/backend/dist/utils/openAIResponsesAdapter.js.map +1 -0
  127. package/backend/dist/utils/pluginChatAdapter.d.ts +7 -4
  128. package/backend/dist/utils/pluginChatAdapter.d.ts.map +1 -1
  129. package/backend/dist/utils/pluginChatAdapter.js +33 -5
  130. package/backend/dist/utils/pluginChatAdapter.js.map +1 -1
  131. package/backend/dist/utils/pluginChatContext.d.ts.map +1 -1
  132. package/backend/dist/utils/pluginChatContext.js +5 -1
  133. package/backend/dist/utils/pluginChatContext.js.map +1 -1
  134. package/backend/dist/utils/pluginConnectionVariables.d.ts +6 -0
  135. package/backend/dist/utils/pluginConnectionVariables.d.ts.map +1 -0
  136. package/backend/dist/utils/pluginConnectionVariables.js +52 -0
  137. package/backend/dist/utils/pluginConnectionVariables.js.map +1 -0
  138. package/backend/dist/utils/pluginDefinitionTrust.d.ts +9 -0
  139. package/backend/dist/utils/pluginDefinitionTrust.d.ts.map +1 -0
  140. package/backend/dist/utils/pluginDefinitionTrust.js +71 -0
  141. package/backend/dist/utils/pluginDefinitionTrust.js.map +1 -0
  142. package/backend/dist/utils/pluginStreamAdapter.d.ts +3 -0
  143. package/backend/dist/utils/pluginStreamAdapter.d.ts.map +1 -1
  144. package/backend/dist/utils/pluginStreamAdapter.js +410 -0
  145. package/backend/dist/utils/pluginStreamAdapter.js.map +1 -1
  146. package/backend/dist/utils/pluginStreaming.d.ts +7 -1
  147. package/backend/dist/utils/pluginStreaming.d.ts.map +1 -1
  148. package/backend/dist/utils/pluginStreaming.js +12 -1
  149. package/backend/dist/utils/pluginStreaming.js.map +1 -1
  150. package/backend/dist/utils/pluginValidation.d.ts +20 -2
  151. package/backend/dist/utils/pluginValidation.d.ts.map +1 -1
  152. package/backend/dist/utils/pluginValidation.js +187 -17
  153. package/backend/dist/utils/pluginValidation.js.map +1 -1
  154. package/backend/dist/utils/pluginVariableValidation.d.ts +7 -0
  155. package/backend/dist/utils/pluginVariableValidation.d.ts.map +1 -1
  156. package/backend/dist/utils/pluginVariableValidation.js +79 -4
  157. package/backend/dist/utils/pluginVariableValidation.js.map +1 -1
  158. package/backend/dist/websocketServer.d.ts.map +1 -1
  159. package/backend/dist/websocketServer.js +16 -3
  160. package/backend/dist/websocketServer.js.map +1 -1
  161. package/backend/package.json +1 -1
  162. package/frontend/dist/assets/index-BVgyQN3z.js +5 -0
  163. package/frontend/dist/assets/index-Dme2Ozol.css +1 -0
  164. package/frontend/dist/index.html +3 -3
  165. package/frontend/dist/js/{ArtifactContainer-BHSBrlQB.js → ArtifactContainer-CbMAYwvr.js} +1 -1
  166. package/frontend/dist/js/{ArtifactDemoPage-BHGJmJAT.js → ArtifactDemoPage-CWDhGWrz.js} +1 -1
  167. package/frontend/dist/js/ArtifactSlideOutPanel-CzUpDM59.js +1 -0
  168. package/frontend/dist/js/ChatPage-D0fdzy7a.js +16 -0
  169. package/frontend/dist/js/{FirstTimeSetup-BYWAVAdE.js → FirstTimeSetup-CfnOmy9w.js} +1 -1
  170. package/frontend/dist/js/GalleryPage-CMkCur9v.js +1 -0
  171. package/frontend/dist/js/LibreClawPage-CHEfny1k.js +1 -0
  172. package/frontend/dist/js/ModelSelector-COnlNsVM.js +1 -0
  173. package/frontend/dist/js/ModelsPage-nxheiYJp.js +2 -0
  174. package/frontend/dist/js/PersonasPage-C2pqDsWA.js +13 -0
  175. package/frontend/dist/js/{RichMessageContent-BdU5vXz9.js → RichMessageContent-Cz9y-bgl.js} +1 -1
  176. package/frontend/dist/js/SettingsModal-B6Se68Hd.js +1 -0
  177. package/frontend/dist/js/UserManagementPage-DQeL-Twp.js +1 -0
  178. package/frontend/dist/js/WorkPage-AcZMp8OB.js +2 -0
  179. package/frontend/dist/js/api-Fi-lJlfu.js +19 -0
  180. package/frontend/dist/js/{estree-Bu_-Wi6O.js → estree-BRklD62a.js} +1 -1
  181. package/frontend/dist/js/{html-Ca764Rus.js → html-BgGIAGmn.js} +1 -1
  182. package/frontend/dist/js/{markdown-DaiQGPEs.js → markdown-CM2nBJJi.js} +1 -1
  183. package/frontend/dist/js/{standalone-DWWnsbIr.js → standalone-CUBo-NGt.js} +1 -1
  184. package/frontend/dist/js/{typescript-CatjJeHm.js → typescript-Dcy9qxSB.js} +1 -1
  185. package/frontend/dist/js/useBaseQuery-CFTidwqz.js +1 -0
  186. package/frontend/dist/js/useQuery-UsSBfWzs.js +1 -0
  187. package/package.json +2 -2
  188. package/plugins/github.json +1 -2
  189. package/plugins/huggingface.json +30 -30
  190. package/plugins/openai.json +53 -22
  191. package/frontend/dist/assets/index-DkShhNBf.js +0 -5
  192. package/frontend/dist/assets/index-bdSWBsAM.css +0 -1
  193. package/frontend/dist/js/ArtifactSlideOutPanel-s8vcE5Ea.js +0 -1
  194. package/frontend/dist/js/ChatPage-B6Hm_pdu.js +0 -16
  195. package/frontend/dist/js/GalleryPage-CH4f65q8.js +0 -1
  196. package/frontend/dist/js/LibreClawPage-CcH7M3mJ.js +0 -1
  197. package/frontend/dist/js/ModelSelector-T1KZtIde.js +0 -1
  198. package/frontend/dist/js/ModelsPage-C1bM_Yoc.js +0 -2
  199. package/frontend/dist/js/PersonasPage-T_PrhnPG.js +0 -13
  200. package/frontend/dist/js/SettingsModal-BVj-D1L4.js +0 -1
  201. package/frontend/dist/js/UserManagementPage-Pix71oRW.js +0 -1
  202. package/frontend/dist/js/WorkPage-DdOVGyj_.js +0 -2
  203. package/frontend/dist/js/api-Do6u0tMM.js +0 -19
  204. package/frontend/dist/js/useBaseQuery-CVNuhuID.js +0 -1
  205. package/frontend/dist/js/useQuery-CYhC_da4.js +0 -1
@@ -16,23 +16,109 @@
16
16
  */
17
17
  import fs from 'fs';
18
18
  import path from 'path';
19
+ import { createHash } from 'crypto';
19
20
  import sanitize from 'sanitize-filename';
20
21
  import axios from 'axios';
21
22
  import pluginCredentialsService from './pluginCredentialsService.js';
22
23
  import pluginVariablesService from './pluginVariablesService.js';
24
+ import pluginActivationService from './pluginActivationService.js';
23
25
  import { PluginCapabilityRegistryService } from './pluginCapabilityRegistryService.js';
24
26
  import { PluginEmbeddingService } from './pluginEmbeddingService.js';
25
27
  import { PluginImageGenerationService } from './pluginImageGenerationService.js';
26
28
  import { PluginTTSService } from './pluginTTSService.js';
27
29
  import { applyPluginDefinitionPolicy, buildPluginChatPayload, convertProviderResponse, getOpenAICompatibleSamplingParameters, resolvePluginChatParameters, toOpenAICompatibleMessages, } from '../utils/pluginChatAdapter.js';
28
- import { streamAnthropicResponse, streamOpenAICompatibleResponse, } from '../utils/pluginStreamAdapter.js';
29
- import { applyModelEndpointTemplate, assertSafePluginEndpoint, buildPluginAuthHeaders, buildPluginModelDiscoveryHeaders, pluginRequiresApiKey, resolvePluginEndpoint, resolvePluginModelsEndpoint, validatePluginEndpointOverride, validatePluginModel, } from '../utils/pluginValidation.js';
30
+ import { streamAnthropicResponse, streamOpenAICompatibleResponse, streamOpenAIResponsesResponse, } from '../utils/pluginStreamAdapter.js';
31
+ import { applyModelEndpointTemplate, assertSafePluginEndpoint, buildPluginAuthHeaders, buildPluginModelDiscoveryHeaders, pluginRequiresApiKey, resolvePluginApiConfig, resolvePluginEndpoint, resolvePluginModelsEndpoint, validatePluginModel, } from '../utils/pluginValidation.js';
30
32
  import { createLogger } from '../utils/logger.js';
31
33
  import { resolveBundledPluginsDir } from '../utils/packagePaths.js';
34
+ import { getDatabaseSafe } from '../db.js';
35
+ import { createOpenAIResponsesStateScope, createPluginCredentialFingerprint, OPENAI_RESPONSES_INCOMPLETE_REASON_METADATA_KEY, } from '../utils/openAIResponsesAdapter.js';
36
+ import { getPluginConnectionVariableNames } from '../utils/pluginConnectionVariables.js';
37
+ import { getPluginDefinitionFingerprint, matchesBundledPluginTrustAnchor, } from '../utils/pluginDefinitionTrust.js';
32
38
  const logger = createLogger('plugins');
33
- class PluginService {
39
+ const readPositiveIntEnv = (name, fallback) => {
40
+ const parsed = Number(process.env[name]);
41
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
42
+ };
43
+ // A discovered catalog older than this is refreshed the next time the plugin
44
+ // list is requested, so a browser reload picks up provider-side model changes.
45
+ const modelDiscoveryTtlMs = () => readPositiveIntEnv('PLUGIN_MODEL_DISCOVERY_TTL_MS', 6 * 60 * 60 * 1000);
46
+ // Backoff between attempts. Applies to failures too, so an unreachable provider
47
+ // is retried periodically instead of on every request.
48
+ const modelDiscoveryRetryMs = () => readPositiveIntEnv('PLUGIN_MODEL_DISCOVERY_RETRY_MS', 10 * 60 * 1000);
49
+ // Upper bound on how long a plugin-list request waits for background refreshes.
50
+ // Slower providers keep resolving and land in the next response.
51
+ const modelDiscoveryRefreshDeadlineMs = () => readPositiveIntEnv('PLUGIN_MODEL_DISCOVERY_REFRESH_DEADLINE_MS', 3000);
52
+ const MODEL_DISCOVERY_REQUEST_TIMEOUT_MS = 5000;
53
+ function describeModelDiscoveryFailure(error) {
54
+ if (axios.isAxiosError(error)) {
55
+ if (error.response) {
56
+ return `The provider responded with HTTP ${error.response.status}`;
57
+ }
58
+ if (error.code === 'ECONNABORTED') {
59
+ return 'The provider did not respond in time';
60
+ }
61
+ return error.code
62
+ ? `Could not reach the provider (${error.code})`
63
+ : 'Could not reach the provider';
64
+ }
65
+ return error instanceof Error ? error.message : 'Unknown error';
66
+ }
67
+ function getPluginRoutingAuthProjection(plugin) {
68
+ const capabilityEndpoints = Object.entries((plugin.capabilities || {}))
69
+ .sort(([left], [right]) => left.localeCompare(right))
70
+ .map(([name, value]) => {
71
+ const capability = value && typeof value === 'object'
72
+ ? value
73
+ : {};
74
+ const config = capability.config && typeof capability.config === 'object'
75
+ ? capability.config
76
+ : {};
77
+ return {
78
+ name,
79
+ endpoint: capability.endpoint ?? null,
80
+ endpoint_variable: config.endpoint_variable ?? capability.endpoint_variable ?? null,
81
+ };
82
+ });
83
+ const definitions = new Map((plugin.variables || []).map(definition => [definition.name, definition]));
84
+ const connectionVariables = Array.from(getPluginConnectionVariableNames(plugin), name => {
85
+ const definition = definitions.get(name);
86
+ if (!definition)
87
+ return null;
88
+ return {
89
+ name: definition.name,
90
+ type: definition.type,
91
+ label: definition.label,
92
+ description: definition.description ?? null,
93
+ default: definition.default ?? null,
94
+ required: definition.required ?? null,
95
+ sensitive: definition.sensitive ?? null,
96
+ options: definition.options ?? null,
97
+ min: definition.min ?? null,
98
+ max: definition.max ?? null,
99
+ };
100
+ });
101
+ return JSON.stringify({
102
+ endpoint: plugin.endpoint,
103
+ base_url: plugin.base_url ?? null,
104
+ api_path: plugin.api_path ?? null,
105
+ api_mode: plugin.api_mode ?? null,
106
+ auth: {
107
+ header: plugin.auth.header,
108
+ prefix: plugin.auth.prefix ?? '',
109
+ key_env: plugin.auth.key_env,
110
+ },
111
+ capabilityEndpoints,
112
+ connectionVariables,
113
+ });
114
+ }
115
+ export class PluginService {
34
116
  constructor() {
35
- this.activePluginIds = new Set();
117
+ this.discoveredModelsCache = new Map();
118
+ this.discoveredModelsUpdatedAt = new Map();
119
+ this.discoveryAttemptedAt = new Map();
120
+ this.inflightDiscovery = new Map();
121
+ this.bundledRoutingProjectionCache = new Map();
36
122
  this.bundledPluginsDir = resolveBundledPluginsDir(import.meta.url);
37
123
  this.legacyPluginsDir = path.join(process.cwd(), 'plugins');
38
124
  this.pluginsDir =
@@ -42,67 +128,290 @@ class PluginService {
42
128
  : this.legacyPluginsDir);
43
129
  this.pluginReadDirs = Array.from(new Set([this.bundledPluginsDir, this.legacyPluginsDir, this.pluginsDir]));
44
130
  this.ensurePluginsDirectory();
45
- this.loadActivePlugins();
131
+ pluginActivationService.migrateLegacyStatus([this.pluginsDir, this.legacyPluginsDir], pluginId => this.canMigrateLegacyActivation(pluginId));
46
132
  this.embeddingService = new PluginEmbeddingService({
47
- getAllPlugins: () => this.getAllPlugins(),
133
+ getAllPlugins: userId => this.getActivePlugins(userId),
48
134
  getApiKey: (plugin, userId) => this.getApiKey(plugin, userId),
49
135
  getPluginVariables: (plugin, userId) => this.getPluginVariables(plugin, userId),
50
136
  validateEndpointUrl: endpoint => this.validateEndpointUrl(endpoint),
51
137
  });
52
138
  this.ttsService = new PluginTTSService({
53
- getAllPlugins: () => this.getAllPlugins(),
54
- getPlugin: id => this.getPlugin(id),
139
+ getAllPlugins: userId => this.getActivePlugins(userId),
140
+ getPlugin: (id, userId) => this.getPlugin(id, userId),
55
141
  getApiKey: (plugin, userId) => this.getApiKey(plugin, userId),
56
142
  getPluginVariables: (plugin, userId) => this.getPluginVariables(plugin, userId),
57
143
  validateEndpointUrl: endpoint => this.validateEndpointUrl(endpoint),
58
144
  });
59
145
  this.imageGenerationService = new PluginImageGenerationService({
60
- getAllPlugins: () => this.getAllPlugins(),
61
- getPlugin: id => this.getPlugin(id),
146
+ getAllPlugins: userId => this.getActivePlugins(userId),
147
+ getPlugin: (id, userId) => this.getPlugin(id, userId),
62
148
  getApiKey: (plugin, userId) => this.getApiKey(plugin, userId),
63
149
  getPluginVariables: (plugin, userId) => this.getPluginVariables(plugin, userId),
64
150
  validateEndpointUrl: endpoint => this.validateEndpointUrl(endpoint),
65
151
  });
66
152
  this.capabilityRegistryService = new PluginCapabilityRegistryService({
67
- getAllPlugins: () => this.getAllPlugins(),
153
+ getAllPlugins: userId => this.getActivePlugins(userId),
68
154
  getApiKey: (plugin, userId) => this.getApiKey(plugin, userId),
69
155
  });
70
156
  }
157
+ discoveredModelsCacheKey(pluginId, userId) {
158
+ return `${userId}:${pluginId}`;
159
+ }
160
+ getDiscoveredModels(pluginId, userId) {
161
+ const effectiveUserId = userId || 'default';
162
+ const cacheKey = this.discoveredModelsCacheKey(pluginId, effectiveUserId);
163
+ if (this.discoveredModelsCache.has(cacheKey)) {
164
+ return this.discoveredModelsCache.get(cacheKey) || undefined;
165
+ }
166
+ const db = getDatabaseSafe();
167
+ if (!db) {
168
+ this.discoveredModelsCache.set(cacheKey, null);
169
+ return undefined;
170
+ }
171
+ try {
172
+ const row = db
173
+ .prepare('SELECT models_json, updated_at FROM plugin_discovered_models WHERE user_id = ? AND plugin_id = ?')
174
+ .get(effectiveUserId, pluginId);
175
+ if (!row) {
176
+ this.discoveredModelsCache.set(cacheKey, null);
177
+ return undefined;
178
+ }
179
+ const parsed = JSON.parse(row.models_json);
180
+ const models = Array.isArray(parsed)
181
+ ? Array.from(new Set(parsed.filter((model) => typeof model === 'string' && model.length > 0)))
182
+ : [];
183
+ if (models.length === 0) {
184
+ this.discoveredModelsCache.set(cacheKey, null);
185
+ return undefined;
186
+ }
187
+ this.discoveredModelsCache.set(cacheKey, models);
188
+ if (typeof row.updated_at === 'number') {
189
+ this.discoveredModelsUpdatedAt.set(cacheKey, row.updated_at);
190
+ }
191
+ return models;
192
+ }
193
+ catch (error) {
194
+ logger.warn('Failed to read discovered models for plugin %s:', pluginId, error);
195
+ this.discoveredModelsCache.set(cacheKey, null);
196
+ return undefined;
197
+ }
198
+ }
199
+ storeDiscoveredModels(pluginId, models, userId) {
200
+ const effectiveUserId = userId || 'default';
201
+ const uniqueModels = Array.from(new Set(models));
202
+ const cacheKey = this.discoveredModelsCacheKey(pluginId, effectiveUserId);
203
+ const discoveredAt = Date.now();
204
+ this.discoveredModelsCache.set(cacheKey, uniqueModels);
205
+ this.discoveredModelsUpdatedAt.set(cacheKey, discoveredAt);
206
+ const db = getDatabaseSafe();
207
+ if (!db)
208
+ return;
209
+ try {
210
+ db.prepare(`INSERT INTO plugin_discovered_models
211
+ (user_id, plugin_id, models_json, updated_at)
212
+ VALUES (?, ?, ?, ?)
213
+ ON CONFLICT(user_id, plugin_id) DO UPDATE SET
214
+ models_json = excluded.models_json,
215
+ updated_at = excluded.updated_at`).run(effectiveUserId, pluginId, JSON.stringify(uniqueModels), discoveredAt);
216
+ }
217
+ catch (error) {
218
+ logger.warn('Failed to persist discovered models for plugin %s:', pluginId, error);
219
+ }
220
+ }
221
+ clearDiscoveredModels(pluginId, userId) {
222
+ const db = getDatabaseSafe();
223
+ if (userId) {
224
+ const cacheKey = this.discoveredModelsCacheKey(pluginId, userId);
225
+ this.discoveredModelsCache.set(cacheKey, null);
226
+ this.discoveredModelsUpdatedAt.delete(cacheKey);
227
+ this.discoveryAttemptedAt.delete(cacheKey);
228
+ if (!db)
229
+ return;
230
+ try {
231
+ db.prepare('DELETE FROM plugin_discovered_models WHERE user_id = ? AND plugin_id = ?').run(userId, pluginId);
232
+ }
233
+ catch (error) {
234
+ logger.warn('Failed to clear discovered models for plugin %s:', pluginId, error);
235
+ }
236
+ return;
237
+ }
238
+ for (const key of this.discoveredModelsCache.keys()) {
239
+ if (key.endsWith(`:${pluginId}`)) {
240
+ this.discoveredModelsCache.delete(key);
241
+ this.discoveredModelsUpdatedAt.delete(key);
242
+ this.discoveryAttemptedAt.delete(key);
243
+ }
244
+ }
245
+ if (!db)
246
+ return;
247
+ try {
248
+ db.prepare('DELETE FROM plugin_discovered_models WHERE plugin_id = ?').run(pluginId);
249
+ }
250
+ catch (error) {
251
+ logger.warn('Failed to clear discovered models for plugin %s:', pluginId, error);
252
+ }
253
+ }
254
+ applyDiscoveredModels(plugin, userId) {
255
+ if (!this.canUseStoredConnectionOverrides(userId) &&
256
+ pluginVariablesService.hasStoredConnectionOverride(plugin.id, userId, plugin.variables, getPluginConnectionVariableNames(plugin))) {
257
+ return plugin;
258
+ }
259
+ const models = this.getDiscoveredModels(plugin.id, userId);
260
+ return models ? { ...plugin, model_map: [...models] } : plugin;
261
+ }
71
262
  ensurePluginsDirectory() {
72
263
  if (!fs.existsSync(this.pluginsDir)) {
73
264
  fs.mkdirSync(this.pluginsDir, { recursive: true });
74
265
  }
75
266
  }
76
- loadActivePlugins() {
77
- const statusDirs = Array.from(new Set([this.pluginsDir, this.legacyPluginsDir]));
78
- for (const statusDir of statusDirs) {
79
- const statusFile = path.join(statusDir, '.status.json');
80
- if (!fs.existsSync(statusFile)) {
81
- continue;
82
- }
83
- try {
84
- const status = JSON.parse(fs.readFileSync(statusFile, 'utf8'));
85
- if (Array.isArray(status.activePlugins)) {
86
- this.activePluginIds = new Set(status.activePlugins);
87
- }
88
- else if (status.activePlugin) {
89
- // Legacy support for single active plugin
90
- this.activePluginIds = new Set([status.activePlugin]);
91
- }
92
- return;
267
+ bundledPluginPath(id) {
268
+ return path.resolve(this.bundledPluginsDir, `${sanitize(id)}.json`);
269
+ }
270
+ isAnchoredBundledDefinition(plugin, filePath) {
271
+ return (path.resolve(filePath) === this.bundledPluginPath(plugin.id) &&
272
+ matchesBundledPluginTrustAnchor(plugin));
273
+ }
274
+ isPluginDefinitionApproved(plugin, filePath) {
275
+ if (this.isAnchoredBundledDefinition(plugin, filePath)) {
276
+ return true;
277
+ }
278
+ const db = getDatabaseSafe();
279
+ if (!db)
280
+ return false;
281
+ try {
282
+ const row = db
283
+ .prepare(`SELECT definition_fingerprint, source_path
284
+ FROM plugin_definition_approvals
285
+ WHERE plugin_id = ?`)
286
+ .get(plugin.id);
287
+ return (row?.definition_fingerprint ===
288
+ getPluginDefinitionFingerprint(plugin) &&
289
+ row.source_path === path.resolve(filePath));
290
+ }
291
+ catch (error) {
292
+ logger.warn('Failed to inspect definition approval for plugin %s:', plugin.id, error);
293
+ return false;
294
+ }
295
+ }
296
+ approvePluginDefinition(plugin, filePath, userId) {
297
+ if (!this.canUseStoredConnectionOverrides(userId)) {
298
+ throw new Error('Administrator approval is required');
299
+ }
300
+ const db = getDatabaseSafe();
301
+ if (!db) {
302
+ throw new Error('Database not available for plugin approval');
303
+ }
304
+ db.prepare(`INSERT INTO plugin_definition_approvals
305
+ (plugin_id, definition_fingerprint, source_path,
306
+ approved_by_user_id, approved_at)
307
+ VALUES (?, ?, ?, ?, ?)
308
+ ON CONFLICT(plugin_id) DO UPDATE SET
309
+ definition_fingerprint = excluded.definition_fingerprint,
310
+ source_path = excluded.source_path,
311
+ approved_by_user_id = excluded.approved_by_user_id,
312
+ approved_at = excluded.approved_at`).run(plugin.id, getPluginDefinitionFingerprint(plugin), path.resolve(filePath), userId, Date.now());
313
+ }
314
+ removePluginDefinitionApproval(pluginId) {
315
+ const db = getDatabaseSafe();
316
+ if (!db)
317
+ return;
318
+ db.prepare('DELETE FROM plugin_definition_approvals WHERE plugin_id = ?').run(pluginId);
319
+ }
320
+ revokePluginDefinitionConsent(pluginId) {
321
+ const db = getDatabaseSafe();
322
+ if (!db) {
323
+ throw new Error('Database not available for plugin approval');
324
+ }
325
+ db.transaction(() => {
326
+ db.prepare('DELETE FROM plugin_activations WHERE plugin_id = ?').run(pluginId);
327
+ db.prepare('DELETE FROM plugin_definition_approvals WHERE plugin_id = ?').run(pluginId);
328
+ })();
329
+ }
330
+ canMigrateLegacyActivation(pluginId) {
331
+ const effectivePath = this.resolveEffectivePluginFilePath(pluginId);
332
+ if (!effectivePath)
333
+ return false;
334
+ try {
335
+ const parsedPlugin = JSON.parse(fs.readFileSync(effectivePath, 'utf8'));
336
+ return (this.validatePlugin(parsedPlugin) &&
337
+ parsedPlugin.id === pluginId &&
338
+ this.isAnchoredBundledDefinition(parsedPlugin, effectivePath));
339
+ }
340
+ catch {
341
+ return false;
342
+ }
343
+ }
344
+ resolveEffectivePluginFilePath(id) {
345
+ const sanitizedId = sanitize(id);
346
+ if (!sanitizedId || sanitizedId !== id)
347
+ return null;
348
+ for (const pluginsDir of [...this.pluginReadDirs].reverse()) {
349
+ const resolvedDirectory = path.resolve(pluginsDir);
350
+ const candidate = path.resolve(pluginsDir, `${sanitizedId}.json`);
351
+ if (candidate.startsWith(resolvedDirectory) && fs.existsSync(candidate)) {
352
+ return candidate;
93
353
  }
94
- catch (error) {
95
- logger.error('Failed to load plugin status:', error);
354
+ }
355
+ return null;
356
+ }
357
+ getBundledRoutingProjection(id) {
358
+ if (this.bundledRoutingProjectionCache.has(id)) {
359
+ return this.bundledRoutingProjectionCache.get(id) ?? null;
360
+ }
361
+ const sanitizedId = sanitize(id);
362
+ if (!sanitizedId || sanitizedId !== id)
363
+ return null;
364
+ const bundledDirectory = path.resolve(this.bundledPluginsDir);
365
+ const bundledPath = this.bundledPluginPath(sanitizedId);
366
+ if (!bundledPath.startsWith(bundledDirectory) ||
367
+ !fs.existsSync(bundledPath)) {
368
+ this.bundledRoutingProjectionCache.set(id, null);
369
+ return null;
370
+ }
371
+ try {
372
+ const parsedPlugin = JSON.parse(fs.readFileSync(bundledPath, 'utf8'));
373
+ if (!this.validatePlugin(parsedPlugin) ||
374
+ parsedPlugin.id !== sanitizedId ||
375
+ !matchesBundledPluginTrustAnchor(parsedPlugin)) {
376
+ this.bundledRoutingProjectionCache.set(id, null);
377
+ return null;
96
378
  }
379
+ const projection = getPluginRoutingAuthProjection(applyPluginDefinitionPolicy(parsedPlugin));
380
+ this.bundledRoutingProjectionCache.set(id, projection);
381
+ return projection;
382
+ }
383
+ catch (error) {
384
+ logger.warn('Failed to inspect bundled routing for plugin %s:', id, error);
385
+ this.bundledRoutingProjectionCache.set(id, null);
386
+ return null;
97
387
  }
98
388
  }
99
- saveActivePlugins() {
100
- const statusFile = path.join(this.pluginsDir, '.status.json');
101
- const status = {
102
- activePlugins: Array.from(this.activePluginIds),
103
- lastUpdated: new Date().toISOString(),
104
- };
105
- fs.writeFileSync(statusFile, JSON.stringify(status, null, 2));
389
+ usesTrustedBundledRouting(plugin) {
390
+ const effectivePath = this.resolveEffectivePluginFilePath(plugin.id);
391
+ const bundledPath = this.bundledPluginPath(plugin.id);
392
+ if (!effectivePath || effectivePath !== bundledPath)
393
+ return false;
394
+ const bundledProjection = this.getBundledRoutingProjection(plugin.id);
395
+ return (bundledProjection !== null &&
396
+ getPluginRoutingAuthProjection(plugin) === bundledProjection);
397
+ }
398
+ isPluginActive(pluginId, userId) {
399
+ return pluginActivationService.getActivePluginIds(userId).has(pluginId);
400
+ }
401
+ canUseStoredConnectionOverrides(userId) {
402
+ const db = getDatabaseSafe();
403
+ if (!db)
404
+ return false;
405
+ try {
406
+ const row = db
407
+ .prepare('SELECT role FROM users WHERE id = ?')
408
+ .get(userId || 'default');
409
+ return row?.role === 'admin';
410
+ }
411
+ catch (error) {
412
+ logger.warn('Failed to resolve plugin routing permission:', error);
413
+ return false;
414
+ }
106
415
  }
107
416
  /**
108
417
  * Get API key for a plugin from database (per-user) or environment variable (fallback)
@@ -111,7 +420,57 @@ class PluginService {
111
420
  * @returns The API key or null if not found
112
421
  */
113
422
  getApiKey(plugin, userId) {
114
- return pluginCredentialsService.getApiKey(plugin.id, plugin.auth.key_env, userId);
423
+ const hasHonoredConnectionOverride = this.canUseStoredConnectionOverrides(userId) &&
424
+ pluginVariablesService.hasStoredConnectionOverride(plugin.id, userId, plugin.variables, getPluginConnectionVariableNames(plugin));
425
+ const usesTrustedBundledRouting = this.usesTrustedBundledRouting(plugin);
426
+ const allowTrustedFallback = usesTrustedBundledRouting && !hasHonoredConnectionOverride;
427
+ return pluginCredentialsService.getApiKey(plugin.id, plugin.auth.key_env, userId, {
428
+ allowEnvironmentFallback: allowTrustedFallback,
429
+ expectedRoutingAuthFingerprint: this.getCredentialRoutingAuthFingerprint(plugin, userId),
430
+ allowLegacyUnboundCredential: allowTrustedFallback,
431
+ });
432
+ }
433
+ /**
434
+ * Bind a user credential to the exact routing/authentication contract in
435
+ * effect when they save it. Generation controls are intentionally excluded.
436
+ */
437
+ getCredentialRoutingAuthFingerprint(plugin, userId) {
438
+ const variables = this.getPluginVariables(plugin, userId);
439
+ const effectiveConnectionValues = Array.from(getPluginConnectionVariableNames(plugin), name => {
440
+ const definition = plugin.variables?.find(candidate => candidate.name === name);
441
+ if (!definition)
442
+ return null;
443
+ return {
444
+ name,
445
+ value: variables[name] ?? definition.default ?? '',
446
+ };
447
+ });
448
+ const effectivePath = this.resolveEffectivePluginFilePath(plugin.id);
449
+ let effectiveDefinitionFingerprint = getPluginDefinitionFingerprint(plugin);
450
+ if (effectivePath) {
451
+ try {
452
+ const effectiveDefinition = JSON.parse(fs.readFileSync(effectivePath, 'utf8'));
453
+ if (this.validatePlugin(effectiveDefinition) &&
454
+ effectiveDefinition.id === plugin.id) {
455
+ effectiveDefinitionFingerprint =
456
+ getPluginDefinitionFingerprint(effectiveDefinition);
457
+ }
458
+ }
459
+ catch {
460
+ // Keep the in-memory fingerprint. A missing/invalid source is already
461
+ // excluded from normal plugin loading and cannot gain trust here.
462
+ }
463
+ }
464
+ const fingerprintInput = JSON.stringify({
465
+ plugin_id: plugin.id,
466
+ plugin_type: plugin.type,
467
+ trusted_bundled_source: this.usesTrustedBundledRouting(plugin),
468
+ effective_source_path: effectivePath ? path.resolve(effectivePath) : null,
469
+ effective_definition_fingerprint: effectiveDefinitionFingerprint,
470
+ routing_auth_projection: getPluginRoutingAuthProjection(plugin),
471
+ effective_connection_values: effectiveConnectionValues,
472
+ });
473
+ return createHash('sha256').update(fingerprintInput).digest('hex');
115
474
  }
116
475
  /**
117
476
  * Get resolved variable values for a plugin (decrypted, typed).
@@ -120,34 +479,135 @@ class PluginService {
120
479
  if (!plugin.variables || plugin.variables.length === 0) {
121
480
  return {};
122
481
  }
123
- return pluginVariablesService.getResolvedVariables(plugin.id, plugin.variables, userId);
482
+ const variables = pluginVariablesService.getResolvedVariables(plugin.id, plugin.variables, userId);
483
+ if (this.canUseStoredConnectionOverrides(userId))
484
+ return variables;
485
+ return Object.fromEntries(Object.entries(variables).map(([name, value]) => {
486
+ if (!getPluginConnectionVariableNames(plugin).has(name)) {
487
+ return [name, value];
488
+ }
489
+ const definition = plugin.variables?.find(candidate => candidate.name === name);
490
+ return [name, definition?.default ?? ''];
491
+ }));
124
492
  }
125
493
  /**
126
- * Validate an endpoint URL for safety (SSRF protection).
127
- * Returns the URL string if valid, or null if invalid.
494
+ * Validate an endpoint override as an absolute HTTP or HTTPS URL.
495
+ * Returns the URL string if valid and throws for an invalid explicit value.
128
496
  */
129
497
  validateEndpointUrl(endpoint) {
130
- return validatePluginEndpointOverride(endpoint);
498
+ return resolvePluginEndpoint('', endpoint);
499
+ }
500
+ /**
501
+ * Resolve and validate routing before credential lookup. The credential
502
+ * service's custom-route policy is integrated separately; keeping this
503
+ * boundary ordered prevents a rejected override from ever selecting an
504
+ * environment credential.
505
+ */
506
+ resolveOperationEndpoint(plugin, userId) {
507
+ return resolvePluginApiConfig(plugin, this.getPluginVariables(plugin, userId)).endpoint;
131
508
  }
132
509
  /**
133
- * Attempt to auto-discover available models from a plugin's base endpoint.
510
+ * Attempt to auto-discover available models from a plugin's full API endpoint.
134
511
  * Resolves the provider's model-list endpoint and updates the plugin's model_map.
135
- * Falls back silently to the existing model_map if the endpoint is unavailable.
512
+ * Falls back to the existing model_map if the endpoint is unavailable; callers
513
+ * that need to tell a real update from a fallback use discoverModelsResult.
136
514
  */
137
515
  async discoverModels(pluginId, userId) {
138
- const plugin = this.getPlugin(pluginId);
139
- if (!plugin)
140
- return [];
516
+ const { models } = await this.discoverModelsResult(pluginId, userId);
517
+ return models;
518
+ }
519
+ /**
520
+ * Model discovery with the outcome attached, so a caller can report whether
521
+ * the provider was actually reached instead of assuming the returned catalog
522
+ * is fresh.
523
+ */
524
+ async discoverModelsResult(pluginId, userId) {
525
+ const cacheKey = this.discoveredModelsCacheKey(pluginId, userId || 'default');
526
+ const inflight = this.inflightDiscovery.get(cacheKey);
527
+ if (inflight)
528
+ return inflight;
529
+ const attempt = this.runModelDiscovery(pluginId, userId).finally(() => {
530
+ this.inflightDiscovery.delete(cacheKey);
531
+ });
532
+ this.inflightDiscovery.set(cacheKey, attempt);
533
+ return attempt;
534
+ }
535
+ /**
536
+ * Whether a plugin's catalog should be re-fetched. Missing catalogs are due
537
+ * immediately; stored ones age out after the TTL. Both cases are gated by a
538
+ * per-plugin backoff so a failing provider is not probed on every request.
539
+ */
540
+ isModelDiscoveryDue(pluginId, userId) {
541
+ const cacheKey = this.discoveredModelsCacheKey(pluginId, userId || 'default');
542
+ const attemptedAt = this.discoveryAttemptedAt.get(cacheKey);
543
+ if (attemptedAt && Date.now() - attemptedAt < modelDiscoveryRetryMs()) {
544
+ return false;
545
+ }
546
+ const models = this.getDiscoveredModels(pluginId, userId);
547
+ const updatedAt = this.discoveredModelsUpdatedAt.get(cacheKey);
548
+ if (!models || !updatedAt)
549
+ return true;
550
+ return Date.now() - updatedAt >= modelDiscoveryTtlMs();
551
+ }
552
+ /**
553
+ * Re-discover models for every active completion plugin whose catalog is
554
+ * missing or stale. Bounded by a deadline so a slow provider cannot stall the
555
+ * plugin-list response; refreshes that outrun it still persist and surface on
556
+ * the next request.
557
+ */
558
+ async refreshStaleModels(userId) {
559
+ const due = this.getActivePlugins(userId).filter(plugin => (plugin.type === 'completion' || plugin.type === 'chat') &&
560
+ this.isModelDiscoveryDue(plugin.id, userId));
561
+ if (due.length === 0)
562
+ return;
563
+ const refreshes = Promise.all(due.map(plugin => this.discoverModels(plugin.id, userId).catch(() => [])));
564
+ await Promise.race([
565
+ refreshes,
566
+ new Promise(resolve => setTimeout(resolve, modelDiscoveryRefreshDeadlineMs()).unref?.()),
567
+ ]);
568
+ }
569
+ /**
570
+ * Say why no key was usable. An environment key is deliberately ignored for a
571
+ * provider whose definition was installed into the writable plugins directory,
572
+ * because that file can be edited to point the credential somewhere else — a
573
+ * silent "no API key" hides that distinction from whoever set the variable.
574
+ */
575
+ describeMissingCredential(plugin) {
576
+ const keyEnv = plugin.auth.key_env;
577
+ if (keyEnv &&
578
+ process.env[keyEnv] &&
579
+ !this.usesTrustedBundledRouting(plugin)) {
580
+ return `${keyEnv} is set but is not used for this provider because it runs an installed definition instead of the bundled one. Save the key in the provider's settings, or remove the installed copy to fall back to the bundled provider.`;
581
+ }
582
+ return 'No API key is configured for this provider.';
583
+ }
584
+ async runModelDiscovery(pluginId, userId) {
585
+ const plugin = this.getPlugin(pluginId, userId);
586
+ if (!plugin) {
587
+ return { models: [], outcome: 'unavailable', reason: 'Plugin not found' };
588
+ }
589
+ this.discoveryAttemptedAt.set(this.discoveredModelsCacheKey(pluginId, userId || 'default'), Date.now());
141
590
  const pluginVars = this.getPluginVariables(plugin, userId);
142
- const endpointOverride = pluginVars.endpoint;
143
- const effectiveEndpoint = (endpointOverride && this.validateEndpointUrl(endpointOverride)) ||
144
- plugin.endpoint;
591
+ const { endpoint: effectiveEndpoint } = resolvePluginApiConfig(plugin, pluginVars);
592
+ const modelsEndpoint = resolvePluginModelsEndpoint(effectiveEndpoint, typeof pluginVars.models_endpoint === 'string'
593
+ ? pluginVars.models_endpoint
594
+ : undefined);
595
+ assertSafePluginEndpoint(modelsEndpoint, 'model discovery endpoint');
145
596
  const apiKey = this.getApiKey(plugin, userId);
597
+ if (pluginRequiresApiKey(plugin) && !apiKey) {
598
+ logger.debug('[Plugin] Model discovery for %s has no usable API key; keeping the existing catalog', pluginId);
599
+ return {
600
+ models: plugin.model_map,
601
+ outcome: 'missing_credentials',
602
+ reason: this.describeMissingCredential(plugin),
603
+ };
604
+ }
146
605
  const headers = buildPluginModelDiscoveryHeaders(plugin, apiKey);
147
606
  try {
148
- const response = await axios.get(resolvePluginModelsEndpoint(effectiveEndpoint), {
607
+ const response = await axios.get(modelsEndpoint, {
149
608
  headers,
150
- timeout: 5000,
609
+ timeout: MODEL_DISCOVERY_REQUEST_TIMEOUT_MS,
610
+ maxRedirects: 0,
151
611
  });
152
612
  if (response.data?.data && Array.isArray(response.data.data)) {
153
613
  const models = response.data.data
@@ -155,33 +615,33 @@ class PluginService {
155
615
  .filter((id) => typeof id === 'string');
156
616
  if (models.length > 0) {
157
617
  logger.debug('[Plugin] Auto-discovered %d models for %s:', models.length, pluginId, models);
158
- // Update the plugin file with discovered models
159
- plugin.model_map = models;
160
- const safeId = pluginId.replace(/[^a-zA-Z0-9_.-]/g, '');
161
- if (safeId !== pluginId)
162
- throw new Error('Invalid plugin ID');
163
- const filePath = path.resolve(this.pluginsDir, `${safeId}.json`);
164
- if (!filePath.startsWith(path.resolve(this.pluginsDir))) {
165
- throw new Error('Path traversal detected');
166
- }
167
- const pluginData = fs.existsSync(filePath)
168
- ? JSON.parse(fs.readFileSync(filePath, 'utf8'))
169
- : plugin;
170
- pluginData.model_map = models;
171
- pluginData.updated_at = Date.now();
172
- fs.writeFileSync(filePath, JSON.stringify(pluginData, null, 2));
173
- return models;
618
+ const previousModels = plugin.model_map;
619
+ this.storeDiscoveredModels(pluginId, models, userId);
620
+ const stored = this.getDiscoveredModels(pluginId, userId) || models;
621
+ return {
622
+ models: stored,
623
+ outcome: JSON.stringify(stored) === JSON.stringify(previousModels)
624
+ ? 'unchanged'
625
+ : 'updated',
626
+ };
174
627
  }
175
628
  }
629
+ return {
630
+ models: plugin.model_map,
631
+ outcome: 'unavailable',
632
+ reason: 'The provider returned no models',
633
+ };
176
634
  }
177
- catch (_error) {
178
- logger.debug(`[Plugin] Model discovery unavailable for ${pluginId}, using existing model_map`);
635
+ catch (error) {
636
+ const reason = describeModelDiscoveryFailure(error);
637
+ logger.debug('[Plugin] Model discovery unavailable for %s (%s), using existing model_map', pluginId, reason);
638
+ return { models: plugin.model_map, outcome: 'unavailable', reason };
179
639
  }
180
- return plugin.model_map;
181
640
  }
182
641
  // List all installed plugins
183
- getAllPlugins() {
642
+ getAllPlugins(userId) {
184
643
  const plugins = new Map();
644
+ const activePluginIds = pluginActivationService.getActivePluginIds(userId);
185
645
  for (const pluginsDir of this.pluginReadDirs) {
186
646
  if (!fs.existsSync(pluginsDir)) {
187
647
  continue;
@@ -190,18 +650,36 @@ class PluginService {
190
650
  const files = fs.readdirSync(pluginsDir);
191
651
  for (const file of files) {
192
652
  if (file.endsWith('.json') && !file.startsWith('.')) {
653
+ const filenameId = path.basename(file, '.json');
193
654
  try {
194
655
  const filePath = path.join(pluginsDir, file);
195
656
  const content = fs.readFileSync(filePath, 'utf8');
196
657
  const parsedPlugin = JSON.parse(content);
197
658
  // Validate plugin structure
198
- if (this.validatePlugin(parsedPlugin)) {
659
+ if (this.validatePlugin(parsedPlugin) &&
660
+ parsedPlugin.id === filenameId) {
661
+ if (!this.isPluginDefinitionApproved(parsedPlugin, filePath)) {
662
+ // A later writable definition shadows an earlier bundled
663
+ // definition even while quarantined.
664
+ plugins.delete(parsedPlugin.id);
665
+ continue;
666
+ }
199
667
  const plugin = applyPluginDefinitionPolicy(parsedPlugin);
200
- plugin.active = this.activePluginIds.has(plugin.id);
201
- plugins.set(plugin.id, plugin);
668
+ plugin.active = activePluginIds.has(plugin.id);
669
+ plugins.set(plugin.id, this.applyDiscoveredModels(plugin, userId));
670
+ }
671
+ else if (parsedPlugin.id !== filenameId) {
672
+ plugins.delete(filenameId);
673
+ logger.warn('Ignoring plugin %s because its declared ID does not match its filename', file);
674
+ }
675
+ else {
676
+ plugins.delete(filenameId);
202
677
  }
203
678
  }
204
679
  catch (error) {
680
+ // Invalid JSON in an effective same-ID writable file must fail
681
+ // closed instead of revealing an earlier bundled definition.
682
+ plugins.delete(filenameId);
205
683
  logger.error(`Failed to load plugin ${file}:`, error);
206
684
  }
207
685
  }
@@ -214,39 +692,40 @@ class PluginService {
214
692
  return Array.from(plugins.values());
215
693
  }
216
694
  // Get a specific plugin by ID
217
- getPlugin(id) {
695
+ getPlugin(id, userId) {
218
696
  // Sanitize the ID to prevent path traversal
219
697
  const sanitizedId = sanitize(id);
220
698
  if (!sanitizedId || sanitizedId !== id) {
221
699
  logger.error('Invalid plugin ID provided:', id);
222
700
  return null;
223
701
  }
224
- for (const pluginsDir of [...this.pluginReadDirs].reverse()) {
225
- const filePath = path.resolve(pluginsDir, `${sanitizedId}.json`);
226
- if (!filePath.startsWith(path.resolve(pluginsDir)) ||
227
- !fs.existsSync(filePath)) {
228
- continue;
229
- }
230
- try {
231
- const content = fs.readFileSync(filePath, 'utf8');
232
- const parsedPlugin = JSON.parse(content);
233
- if (this.validatePlugin(parsedPlugin)) {
234
- const plugin = applyPluginDefinitionPolicy(parsedPlugin);
235
- plugin.active = this.activePluginIds.has(plugin.id);
236
- return plugin;
237
- }
238
- }
239
- catch (error) {
240
- logger.error('Failed to load plugin %s:', sanitizedId, error);
702
+ const filePath = this.resolveEffectivePluginFilePath(sanitizedId);
703
+ if (!filePath)
704
+ return null;
705
+ try {
706
+ const content = fs.readFileSync(filePath, 'utf8');
707
+ const parsedPlugin = JSON.parse(content);
708
+ if (this.validatePlugin(parsedPlugin) &&
709
+ parsedPlugin.id === sanitizedId &&
710
+ this.isPluginDefinitionApproved(parsedPlugin, filePath)) {
711
+ const plugin = applyPluginDefinitionPolicy(parsedPlugin);
712
+ plugin.active = this.isPluginActive(plugin.id, userId);
713
+ return this.applyDiscoveredModels(plugin, userId);
241
714
  }
242
715
  }
716
+ catch (error) {
717
+ logger.error('Failed to load plugin %s:', sanitizedId, error);
718
+ }
243
719
  return null;
244
720
  }
245
721
  // Install or update a plugin
246
- installPlugin(pluginData) {
722
+ installPlugin(pluginData, approvedByUserId) {
247
723
  if (!this.validatePlugin(pluginData)) {
248
724
  throw new Error('Invalid plugin structure');
249
725
  }
726
+ if (!this.canUseStoredConnectionOverrides(approvedByUserId)) {
727
+ throw new Error('Administrator approval is required');
728
+ }
250
729
  const now = Date.now();
251
730
  const plugin = {
252
731
  ...pluginData,
@@ -261,7 +740,25 @@ class PluginService {
261
740
  if (!filePath.startsWith(path.resolve(this.pluginsDir))) {
262
741
  throw new Error('Path traversal detected');
263
742
  }
264
- fs.writeFileSync(filePath, JSON.stringify(plugin, null, 2));
743
+ // Revoke definition approval and every user's activation before replacing
744
+ // bytes on disk. Any crash or write failure therefore leaves the provider
745
+ // quarantined and inactive.
746
+ this.revokePluginDefinitionConsent(plugin.id);
747
+ this.clearDiscoveredModels(plugin.id);
748
+ const temporaryPath = path.resolve(this.pluginsDir, `.${safeId}.${process.pid}.${Date.now()}.tmp`);
749
+ try {
750
+ fs.writeFileSync(temporaryPath, JSON.stringify(plugin, null, 2), {
751
+ flag: 'wx',
752
+ });
753
+ fs.renameSync(temporaryPath, filePath);
754
+ }
755
+ catch (error) {
756
+ if (fs.existsSync(temporaryPath)) {
757
+ fs.unlinkSync(temporaryPath);
758
+ }
759
+ throw error;
760
+ }
761
+ this.approvePluginDefinition(plugin, filePath, approvedByUserId);
265
762
  return plugin;
266
763
  }
267
764
  // Delete a plugin
@@ -278,7 +775,8 @@ class PluginService {
278
775
  logger.error('Plugin ID failed sanitization:', id);
279
776
  return false;
280
777
  }
281
- const writablePluginDirs = Array.from(new Set([this.pluginsDir, this.legacyPluginsDir]));
778
+ const bundledDirectory = path.resolve(this.bundledPluginsDir);
779
+ const writablePluginDirs = Array.from(new Set([this.pluginsDir, this.legacyPluginsDir])).filter(pluginsDir => path.resolve(pluginsDir) !== bundledDirectory);
282
780
  const filePath = writablePluginDirs
283
781
  .map(pluginsDir => path.resolve(pluginsDir, `${sanitizedId}.json`))
284
782
  .find(candidate => fs.existsSync(candidate));
@@ -288,13 +786,12 @@ class PluginService {
288
786
  }
289
787
  try {
290
788
  fs.unlinkSync(filePath);
291
- // If this was an active plugin, deactivate it
292
- if (this.activePluginIds.has(id)) {
293
- this.activePluginIds.delete(id);
294
- this.saveActivePlugins();
295
- }
789
+ pluginActivationService.deletePlugin(id);
790
+ this.removePluginDefinitionApproval(id);
296
791
  // Clean up stored variables
297
792
  pluginVariablesService.deletePluginVariables(id);
793
+ pluginCredentialsService.deleteAllPluginCredentials(id);
794
+ this.clearDiscoveredModels(id);
298
795
  return true;
299
796
  }
300
797
  catch (error) {
@@ -303,38 +800,52 @@ class PluginService {
303
800
  }
304
801
  }
305
802
  // Activate a plugin
306
- activatePlugin(id) {
307
- const plugin = this.getPlugin(id);
803
+ async activatePlugin(id, userId) {
804
+ const plugin = this.getPlugin(id, userId);
308
805
  if (!plugin) {
309
806
  throw new Error('Plugin not found');
310
807
  }
311
- this.activePluginIds.add(id);
312
- this.saveActivePlugins();
313
- // Trigger model discovery in background (non-blocking)
314
- this.discoverModels(id).catch(() => { });
808
+ if (!pluginActivationService.activate(id, userId)) {
809
+ throw new Error('Failed to persist plugin activation');
810
+ }
811
+ // Wait for discovery so the activation response and the UI's first reload
812
+ // observe the same user-scoped model catalog.
813
+ await this.discoverModels(id, userId).catch(() => { });
315
814
  return true;
316
815
  }
317
816
  // Deactivate a specific plugin
318
- deactivatePlugin(id) {
319
- if (id) {
320
- this.activePluginIds.delete(id);
321
- }
322
- else {
323
- // Legacy: deactivate all plugins
324
- this.activePluginIds.clear();
325
- }
326
- this.saveActivePlugins();
327
- return true;
817
+ deactivatePlugin(id, userId) {
818
+ // The legacy no-ID route now deactivates all plugins only for this user.
819
+ return pluginActivationService.deactivate(id, userId);
328
820
  }
329
821
  // Get the active plugin for a specific model
330
- getActivePluginForModel(model, userId) {
822
+ getActivePluginForModel(model, userId, pluginId) {
823
+ if (pluginId) {
824
+ const plugin = this.getPlugin(pluginId, userId);
825
+ if (!plugin) {
826
+ throw new Error(`Plugin not found: ${pluginId}`);
827
+ }
828
+ if (!this.isPluginActive(pluginId, userId)) {
829
+ throw new Error(`Plugin is not active: ${pluginId}`);
830
+ }
831
+ if (!plugin.model_map.includes(model)) {
832
+ throw new Error(`Model ${model} is not supported by plugin ${pluginId}`);
833
+ }
834
+ this.resolveOperationEndpoint(plugin, userId);
835
+ const apiKey = this.getApiKey(plugin, userId);
836
+ if (pluginRequiresApiKey(plugin) && !apiKey) {
837
+ throw new Error(`API key not found for plugin ${pluginId} (save a provider credential in Settings)`);
838
+ }
839
+ return plugin;
840
+ }
331
841
  // Only route through plugins the user explicitly activated.
332
- const activePlugins = this.getActivePlugins();
842
+ const activePlugins = this.getActivePlugins(userId);
333
843
  // Find the active plugin that supports this model
334
844
  for (const plugin of activePlugins) {
335
845
  if (plugin.model_map.includes(model)) {
336
846
  // Local OpenAI-compatible servers can explicitly opt out of auth by
337
847
  // leaving both auth fields empty.
848
+ this.resolveOperationEndpoint(plugin, userId);
338
849
  const apiKey = this.getApiKey(plugin, userId);
339
850
  if (pluginRequiresApiKey(plugin) && !apiKey) {
340
851
  continue;
@@ -345,52 +856,57 @@ class PluginService {
345
856
  return null;
346
857
  }
347
858
  // Get all currently active plugins
348
- getActivePlugins() {
349
- const allPlugins = this.getAllPlugins();
350
- const activePlugins = allPlugins.filter(plugin => this.activePluginIds.has(plugin.id));
859
+ getActivePlugins(userId) {
860
+ const allPlugins = this.getAllPlugins(userId);
861
+ const activePlugins = allPlugins.filter(plugin => plugin.active);
351
862
  return activePlugins;
352
863
  }
353
864
  // Legacy method for backward compatibility - returns first active plugin
354
- getActivePlugin() {
355
- const activePlugins = this.getActivePlugins();
865
+ getActivePlugin(userId) {
866
+ const activePlugins = this.getActivePlugins(userId);
356
867
  return activePlugins.length > 0 ? activePlugins[0] : null;
357
868
  }
358
869
  // Get plugin status
359
- getPluginStatus() {
360
- const plugins = this.getAllPlugins();
870
+ getPluginStatus(userId) {
871
+ const plugins = this.getAllPlugins(userId);
361
872
  return plugins.map(plugin => ({
362
873
  id: plugin.id,
363
874
  active: plugin.active || false,
364
- available: true, // Could be enhanced to check endpoint availability
875
+ available: !pluginRequiresApiKey(plugin) ||
876
+ this.getApiKey(plugin, userId) !== null,
365
877
  }));
366
878
  }
367
879
  // Execute a chat request through the active plugin
368
- async executePluginRequest(model, messages, options = {}, userId) {
880
+ async executePluginRequest(model, messages, options = {}, userId, pluginId) {
369
881
  validatePluginModel(model);
370
- const activePlugin = this.getActivePluginForModel(model, userId);
882
+ const activePlugin = this.getActivePluginForModel(model, userId, pluginId);
371
883
  if (!activePlugin) {
372
884
  throw new Error(`No active plugin found for model: ${model}`);
373
885
  }
374
886
  if (!activePlugin.model_map.includes(model)) {
375
887
  throw new Error(`Model ${model} is not supported by plugin ${activePlugin.id}`);
376
888
  }
889
+ const pluginVars = this.getPluginVariables(activePlugin, userId);
890
+ const { apiMode, endpoint: effectiveEndpoint } = resolvePluginApiConfig(activePlugin, pluginVars);
891
+ const processedEndpoint = applyModelEndpointTemplate(effectiveEndpoint, model);
892
+ assertSafePluginEndpoint(processedEndpoint, 'endpoint URL constructed');
377
893
  const apiKey = this.getApiKey(activePlugin, userId);
378
894
  if (pluginRequiresApiKey(activePlugin) && !apiKey) {
379
- throw new Error(`API key not found for plugin ${activePlugin.id} (set via Settings or ${activePlugin.auth.key_env} env var)`);
895
+ throw new Error(`API key not found for plugin ${activePlugin.id} (save a provider credential in Settings)`);
380
896
  }
381
- const pluginVars = this.getPluginVariables(activePlugin, userId);
382
- const effectiveEndpoint = resolvePluginEndpoint(activePlugin.endpoint, pluginVars.endpoint);
897
+ const providerStateScope = apiMode === 'responses'
898
+ ? createOpenAIResponsesStateScope(activePlugin.id, model, processedEndpoint, createPluginCredentialFingerprint(apiKey))
899
+ : undefined;
383
900
  const headers = buildPluginAuthHeaders(activePlugin, apiKey);
384
- const { payload, headers: payloadHeaders } = buildPluginChatPayload(activePlugin, model, messages, options, pluginVars, false);
901
+ const { payload, headers: payloadHeaders } = buildPluginChatPayload(activePlugin, model, messages, options, pluginVars, false, apiMode, providerStateScope);
385
902
  Object.assign(headers, payloadHeaders);
386
- const processedEndpoint = applyModelEndpointTemplate(effectiveEndpoint, model);
387
- assertSafePluginEndpoint(processedEndpoint, 'endpoint URL constructed');
388
903
  try {
389
904
  const response = await axios.post(processedEndpoint, payload, {
390
905
  headers,
391
906
  timeout: 60000, // 60 second timeout
907
+ maxRedirects: 0,
392
908
  });
393
- return convertProviderResponse(activePlugin, response.data, model);
909
+ return convertProviderResponse(activePlugin, response.data, model, apiMode, providerStateScope);
394
910
  }
395
911
  catch (error) {
396
912
  logger.error(`Plugin request failed for ${activePlugin.id}:`, error);
@@ -412,27 +928,32 @@ class PluginService {
412
928
  * Returns an async generator that yields SSE delta content strings.
413
929
  * Also collects tool_calls from the stream.
414
930
  */
415
- async *executePluginStreamRequest(model, messages, options = {}, userId) {
931
+ async *executePluginStreamRequest(model, messages, options = {}, userId, pluginId) {
416
932
  validatePluginModel(model);
417
- const activePlugin = this.getActivePluginForModel(model, userId);
933
+ const activePlugin = this.getActivePluginForModel(model, userId, pluginId);
418
934
  if (!activePlugin) {
419
935
  throw new Error(`No active plugin found for model: ${model}`);
420
936
  }
421
937
  if (!activePlugin.model_map.includes(model)) {
422
938
  throw new Error(`Model ${model} is not supported by plugin ${activePlugin.id}`);
423
939
  }
940
+ const pluginVars = this.getPluginVariables(activePlugin, userId);
941
+ const { apiMode, endpoint: effectiveEndpoint } = resolvePluginApiConfig(activePlugin, pluginVars);
942
+ const processedEndpoint = applyModelEndpointTemplate(effectiveEndpoint, model);
943
+ assertSafePluginEndpoint(processedEndpoint);
424
944
  const apiKey = this.getApiKey(activePlugin, userId);
425
945
  if (pluginRequiresApiKey(activePlugin) && !apiKey) {
426
- throw new Error(`API key not found for plugin ${activePlugin.id} (set via Settings or ${activePlugin.auth.key_env} env var)`);
946
+ throw new Error(`API key not found for plugin ${activePlugin.id} (save a provider credential in Settings)`);
427
947
  }
428
- const pluginVars = this.getPluginVariables(activePlugin, userId);
429
- const effectiveEndpoint = resolvePluginEndpoint(activePlugin.endpoint, pluginVars.endpoint);
948
+ const providerStateScope = apiMode === 'responses'
949
+ ? createOpenAIResponsesStateScope(activePlugin.id, model, processedEndpoint, createPluginCredentialFingerprint(apiKey))
950
+ : undefined;
430
951
  const headers = buildPluginAuthHeaders(activePlugin, apiKey);
431
952
  let payload;
432
- if (activePlugin.id === 'anthropic') {
433
- const anthropicRequest = buildPluginChatPayload(activePlugin, model, messages, options, pluginVars, true);
434
- payload = anthropicRequest.payload;
435
- Object.assign(headers, anthropicRequest.headers);
953
+ if (activePlugin.id === 'anthropic' || apiMode === 'responses') {
954
+ const pluginRequest = buildPluginChatPayload(activePlugin, model, messages, options, pluginVars, true, apiMode, providerStateScope);
955
+ payload = pluginRequest.payload;
956
+ Object.assign(headers, pluginRequest.headers);
436
957
  }
437
958
  else {
438
959
  const params = resolvePluginChatParameters(options, pluginVars);
@@ -445,54 +966,152 @@ class PluginService {
445
966
  stream: true,
446
967
  };
447
968
  }
448
- const processedEndpoint = applyModelEndpointTemplate(effectiveEndpoint, model);
449
- assertSafePluginEndpoint(processedEndpoint);
450
969
  const response = await fetch(processedEndpoint, {
451
970
  method: 'POST',
452
971
  headers,
453
972
  body: JSON.stringify(payload),
973
+ redirect: 'error',
454
974
  });
455
975
  if (activePlugin.id === 'anthropic') {
456
976
  yield* streamAnthropicResponse(response);
457
977
  }
978
+ else if (apiMode === 'responses') {
979
+ const contentType = response.headers.get('content-type') || '';
980
+ if (!contentType.includes('text/event-stream')) {
981
+ if (!response.ok) {
982
+ const errorText = await response.text();
983
+ throw new Error(`Plugin API error: ${response.status} - ${errorText.slice(0, 200)}`);
984
+ }
985
+ const normalized = convertProviderResponse(activePlugin, (await response.json()), model, apiMode, providerStateScope);
986
+ const choice = normalized.choices[0];
987
+ const message = choice?.message;
988
+ if (typeof message?.content === 'string' && message.content) {
989
+ yield { type: 'content', content: message.content };
990
+ }
991
+ if (typeof message?.reasoning_content === 'string' &&
992
+ message.reasoning_content) {
993
+ yield {
994
+ type: 'reasoning',
995
+ content: message.reasoning_content,
996
+ };
997
+ }
998
+ for (const call of message?.tool_calls || []) {
999
+ yield {
1000
+ type: 'tool_call',
1001
+ toolCall: {
1002
+ id: call.id,
1003
+ name: call.function.name,
1004
+ arguments: call.function.arguments,
1005
+ ...(call.providerMetadata
1006
+ ? { providerMetadata: call.providerMetadata }
1007
+ : {}),
1008
+ },
1009
+ };
1010
+ }
1011
+ if (normalized.usage) {
1012
+ yield {
1013
+ type: 'usage',
1014
+ usage: {
1015
+ promptTokens: normalized.usage.prompt_tokens,
1016
+ completionTokens: normalized.usage.completion_tokens,
1017
+ totalTokens: normalized.usage.total_tokens,
1018
+ },
1019
+ };
1020
+ }
1021
+ const incompleteReason = typeof normalized.providerMetadata?.[OPENAI_RESPONSES_INCOMPLETE_REASON_METADATA_KEY] === 'string'
1022
+ ? normalized.providerMetadata[OPENAI_RESPONSES_INCOMPLETE_REASON_METADATA_KEY]
1023
+ : undefined;
1024
+ yield {
1025
+ type: 'done',
1026
+ ...(incompleteReason
1027
+ ? { doneReason: `incomplete:${incompleteReason}` }
1028
+ : {}),
1029
+ ...(normalized.providerMetadata
1030
+ ? { providerMetadata: normalized.providerMetadata }
1031
+ : {}),
1032
+ };
1033
+ }
1034
+ else {
1035
+ yield* streamOpenAIResponsesResponse(response, providerStateScope);
1036
+ }
1037
+ }
458
1038
  else {
459
1039
  yield* streamOpenAICompatibleResponse(response);
460
1040
  }
461
1041
  }
462
1042
  // Validate plugin structure
463
1043
  validatePlugin(plugin) {
464
- return (typeof plugin === 'object' &&
465
- plugin !== null &&
466
- typeof plugin.id === 'string' &&
467
- typeof plugin.name === 'string' &&
468
- typeof plugin.type === 'string' &&
469
- typeof plugin.endpoint === 'string' &&
470
- typeof plugin.auth === 'object' &&
471
- plugin.auth !== null &&
472
- typeof plugin.auth.header === 'string' &&
473
- typeof plugin.auth.key_env === 'string' &&
474
- (plugin.auth
475
- .prefix === undefined ||
476
- typeof plugin.auth.prefix === 'string') &&
477
- Array.isArray(plugin.model_map) &&
478
- plugin.model_map.length > 0);
1044
+ const pluginRecord = typeof plugin === 'object' && plugin !== null
1045
+ ? plugin
1046
+ : null;
1047
+ const variables = pluginRecord?.variables;
1048
+ const variableNames = variables === undefined
1049
+ ? []
1050
+ : Array.isArray(variables)
1051
+ ? variables.map(variable => typeof variable === 'object' &&
1052
+ variable !== null &&
1053
+ typeof variable.name === 'string'
1054
+ ? variable.name
1055
+ : null)
1056
+ : [null];
1057
+ const hasUniqueVariableNames = !variableNames.includes(null) &&
1058
+ new Set(variableNames).size === variableNames.length;
1059
+ return (pluginRecord !== null &&
1060
+ hasUniqueVariableNames &&
1061
+ typeof pluginRecord.id === 'string' &&
1062
+ typeof pluginRecord.name === 'string' &&
1063
+ typeof pluginRecord.type === 'string' &&
1064
+ typeof pluginRecord.endpoint === 'string' &&
1065
+ (pluginRecord.api_mode === undefined ||
1066
+ pluginRecord.api_mode === 'chat_completions' ||
1067
+ pluginRecord.api_mode === 'responses') &&
1068
+ (pluginRecord.base_url === undefined ||
1069
+ typeof pluginRecord.base_url === 'string') &&
1070
+ (pluginRecord.api_path === undefined ||
1071
+ typeof pluginRecord.api_path === 'string') &&
1072
+ typeof pluginRecord.auth === 'object' &&
1073
+ pluginRecord.auth !== null &&
1074
+ typeof pluginRecord.auth.header ===
1075
+ 'string' &&
1076
+ typeof pluginRecord.auth.key_env ===
1077
+ 'string' &&
1078
+ (pluginRecord.auth.prefix === undefined ||
1079
+ typeof pluginRecord.auth.prefix ===
1080
+ 'string') &&
1081
+ Array.isArray(pluginRecord.model_map) &&
1082
+ pluginRecord.model_map.length > 0);
479
1083
  }
480
1084
  // Export plugin to JSON
481
- exportPlugin(id) {
482
- return this.getPlugin(id);
1085
+ exportPlugin(id, userId) {
1086
+ return this.getPlugin(id, userId);
483
1087
  }
484
1088
  // Import plugin from JSON data
485
- importPlugin(pluginData) {
1089
+ importPlugin(pluginData, approvedByUserId) {
486
1090
  // Validate and clean the plugin data
487
1091
  if (!this.validatePlugin(pluginData)) {
488
1092
  throw new Error('Invalid plugin data');
489
1093
  }
490
1094
  // Check if plugin already exists
491
- const existingPlugin = this.getPlugin(pluginData.id);
492
- if (existingPlugin) {
493
- throw new Error(`Plugin with ID ${pluginData.id} already exists`);
1095
+ const existingPluginPath = this.resolveEffectivePluginFilePath(pluginData.id);
1096
+ if (existingPluginPath) {
1097
+ try {
1098
+ const existingPlugin = JSON.parse(fs.readFileSync(existingPluginPath, 'utf8'));
1099
+ if (this.validatePlugin(existingPlugin) &&
1100
+ existingPlugin.id === pluginData.id &&
1101
+ this.isPluginDefinitionApproved(existingPlugin, existingPluginPath)) {
1102
+ throw new Error(`Plugin with ID ${pluginData.id} already exists`);
1103
+ }
1104
+ }
1105
+ catch (error) {
1106
+ if (error instanceof Error &&
1107
+ error.message.includes('already exists')) {
1108
+ throw error;
1109
+ }
1110
+ // Invalid and unapproved legacy definitions may be replaced by this
1111
+ // authenticated administrator import.
1112
+ }
494
1113
  }
495
- return this.installPlugin(pluginData);
1114
+ return this.installPlugin(pluginData, approvedByUserId);
496
1115
  }
497
1116
  // ============================================
498
1117
  // Capability Methods
@@ -515,20 +1134,20 @@ class PluginService {
515
1134
  async executeTTSRequest(model, input, options = {}) {
516
1135
  return this.ttsService.executeTTSRequest(model, input, options);
517
1136
  }
518
- getTTSConfig(pluginId) {
519
- return this.ttsService.getTTSConfig(pluginId);
1137
+ getTTSConfig(pluginId, userId) {
1138
+ return this.ttsService.getTTSConfig(pluginId, userId);
520
1139
  }
521
- getPluginForImageGen(model) {
522
- return this.imageGenerationService.getPluginForImageGen(model);
1140
+ getPluginForImageGen(model, pluginId, userId) {
1141
+ return this.imageGenerationService.getPluginForImageGen(model, pluginId, userId);
523
1142
  }
524
- getAvailableImageGenModels() {
525
- return this.imageGenerationService.getAvailableImageGenModels();
1143
+ getAvailableImageGenModels(userId) {
1144
+ return this.imageGenerationService.getAvailableImageGenModels(userId);
526
1145
  }
527
- async executeImageGenRequest(model, prompt, options = {}) {
1146
+ async executeImageGenRequest(model, prompt, options) {
528
1147
  return this.imageGenerationService.executeImageGenRequest(model, prompt, options);
529
1148
  }
530
- getImageGenConfig(pluginId) {
531
- return this.imageGenerationService.getImageGenConfig(pluginId);
1149
+ getImageGenConfig(pluginId, userId) {
1150
+ return this.imageGenerationService.getImageGenConfig(pluginId, userId);
532
1151
  }
533
1152
  getPluginsByCapability(capabilityType, userId) {
534
1153
  return this.capabilityRegistryService.getPluginsByCapability(capabilityType, userId);