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.
- package/README.md +18 -8
- package/backend/dist/db.d.ts.map +1 -1
- package/backend/dist/db.js +63 -0
- package/backend/dist/db.js.map +1 -1
- package/backend/dist/index.d.ts.map +1 -1
- package/backend/dist/index.js +4 -4
- package/backend/dist/index.js.map +1 -1
- package/backend/dist/routes/chat.d.ts.map +1 -1
- package/backend/dist/routes/chat.js +93 -10
- package/backend/dist/routes/chat.js.map +1 -1
- package/backend/dist/routes/imageGen.d.ts.map +1 -1
- package/backend/dist/routes/imageGen.js +52 -29
- package/backend/dist/routes/imageGen.js.map +1 -1
- package/backend/dist/routes/plugins.d.ts.map +1 -1
- package/backend/dist/routes/plugins.js +226 -42
- package/backend/dist/routes/plugins.js.map +1 -1
- package/backend/dist/routes/preferences.d.ts.map +1 -1
- package/backend/dist/routes/preferences.js +5 -4
- package/backend/dist/routes/preferences.js.map +1 -1
- package/backend/dist/routes/tts.js +2 -2
- package/backend/dist/routes/tts.js.map +1 -1
- package/backend/dist/routes/work.d.ts.map +1 -1
- package/backend/dist/routes/work.js +2 -1
- package/backend/dist/routes/work.js.map +1 -1
- package/backend/dist/services/assistantCompletionService.d.ts +2 -1
- package/backend/dist/services/assistantCompletionService.d.ts.map +1 -1
- package/backend/dist/services/assistantCompletionService.js +3 -1
- package/backend/dist/services/assistantCompletionService.js.map +1 -1
- package/backend/dist/services/chatGenerationService.d.ts +5 -3
- package/backend/dist/services/chatGenerationService.d.ts.map +1 -1
- package/backend/dist/services/chatGenerationService.js +18 -6
- package/backend/dist/services/chatGenerationService.js.map +1 -1
- package/backend/dist/services/chatRequestService.d.ts +6 -4
- package/backend/dist/services/chatRequestService.d.ts.map +1 -1
- package/backend/dist/services/chatRequestService.js +10 -2
- package/backend/dist/services/chatRequestService.js.map +1 -1
- package/backend/dist/services/chatService.d.ts +2 -2
- package/backend/dist/services/chatService.d.ts.map +1 -1
- package/backend/dist/services/chatService.js +29 -17
- package/backend/dist/services/chatService.js.map +1 -1
- package/backend/dist/services/embeddingService.js +1 -1
- package/backend/dist/services/embeddingService.js.map +1 -1
- package/backend/dist/services/pluginActivationService.d.ts +11 -0
- package/backend/dist/services/pluginActivationService.d.ts.map +1 -0
- package/backend/dist/services/pluginActivationService.js +163 -0
- package/backend/dist/services/pluginActivationService.js.map +1 -0
- package/backend/dist/services/pluginCapabilityRegistryService.d.ts +1 -1
- package/backend/dist/services/pluginCapabilityRegistryService.d.ts.map +1 -1
- package/backend/dist/services/pluginCapabilityRegistryService.js +1 -1
- package/backend/dist/services/pluginCapabilityRegistryService.js.map +1 -1
- package/backend/dist/services/pluginCredentialsService.d.ts +16 -8
- package/backend/dist/services/pluginCredentialsService.d.ts.map +1 -1
- package/backend/dist/services/pluginCredentialsService.js +47 -14
- package/backend/dist/services/pluginCredentialsService.js.map +1 -1
- package/backend/dist/services/pluginEmbeddingService.d.ts +2 -2
- package/backend/dist/services/pluginEmbeddingService.d.ts.map +1 -1
- package/backend/dist/services/pluginEmbeddingService.js +33 -13
- package/backend/dist/services/pluginEmbeddingService.js.map +1 -1
- package/backend/dist/services/pluginImageGenerationService.d.ts +11 -7
- package/backend/dist/services/pluginImageGenerationService.d.ts.map +1 -1
- package/backend/dist/services/pluginImageGenerationService.js +265 -81
- package/backend/dist/services/pluginImageGenerationService.js.map +1 -1
- package/backend/dist/services/pluginService.d.ts +97 -26
- package/backend/dist/services/pluginService.d.ts.map +1 -1
- package/backend/dist/services/pluginService.js +797 -178
- package/backend/dist/services/pluginService.js.map +1 -1
- package/backend/dist/services/pluginTTSService.d.ts +4 -4
- package/backend/dist/services/pluginTTSService.d.ts.map +1 -1
- package/backend/dist/services/pluginTTSService.js +24 -28
- package/backend/dist/services/pluginTTSService.js.map +1 -1
- package/backend/dist/services/pluginVariablesService.d.ts +6 -1
- package/backend/dist/services/pluginVariablesService.d.ts.map +1 -1
- package/backend/dist/services/pluginVariablesService.js +59 -1
- package/backend/dist/services/pluginVariablesService.js.map +1 -1
- package/backend/dist/services/preferencesService.d.ts +2 -2
- package/backend/dist/services/preferencesService.d.ts.map +1 -1
- package/backend/dist/services/preferencesService.js +78 -10
- package/backend/dist/services/preferencesService.js.map +1 -1
- package/backend/dist/services/titleGenerationService.d.ts +6 -4
- package/backend/dist/services/titleGenerationService.d.ts.map +1 -1
- package/backend/dist/services/titleGenerationService.js +9 -5
- package/backend/dist/services/titleGenerationService.js.map +1 -1
- package/backend/dist/services/workAgentService.d.ts +5 -2
- package/backend/dist/services/workAgentService.d.ts.map +1 -1
- package/backend/dist/services/workAgentService.js +315 -44
- package/backend/dist/services/workAgentService.js.map +1 -1
- package/backend/dist/services/workModelProviderService.d.ts +6 -3
- package/backend/dist/services/workModelProviderService.d.ts.map +1 -1
- package/backend/dist/services/workModelProviderService.js +243 -19
- package/backend/dist/services/workModelProviderService.js.map +1 -1
- package/backend/dist/services/workRuntimeService.d.ts +18 -1
- package/backend/dist/services/workRuntimeService.d.ts.map +1 -1
- package/backend/dist/services/workRuntimeService.js +62 -11
- package/backend/dist/services/workRuntimeService.js.map +1 -1
- package/backend/dist/services/workTaskService.d.ts +2 -0
- package/backend/dist/services/workTaskService.d.ts.map +1 -1
- package/backend/dist/services/workTaskService.js +89 -14
- package/backend/dist/services/workTaskService.js.map +1 -1
- package/backend/dist/storage.d.ts.map +1 -1
- package/backend/dist/storage.js +9 -6
- package/backend/dist/storage.js.map +1 -1
- package/backend/dist/storageMappers.d.ts +3 -0
- package/backend/dist/storageMappers.d.ts.map +1 -1
- package/backend/dist/storageMappers.js +3 -0
- package/backend/dist/storageMappers.js.map +1 -1
- package/backend/dist/types/index.d.ts +54 -1
- package/backend/dist/types/index.d.ts.map +1 -1
- package/backend/dist/types/index.js.map +1 -1
- package/backend/dist/types/work.d.ts +1 -1
- package/backend/dist/types/work.d.ts.map +1 -1
- package/backend/dist/utils/chatContext.d.ts +13 -1
- package/backend/dist/utils/chatContext.d.ts.map +1 -1
- package/backend/dist/utils/chatContext.js +52 -0
- package/backend/dist/utils/chatContext.js.map +1 -1
- package/backend/dist/utils/chatProviderSelection.d.ts +15 -0
- package/backend/dist/utils/chatProviderSelection.d.ts.map +1 -0
- package/backend/dist/utils/chatProviderSelection.js +59 -0
- package/backend/dist/utils/chatProviderSelection.js.map +1 -0
- package/backend/dist/utils/imageGenerationValidation.d.ts +3 -0
- package/backend/dist/utils/imageGenerationValidation.d.ts.map +1 -0
- package/backend/dist/utils/imageGenerationValidation.js +55 -0
- package/backend/dist/utils/imageGenerationValidation.js.map +1 -0
- package/backend/dist/utils/openAIResponsesAdapter.d.ts +88 -0
- package/backend/dist/utils/openAIResponsesAdapter.d.ts.map +1 -0
- package/backend/dist/utils/openAIResponsesAdapter.js +521 -0
- package/backend/dist/utils/openAIResponsesAdapter.js.map +1 -0
- package/backend/dist/utils/pluginChatAdapter.d.ts +7 -4
- package/backend/dist/utils/pluginChatAdapter.d.ts.map +1 -1
- package/backend/dist/utils/pluginChatAdapter.js +33 -5
- package/backend/dist/utils/pluginChatAdapter.js.map +1 -1
- package/backend/dist/utils/pluginChatContext.d.ts.map +1 -1
- package/backend/dist/utils/pluginChatContext.js +5 -1
- package/backend/dist/utils/pluginChatContext.js.map +1 -1
- package/backend/dist/utils/pluginConnectionVariables.d.ts +6 -0
- package/backend/dist/utils/pluginConnectionVariables.d.ts.map +1 -0
- package/backend/dist/utils/pluginConnectionVariables.js +52 -0
- package/backend/dist/utils/pluginConnectionVariables.js.map +1 -0
- package/backend/dist/utils/pluginDefinitionTrust.d.ts +9 -0
- package/backend/dist/utils/pluginDefinitionTrust.d.ts.map +1 -0
- package/backend/dist/utils/pluginDefinitionTrust.js +71 -0
- package/backend/dist/utils/pluginDefinitionTrust.js.map +1 -0
- package/backend/dist/utils/pluginStreamAdapter.d.ts +3 -0
- package/backend/dist/utils/pluginStreamAdapter.d.ts.map +1 -1
- package/backend/dist/utils/pluginStreamAdapter.js +410 -0
- package/backend/dist/utils/pluginStreamAdapter.js.map +1 -1
- package/backend/dist/utils/pluginStreaming.d.ts +7 -1
- package/backend/dist/utils/pluginStreaming.d.ts.map +1 -1
- package/backend/dist/utils/pluginStreaming.js +12 -1
- package/backend/dist/utils/pluginStreaming.js.map +1 -1
- package/backend/dist/utils/pluginValidation.d.ts +20 -2
- package/backend/dist/utils/pluginValidation.d.ts.map +1 -1
- package/backend/dist/utils/pluginValidation.js +187 -17
- package/backend/dist/utils/pluginValidation.js.map +1 -1
- package/backend/dist/utils/pluginVariableValidation.d.ts +7 -0
- package/backend/dist/utils/pluginVariableValidation.d.ts.map +1 -1
- package/backend/dist/utils/pluginVariableValidation.js +79 -4
- package/backend/dist/utils/pluginVariableValidation.js.map +1 -1
- package/backend/dist/websocketServer.d.ts.map +1 -1
- package/backend/dist/websocketServer.js +16 -3
- package/backend/dist/websocketServer.js.map +1 -1
- package/backend/package.json +1 -1
- package/frontend/dist/assets/index-BVgyQN3z.js +5 -0
- package/frontend/dist/assets/index-Dme2Ozol.css +1 -0
- package/frontend/dist/index.html +3 -3
- package/frontend/dist/js/{ArtifactContainer-BHSBrlQB.js → ArtifactContainer-CbMAYwvr.js} +1 -1
- package/frontend/dist/js/{ArtifactDemoPage-BHGJmJAT.js → ArtifactDemoPage-CWDhGWrz.js} +1 -1
- package/frontend/dist/js/ArtifactSlideOutPanel-CzUpDM59.js +1 -0
- package/frontend/dist/js/ChatPage-D0fdzy7a.js +16 -0
- package/frontend/dist/js/{FirstTimeSetup-BYWAVAdE.js → FirstTimeSetup-CfnOmy9w.js} +1 -1
- package/frontend/dist/js/GalleryPage-CMkCur9v.js +1 -0
- package/frontend/dist/js/LibreClawPage-CHEfny1k.js +1 -0
- package/frontend/dist/js/ModelSelector-COnlNsVM.js +1 -0
- package/frontend/dist/js/ModelsPage-nxheiYJp.js +2 -0
- package/frontend/dist/js/PersonasPage-C2pqDsWA.js +13 -0
- package/frontend/dist/js/{RichMessageContent-BdU5vXz9.js → RichMessageContent-Cz9y-bgl.js} +1 -1
- package/frontend/dist/js/SettingsModal-B6Se68Hd.js +1 -0
- package/frontend/dist/js/UserManagementPage-DQeL-Twp.js +1 -0
- package/frontend/dist/js/WorkPage-AcZMp8OB.js +2 -0
- package/frontend/dist/js/api-Fi-lJlfu.js +19 -0
- package/frontend/dist/js/{estree-Bu_-Wi6O.js → estree-BRklD62a.js} +1 -1
- package/frontend/dist/js/{html-Ca764Rus.js → html-BgGIAGmn.js} +1 -1
- package/frontend/dist/js/{markdown-DaiQGPEs.js → markdown-CM2nBJJi.js} +1 -1
- package/frontend/dist/js/{standalone-DWWnsbIr.js → standalone-CUBo-NGt.js} +1 -1
- package/frontend/dist/js/{typescript-CatjJeHm.js → typescript-Dcy9qxSB.js} +1 -1
- package/frontend/dist/js/useBaseQuery-CFTidwqz.js +1 -0
- package/frontend/dist/js/useQuery-UsSBfWzs.js +1 -0
- package/package.json +2 -2
- package/plugins/github.json +1 -2
- package/plugins/huggingface.json +30 -30
- package/plugins/openai.json +53 -22
- package/frontend/dist/assets/index-DkShhNBf.js +0 -5
- package/frontend/dist/assets/index-bdSWBsAM.css +0 -1
- package/frontend/dist/js/ArtifactSlideOutPanel-s8vcE5Ea.js +0 -1
- package/frontend/dist/js/ChatPage-B6Hm_pdu.js +0 -16
- package/frontend/dist/js/GalleryPage-CH4f65q8.js +0 -1
- package/frontend/dist/js/LibreClawPage-CcH7M3mJ.js +0 -1
- package/frontend/dist/js/ModelSelector-T1KZtIde.js +0 -1
- package/frontend/dist/js/ModelsPage-C1bM_Yoc.js +0 -2
- package/frontend/dist/js/PersonasPage-T_PrhnPG.js +0 -13
- package/frontend/dist/js/SettingsModal-BVj-D1L4.js +0 -1
- package/frontend/dist/js/UserManagementPage-Pix71oRW.js +0 -1
- package/frontend/dist/js/WorkPage-DdOVGyj_.js +0 -2
- package/frontend/dist/js/api-Do6u0tMM.js +0 -19
- package/frontend/dist/js/useBaseQuery-CVNuhuID.js +0 -1
- 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,
|
|
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
|
-
|
|
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.
|
|
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.
|
|
131
|
+
pluginActivationService.migrateLegacyStatus([this.pluginsDir, this.legacyPluginsDir], pluginId => this.canMigrateLegacyActivation(pluginId));
|
|
46
132
|
this.embeddingService = new PluginEmbeddingService({
|
|
47
|
-
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
127
|
-
* Returns the URL string if valid
|
|
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
|
|
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
|
|
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
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
143
|
-
const
|
|
144
|
-
|
|
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(
|
|
607
|
+
const response = await axios.get(modelsEndpoint, {
|
|
149
608
|
headers,
|
|
150
|
-
timeout:
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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 (
|
|
178
|
-
|
|
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 =
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
292
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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 =>
|
|
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:
|
|
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} (
|
|
895
|
+
throw new Error(`API key not found for plugin ${activePlugin.id} (save a provider credential in Settings)`);
|
|
380
896
|
}
|
|
381
|
-
const
|
|
382
|
-
|
|
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} (
|
|
946
|
+
throw new Error(`API key not found for plugin ${activePlugin.id} (save a provider credential in Settings)`);
|
|
427
947
|
}
|
|
428
|
-
const
|
|
429
|
-
|
|
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
|
|
434
|
-
payload =
|
|
435
|
-
Object.assign(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
|
-
|
|
465
|
-
plugin
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
|
492
|
-
if (
|
|
493
|
-
|
|
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);
|