@sparkelf/dsh-dataops-managed 0.3.2 → 0.3.4
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/lib/client.js +286 -28
- package/lib/client.js.map +1 -1
- package/lib/index.js +504 -3
- package/lib/types/client/ManagedDataOpsSection.d.ts +4 -1
- package/lib/types/client/locales.d.ts +18 -0
- package/lib/types/client/model-sync-client.d.ts +38 -0
- package/lib/types/index.d.ts +24 -0
- package/lib/types/model-settings.d.ts +64 -0
- package/lib/types/model-sync.d.ts +145 -0
- package/package.json +33 -30
package/lib/index.js
CHANGED
|
@@ -1,6 +1,374 @@
|
|
|
1
1
|
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
2
2
|
import * as McpClient from "@sparkelf/dsh-plugin-mcp-credentials";
|
|
3
3
|
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
//#region src/model-settings.ts
|
|
5
|
+
/** Profile entry id whose Config holds the model providers. */
|
|
6
|
+
const MODEL_SETTINGS_NAMESPACE = "llm-pi-ai";
|
|
7
|
+
/** Field within that entry's Config holding provider profiles keyed by provider id. */
|
|
8
|
+
const PROVIDERS_FIELD = "providers";
|
|
9
|
+
/** Model ids inside a provider profile, as the plugin stores them. */
|
|
10
|
+
const MODELS_FIELD = "models";
|
|
11
|
+
/** Read a model's stable identity inside a provider profile. */
|
|
12
|
+
function modelIdOf(entry) {
|
|
13
|
+
const id = entry.id;
|
|
14
|
+
return typeof id === "string" && id.trim() !== "" ? id : null;
|
|
15
|
+
}
|
|
16
|
+
/** Narrow an unknown value to a plain object. */
|
|
17
|
+
function asRecord$1(value) {
|
|
18
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
19
|
+
}
|
|
20
|
+
/** Read provider profiles out of a settings value. */
|
|
21
|
+
function providersOf(value) {
|
|
22
|
+
const root = asRecord$1(value);
|
|
23
|
+
const providers = root === null ? null : asRecord$1(root[PROVIDERS_FIELD]);
|
|
24
|
+
if (providers === null) return {};
|
|
25
|
+
const result = {};
|
|
26
|
+
for (const [providerId, profile] of Object.entries(providers)) {
|
|
27
|
+
const narrowed = asRecord$1(profile);
|
|
28
|
+
if (narrowed !== null) result[providerId] = narrowed;
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Read the models this workspace currently holds.
|
|
34
|
+
*
|
|
35
|
+
* A missing section is an empty result rather than an error: a fresh workspace has never
|
|
36
|
+
* opened the models page, and that is the normal state for the first user to arrive.
|
|
37
|
+
* @param ctx - DSH Host context carrying the settings service.
|
|
38
|
+
* @returns Provider profiles and the revision the read observed.
|
|
39
|
+
*/
|
|
40
|
+
function readModels(ctx) {
|
|
41
|
+
const descriptor = ctx.settings.describe().find((entry) => entry.ns === MODEL_SETTINGS_NAMESPACE);
|
|
42
|
+
if (descriptor === void 0) return {
|
|
43
|
+
providers: {},
|
|
44
|
+
revision: void 0
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
providers: providersOf(descriptor.value),
|
|
48
|
+
revision: descriptor.revision
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Collect every model id a workspace holds, grouped by provider.
|
|
53
|
+
* @param providers - Provider profiles to walk.
|
|
54
|
+
* @returns Model ids offered by each provider.
|
|
55
|
+
*/
|
|
56
|
+
function modelIdsByProvider(providers) {
|
|
57
|
+
const result = {};
|
|
58
|
+
for (const [providerId, profile] of Object.entries(providers)) {
|
|
59
|
+
const models = profile[MODELS_FIELD];
|
|
60
|
+
if (!Array.isArray(models)) continue;
|
|
61
|
+
result[providerId] = models.map((entry) => asRecord$1(entry)).filter((entry) => entry !== null).map((entry) => modelIdOf(entry)).filter((id) => id !== null);
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Narrow published providers to the models their publisher offers.
|
|
67
|
+
*
|
|
68
|
+
* The publisher decides which of its models other users may use; a model it kept private is
|
|
69
|
+
* dropped here, so it is never written into a reader's settings at all.
|
|
70
|
+
* @param providers - Published provider profiles.
|
|
71
|
+
* @param sharedModelIds - Model ids the publisher offers.
|
|
72
|
+
* @returns Providers holding only the offered models.
|
|
73
|
+
*/
|
|
74
|
+
function selectSharedModels(providers, sharedModelIds) {
|
|
75
|
+
const offered = new Set(sharedModelIds);
|
|
76
|
+
if (offered.size === 0) return {};
|
|
77
|
+
const result = {};
|
|
78
|
+
for (const [providerId, profile] of Object.entries(providers)) {
|
|
79
|
+
const models = profile[MODELS_FIELD];
|
|
80
|
+
if (!Array.isArray(models)) continue;
|
|
81
|
+
const kept = models.filter((entry) => {
|
|
82
|
+
const narrowed = asRecord$1(entry);
|
|
83
|
+
const id = narrowed === null ? null : modelIdOf(narrowed);
|
|
84
|
+
return id !== null && offered.has(id);
|
|
85
|
+
});
|
|
86
|
+
if (kept.length === 0) continue;
|
|
87
|
+
result[providerId] = {
|
|
88
|
+
...profile,
|
|
89
|
+
[MODELS_FIELD]: kept
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Merge adopted provider profiles into the ones a workspace already holds.
|
|
96
|
+
*
|
|
97
|
+
* A provider the user already has keeps its own models and gains the adopted ones that are
|
|
98
|
+
* missing, so adopting never removes a model the user configured. The user's own entry for a
|
|
99
|
+
* model wins on conflict: that is what keeps a local edit from being overwritten.
|
|
100
|
+
* @param local - Providers the workspace holds now.
|
|
101
|
+
* @param adopted - Providers the publisher offers.
|
|
102
|
+
* @returns Providers to write, and whether anything changed.
|
|
103
|
+
*/
|
|
104
|
+
function mergeProviders(local, adopted) {
|
|
105
|
+
const result = { ...local };
|
|
106
|
+
let changed = false;
|
|
107
|
+
for (const [providerId, adoptedProfile] of Object.entries(adopted)) {
|
|
108
|
+
const adoptedModels = Array.isArray(adoptedProfile[MODELS_FIELD]) ? adoptedProfile[MODELS_FIELD] : [];
|
|
109
|
+
const localProfile = result[providerId];
|
|
110
|
+
if (localProfile === void 0) {
|
|
111
|
+
result[providerId] = adoptedProfile;
|
|
112
|
+
changed = changed || adoptedModels.length > 0;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const localModels = Array.isArray(localProfile[MODELS_FIELD]) ? localProfile[MODELS_FIELD] : [];
|
|
116
|
+
const localIds = new Set(localModels.map((entry) => asRecord$1(entry)).filter((entry) => entry !== null).map((entry) => modelIdOf(entry)).filter((id) => id !== null));
|
|
117
|
+
const missing = adoptedModels.filter((entry) => {
|
|
118
|
+
const narrowed = asRecord$1(entry);
|
|
119
|
+
const id = narrowed === null ? null : modelIdOf(narrowed);
|
|
120
|
+
return id !== null && !localIds.has(id);
|
|
121
|
+
});
|
|
122
|
+
if (missing.length === 0) continue;
|
|
123
|
+
result[providerId] = {
|
|
124
|
+
...localProfile,
|
|
125
|
+
[MODELS_FIELD]: [...localModels, ...missing]
|
|
126
|
+
};
|
|
127
|
+
changed = true;
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
providers: result,
|
|
131
|
+
changed
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Write provider profiles into this workspace's settings.
|
|
136
|
+
*
|
|
137
|
+
* The write presents the revision the read observed. A settings service that has moved on
|
|
138
|
+
* refuses it with its own conflict error, which the caller surfaces rather than retrying:
|
|
139
|
+
* silently overwriting a concurrent edit is exactly what this feature must not do.
|
|
140
|
+
* @param ctx - DSH Host context carrying the settings service.
|
|
141
|
+
* @param providers - Providers to store.
|
|
142
|
+
* @param revision - Revision from the read this write is based on.
|
|
143
|
+
*/
|
|
144
|
+
async function writeModels(ctx, providers, revision) {
|
|
145
|
+
await ctx.settings.update(MODEL_SETTINGS_NAMESPACE, { [PROVIDERS_FIELD]: providers }, revision);
|
|
146
|
+
}
|
|
147
|
+
//#endregion
|
|
148
|
+
//#region src/model-sync.ts
|
|
149
|
+
/** Narrow an unknown value to a plain object. */
|
|
150
|
+
function asRecord(value) {
|
|
151
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Own the model-sync state one workspace keeps.
|
|
155
|
+
*
|
|
156
|
+
* The state lives in this workspace rather than in DataOps, because whether a user has written
|
|
157
|
+
* their own models is a fact about this workspace. Keeping it here is also what makes the
|
|
158
|
+
* promise hold across restarts: a user who chose their own models is not adopted again.
|
|
159
|
+
*/
|
|
160
|
+
var ModelSync = class {
|
|
161
|
+
ctx;
|
|
162
|
+
config;
|
|
163
|
+
store;
|
|
164
|
+
constructor(ctx, config, store) {
|
|
165
|
+
this.ctx = ctx;
|
|
166
|
+
this.config = config;
|
|
167
|
+
this.store = store;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Read the DataOps JWT this workspace is currently authorized with.
|
|
171
|
+
* @returns The token, or undefined when the workspace has no session yet.
|
|
172
|
+
*/
|
|
173
|
+
async token() {
|
|
174
|
+
return (await this.ctx.credentials.resolve(credentialRef(this.config.credentialRef)))?.value;
|
|
175
|
+
}
|
|
176
|
+
/** The DataOps API origin this workspace reports to. */
|
|
177
|
+
apiBase() {
|
|
178
|
+
return new URL(this.config.baseUrl).origin;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Fetch the publication this workspace may adopt.
|
|
182
|
+
* @returns The published snapshot, or undefined when the workspace is not authorized.
|
|
183
|
+
*/
|
|
184
|
+
async fetchPublished() {
|
|
185
|
+
const token = await this.token();
|
|
186
|
+
if (token === void 0) return void 0;
|
|
187
|
+
const response = await fetch(new URL("/api/ai/shared-models/published", this.apiBase()), {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: { authorization: `Bearer ${token}` }
|
|
190
|
+
});
|
|
191
|
+
if (response.status === 401) return void 0;
|
|
192
|
+
if (!response.ok) throw new Error(`Shared model read failed with HTTP ${String(response.status)}`);
|
|
193
|
+
const body = asRecord(await response.json());
|
|
194
|
+
if (body === null) return void 0;
|
|
195
|
+
const payload = asRecord(body.payload);
|
|
196
|
+
const providers = payload === null ? null : asRecord(payload.providers);
|
|
197
|
+
return {
|
|
198
|
+
sharingEnabled: body.sharingEnabled === true,
|
|
199
|
+
payload: {
|
|
200
|
+
providers: providers === null ? {} : providers,
|
|
201
|
+
sharedModelIds: Array.isArray(payload?.sharedModelIds) ? payload.sharedModelIds.filter((id) => typeof id === "string") : []
|
|
202
|
+
},
|
|
203
|
+
updatedAt: typeof body.updatedAt === "string" ? body.updatedAt : null
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* The models this workspace offers to others.
|
|
208
|
+
*
|
|
209
|
+
* Every model is offered unless the administrator marked it private, which is what makes a
|
|
210
|
+
* newly added model shared without an extra step.
|
|
211
|
+
* @param providers - Providers this workspace holds.
|
|
212
|
+
* @param privateModelIds - Model ids kept private.
|
|
213
|
+
* @returns Model ids to offer.
|
|
214
|
+
*/
|
|
215
|
+
offeredModelIds(providers, privateModelIds) {
|
|
216
|
+
const held = Object.values(modelIdsByProvider(providers)).flat();
|
|
217
|
+
const privateIds = new Set(privateModelIds);
|
|
218
|
+
return held.filter((id) => !privateIds.has(id));
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Report this workspace's models to DataOps as a publication.
|
|
222
|
+
*
|
|
223
|
+
* Called when an administrator turns sharing on, or edits their models while it is on. The
|
|
224
|
+
* workspace stays the owner of the configuration; DataOps stores only this copy.
|
|
225
|
+
* @param sharingEnabled - Whether sharing is on.
|
|
226
|
+
*/
|
|
227
|
+
async publish(sharingEnabled) {
|
|
228
|
+
const token = await this.token();
|
|
229
|
+
if (token === void 0) throw new Error("This workspace has no DataOps session to publish with");
|
|
230
|
+
const state = this.store.read();
|
|
231
|
+
const current = readModels(this.ctx);
|
|
232
|
+
const response = await fetch(new URL("/api/ai/shared-models/publication", this.apiBase()), {
|
|
233
|
+
method: "PUT",
|
|
234
|
+
headers: {
|
|
235
|
+
authorization: `Bearer ${token}`,
|
|
236
|
+
"content-type": "application/json"
|
|
237
|
+
},
|
|
238
|
+
body: JSON.stringify({
|
|
239
|
+
sharingEnabled,
|
|
240
|
+
providers: current.providers,
|
|
241
|
+
sharedModelIds: this.offeredModelIds(current.providers, state.privateModelIds)
|
|
242
|
+
})
|
|
243
|
+
});
|
|
244
|
+
if (!response.ok) throw new Error(`Shared model publish failed with HTTP ${String(response.status)}`);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Read the current user's own publication state and whether they may change it.
|
|
248
|
+
*
|
|
249
|
+
* The permission rule lives in DataOps; the browser only displays what this reports, so
|
|
250
|
+
* nothing in the page decides who is an administrator.
|
|
251
|
+
* @returns The state, or null when the workspace is not authorized.
|
|
252
|
+
*/
|
|
253
|
+
async readOwnState() {
|
|
254
|
+
const token = await this.token();
|
|
255
|
+
if (token === void 0) return null;
|
|
256
|
+
const response = await fetch(new URL("/api/ai/shared-models/publication", this.apiBase()), { headers: { authorization: `Bearer ${token}` } });
|
|
257
|
+
if (response.status === 401) return null;
|
|
258
|
+
if (!response.ok) throw new Error(`Shared model status read failed with HTTP ${String(response.status)}`);
|
|
259
|
+
const body = asRecord(await response.json());
|
|
260
|
+
return {
|
|
261
|
+
sharingEnabled: body?.sharingEnabled === true,
|
|
262
|
+
canPublish: body?.canPublish === true
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Record which models the administrator keeps private.
|
|
267
|
+
* @param privateModelIds - Model ids not offered to other users.
|
|
268
|
+
* @param sharingEnabled - Whether sharing is on, so the change reaches readers now.
|
|
269
|
+
*/
|
|
270
|
+
async setPrivateModels(privateModelIds, sharingEnabled) {
|
|
271
|
+
const state = this.store.read();
|
|
272
|
+
await this.store.write({
|
|
273
|
+
...state,
|
|
274
|
+
privateModelIds: [...new Set(privateModelIds)]
|
|
275
|
+
});
|
|
276
|
+
if (sharingEnabled) await this.publish(true);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Adopt the published models into this workspace.
|
|
280
|
+
*
|
|
281
|
+
* Does nothing when the user here writes their own models, which is what keeps a local edit
|
|
282
|
+
* from being replaced by the publisher's version. Otherwise it merges: models the workspace
|
|
283
|
+
* lacks are added, and a model it already holds is left exactly as it is.
|
|
284
|
+
* @returns Adopted provider profiles, or null when nothing was adopted.
|
|
285
|
+
*/
|
|
286
|
+
async adopt() {
|
|
287
|
+
const state = this.store.read();
|
|
288
|
+
if (state.detached) return null;
|
|
289
|
+
const published = await this.fetchPublished();
|
|
290
|
+
if (published === void 0 || !published.sharingEnabled) return null;
|
|
291
|
+
const offered = selectSharedModels(published.payload.providers, published.payload.sharedModelIds);
|
|
292
|
+
if (Object.keys(offered).length === 0) return null;
|
|
293
|
+
const offeredIds = [...new Set(Object.values(modelIdsByProvider(offered)).flat())];
|
|
294
|
+
const current = readModels(this.ctx);
|
|
295
|
+
if (this.editedSinceAdoption(current.providers, state.adoptedModelIds)) {
|
|
296
|
+
await this.store.write({
|
|
297
|
+
...state,
|
|
298
|
+
detached: true
|
|
299
|
+
});
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
const merged = mergeProviders(current.providers, offered);
|
|
303
|
+
if (merged.changed) await writeModels(this.ctx, merged.providers, current.revision);
|
|
304
|
+
await this.store.write({
|
|
305
|
+
...state,
|
|
306
|
+
adoptedModelIds: offeredIds
|
|
307
|
+
});
|
|
308
|
+
return offered;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Whether this workspace holds models adoption did not put there.
|
|
312
|
+
*
|
|
313
|
+
* A model the workspace has now and adoption did not write is the user's own, so adopting
|
|
314
|
+
* again would discard their work. An empty record means adoption never ran here, and the
|
|
315
|
+
* first pass must not read the workspace's existing models as an edit.
|
|
316
|
+
* @param current - Providers the workspace holds now.
|
|
317
|
+
* @param adoptedModelIds - Model ids adoption last wrote.
|
|
318
|
+
* @returns True when the workspace holds something adoption did not write.
|
|
319
|
+
*/
|
|
320
|
+
editedSinceAdoption(current, adoptedModelIds) {
|
|
321
|
+
if (adoptedModelIds.length === 0) return false;
|
|
322
|
+
const adopted = new Set(adoptedModelIds);
|
|
323
|
+
return Object.values(modelIdsByProvider(current)).flat().some((id) => !adopted.has(id));
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Stop following the publisher and keep what this workspace holds now.
|
|
327
|
+
*
|
|
328
|
+
* A user who chose their own models must not be pulled back, so this choice is stored and
|
|
329
|
+
* survives the next start.
|
|
330
|
+
*/
|
|
331
|
+
async detach() {
|
|
332
|
+
await this.store.write({
|
|
333
|
+
...this.store.read(),
|
|
334
|
+
detached: true
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Follow the publisher again, adopting its models on the next pass.
|
|
339
|
+
*/
|
|
340
|
+
async resumeFollowing() {
|
|
341
|
+
await this.store.write({
|
|
342
|
+
...this.store.read(),
|
|
343
|
+
detached: false
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Describe the current state for the settings UI.
|
|
348
|
+
* @returns The sync state, whether the viewer may publish, and their own models.
|
|
349
|
+
*/
|
|
350
|
+
async status() {
|
|
351
|
+
const state = this.store.read();
|
|
352
|
+
const own = await this.readOwnState();
|
|
353
|
+
const published = await this.fetchPublished();
|
|
354
|
+
const offered = published === void 0 || !published.sharingEnabled ? {} : selectSharedModels(published.payload.providers, published.payload.sharedModelIds);
|
|
355
|
+
const held = [...new Set(Object.values(modelIdsByProvider(readModels(this.ctx).providers)).flat())];
|
|
356
|
+
const privateIds = new Set(state.privateModelIds);
|
|
357
|
+
return {
|
|
358
|
+
followState: state.detached ? "detached" : "following",
|
|
359
|
+
publisherSharing: published?.sharingEnabled === true,
|
|
360
|
+
adoptedCount: Object.values(modelIdsByProvider(offered)).flat().length,
|
|
361
|
+
privateModelIds: state.privateModelIds,
|
|
362
|
+
publishedAt: published?.updatedAt ?? null,
|
|
363
|
+
canPublish: own?.canPublish === true,
|
|
364
|
+
models: held.map((id) => ({
|
|
365
|
+
id,
|
|
366
|
+
shared: !privateIds.has(id)
|
|
367
|
+
}))
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
//#endregion
|
|
4
372
|
//#region src/index.ts
|
|
5
373
|
/** Cordis plugin name for the DataOps-managed integration. */
|
|
6
374
|
const name = "dataops-managed";
|
|
@@ -8,15 +376,35 @@ const name = "dataops-managed";
|
|
|
8
376
|
const inject = [
|
|
9
377
|
"credentials",
|
|
10
378
|
"webServer",
|
|
11
|
-
"tools"
|
|
379
|
+
"tools",
|
|
380
|
+
"settings"
|
|
12
381
|
];
|
|
13
382
|
const MANAGED_AUTH_PATH = "/integrations/dataops/managed-auth";
|
|
383
|
+
/** This plugin's profile entry id, which owns its Config and sync state. */
|
|
384
|
+
const MANAGED_PLUGIN_ID = "dataops-managed";
|
|
385
|
+
/** Same-origin route the workspace settings pages use to read and change model sharing. */
|
|
386
|
+
const MODEL_SYNC_PATH = "/integrations/dataops/model-sync";
|
|
387
|
+
/** The live sync instance, so the settings routes can act on it after activation. */
|
|
388
|
+
let modelSyncRef;
|
|
389
|
+
/** The live model-sync instance, once this plugin activated. */
|
|
390
|
+
function currentModelSync() {
|
|
391
|
+
return modelSyncRef;
|
|
392
|
+
}
|
|
14
393
|
/** Schemastery parser for managed DataOps configuration. */
|
|
15
394
|
const Config = z.object({
|
|
16
395
|
baseUrl: z.string().default("http://host.docker.internal:3101"),
|
|
17
396
|
serverName: z.string().default("dataops"),
|
|
18
397
|
credentialRef: z.string().role("credential-ref").default("DATAOPS_ACCESS_TOKEN"),
|
|
19
|
-
toolCallTimeoutMs: z.number().min(1).default(12e4)
|
|
398
|
+
toolCallTimeoutMs: z.number().min(1).default(12e4),
|
|
399
|
+
/**
|
|
400
|
+
* Volatile so the settings service persists it in this workspace's document. The shape is
|
|
401
|
+
* left open because the sync state is this plugin's own record, not user configuration.
|
|
402
|
+
*/
|
|
403
|
+
modelSync: z.any().default({
|
|
404
|
+
detached: false,
|
|
405
|
+
adoptedModelIds: [],
|
|
406
|
+
privateModelIds: []
|
|
407
|
+
}).volatile()
|
|
20
408
|
});
|
|
21
409
|
function bearerToken(request) {
|
|
22
410
|
const authorization = request.headers.authorization?.trim() ?? "";
|
|
@@ -59,6 +447,35 @@ async function apply(ctx, config) {
|
|
|
59
447
|
if (fiber !== void 0 && fiber.uid !== null) await fiber.dispose();
|
|
60
448
|
}, "dataops-managed: MCP lifecycle");
|
|
61
449
|
if (await ctx.credentials.resolve(accessRef) !== void 0) await ensureMcp();
|
|
450
|
+
const modelSync = new ModelSync(ctx, {
|
|
451
|
+
baseUrl,
|
|
452
|
+
credentialRef: config.credentialRef
|
|
453
|
+
}, {
|
|
454
|
+
read: () => ({
|
|
455
|
+
detached: config.modelSync?.detached === true,
|
|
456
|
+
adoptedModelIds: Array.isArray(config.modelSync?.adoptedModelIds) ? config.modelSync.adoptedModelIds : [],
|
|
457
|
+
privateModelIds: Array.isArray(config.modelSync?.privateModelIds) ? config.modelSync.privateModelIds : []
|
|
458
|
+
}),
|
|
459
|
+
write: async (next) => {
|
|
460
|
+
await ctx.settings.update(MANAGED_PLUGIN_ID, { modelSync: next });
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
modelSyncRef = modelSync;
|
|
464
|
+
/**
|
|
465
|
+
* Adopt the publisher's models once the workspace has a session.
|
|
466
|
+
*
|
|
467
|
+
* Adoption waits for the JWT because the publication is read with it; a workspace that has
|
|
468
|
+
* not been authorized yet simply tries again when its session arrives.
|
|
469
|
+
*/
|
|
470
|
+
const adoptNow = async () => {
|
|
471
|
+
try {
|
|
472
|
+
await modelSync.adopt();
|
|
473
|
+
} catch (error) {
|
|
474
|
+
ctx.logger.warn("dataops-managed: model sync could not adopt the published models");
|
|
475
|
+
ctx.logger.warn(error);
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
if (await ctx.credentials.resolve(accessRef) !== void 0) await adoptNow();
|
|
62
479
|
ctx.effect(() => ctx.webServer.register({
|
|
63
480
|
kind: "exact",
|
|
64
481
|
path: MANAGED_AUTH_PATH,
|
|
@@ -79,10 +496,94 @@ async function apply(ctx, config) {
|
|
|
79
496
|
}
|
|
80
497
|
await ctx.credentials.set(accessRef, token);
|
|
81
498
|
await ensureMcp();
|
|
499
|
+
await adoptNow();
|
|
82
500
|
response.writeHead(204, { "cache-control": "no-store" });
|
|
83
501
|
response.end();
|
|
84
502
|
}
|
|
85
503
|
}), "dataops-managed: JWT intake route");
|
|
504
|
+
/**
|
|
505
|
+
* The settings surface the workspace's own pages read and write.
|
|
506
|
+
*
|
|
507
|
+
* Same-origin only: the browser reaches these through the gateway that already authenticated
|
|
508
|
+
* it, and the DataOps JWT they act with is the one this plugin holds, never a value the page
|
|
509
|
+
* supplies.
|
|
510
|
+
*/
|
|
511
|
+
ctx.effect(() => ctx.webServer.register({
|
|
512
|
+
kind: "exact",
|
|
513
|
+
path: MODEL_SYNC_PATH,
|
|
514
|
+
handler: async (request, response) => {
|
|
515
|
+
const sync = currentModelSync();
|
|
516
|
+
if (sync === void 0) {
|
|
517
|
+
writeJson(response, 503, { error: "model sync is not active" });
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
try {
|
|
521
|
+
if (request.method === "GET") {
|
|
522
|
+
writeJson(response, 200, await sync.status());
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (request.method !== "POST") {
|
|
526
|
+
response.writeHead(405, {
|
|
527
|
+
allow: "GET, POST",
|
|
528
|
+
"content-length": "0"
|
|
529
|
+
});
|
|
530
|
+
response.end();
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const body = await readJson(request);
|
|
534
|
+
const action = typeof body?.action === "string" ? body.action : "";
|
|
535
|
+
if (action === "publish") {
|
|
536
|
+
const sharingEnabled = body?.sharingEnabled === true;
|
|
537
|
+
await sync.publish(sharingEnabled);
|
|
538
|
+
writeJson(response, 200, await sync.status());
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (action === "detach") {
|
|
542
|
+
await sync.detach();
|
|
543
|
+
writeJson(response, 200, await sync.status());
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (action === "resume") {
|
|
547
|
+
await sync.resumeFollowing();
|
|
548
|
+
await sync.adopt();
|
|
549
|
+
writeJson(response, 200, await sync.status());
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (action === "private-models") {
|
|
553
|
+
const ids = Array.isArray(body?.privateModelIds) ? body.privateModelIds.filter((id) => typeof id === "string") : [];
|
|
554
|
+
await sync.setPrivateModels(ids, body?.sharingEnabled === true);
|
|
555
|
+
writeJson(response, 200, await sync.status());
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
writeJson(response, 400, { error: "unknown action" });
|
|
559
|
+
} catch (error) {
|
|
560
|
+
ctx.logger.warn("dataops-managed: model sync request failed");
|
|
561
|
+
ctx.logger.warn(error);
|
|
562
|
+
writeJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}), "dataops-managed: model sync routes");
|
|
566
|
+
}
|
|
567
|
+
/** Read and parse a JSON request body, tolerating an absent one. */
|
|
568
|
+
async function readJson(request) {
|
|
569
|
+
const chunks = [];
|
|
570
|
+
for await (const chunk of request) chunks.push(chunk);
|
|
571
|
+
if (chunks.length === 0) return null;
|
|
572
|
+
try {
|
|
573
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
574
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
|
|
575
|
+
} catch {
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
/** Answer one settings request as JSON. */
|
|
580
|
+
function writeJson(response, status, body) {
|
|
581
|
+
const payload = JSON.stringify(body);
|
|
582
|
+
response.writeHead(status, {
|
|
583
|
+
"content-type": "application/json",
|
|
584
|
+
"cache-control": "no-store"
|
|
585
|
+
});
|
|
586
|
+
response.end(payload);
|
|
86
587
|
}
|
|
87
588
|
//#endregion
|
|
88
|
-
export { Config, MANAGED_AUTH_PATH, apply, inject, name };
|
|
589
|
+
export { Config, MANAGED_AUTH_PATH, MANAGED_PLUGIN_ID, MODEL_SYNC_PATH, apply, currentModelSync, inject, name };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots';
|
|
2
2
|
import type { en } from './locales.ts';
|
|
3
|
+
import { MODEL_SYNC_PATH } from './model-sync-client.ts';
|
|
3
4
|
/** Values injected by the DSH Settings slot. */
|
|
4
5
|
export interface ManagedDataOpsSectionInjected {
|
|
5
6
|
/** Translate one managed DataOps Settings message key. */
|
|
@@ -8,8 +9,10 @@ export interface ManagedDataOpsSectionInjected {
|
|
|
8
9
|
/** Props accepted by the managed DataOps Settings section. */
|
|
9
10
|
export type ManagedDataOpsSectionProps = Partial<InjectFace<ManagedDataOpsSectionInjected>>;
|
|
10
11
|
/**
|
|
11
|
-
* Explain the DataOps-managed JWT and permission owner
|
|
12
|
+
* Explain the DataOps-managed JWT and permission owner, and share model configuration.
|
|
12
13
|
* @param props - Settings slot injection values.
|
|
13
14
|
* @returns The localized managed DataOps section, or nothing before injection.
|
|
14
15
|
*/
|
|
15
16
|
export declare function ManagedDataOpsSection(props: ManagedDataOpsSectionProps): import("react").JSX.Element | null;
|
|
17
|
+
/** Re-exported so the settings entry can be discovered from the section alone. */
|
|
18
|
+
export { MODEL_SYNC_PATH };
|
|
@@ -11,6 +11,24 @@ export declare const en: {
|
|
|
11
11
|
readonly brandingTitle: "Apply Wanxiang branding";
|
|
12
12
|
readonly brandingDescription: "Use the Wanxiang Data Platform identity in navigation, the New Session welcome view, browser title, and favicon.";
|
|
13
13
|
readonly brandingToggle: "Apply Wanxiang branding";
|
|
14
|
+
readonly modelsTitle: "Model configuration";
|
|
15
|
+
readonly modelsDescription: "Share the models configured here with other users, and choose which of them they may use.";
|
|
16
|
+
readonly modelsUnavailable: "Model configuration is unavailable until this workspace is connected to DataOps.";
|
|
17
|
+
readonly syncTitle: "Share models with other users";
|
|
18
|
+
readonly syncDescription: "Other users who have not configured their own models get the models configured here.";
|
|
19
|
+
readonly syncToggle: "Share models with other users";
|
|
20
|
+
readonly followTitle: "Using the shared model configuration";
|
|
21
|
+
readonly followDescription: "The models shared by an administrator are available here. Change them and this workspace keeps your version instead.";
|
|
22
|
+
readonly detachedTitle: "Using your own model configuration";
|
|
23
|
+
readonly detachedDescription: "This workspace keeps the models configured here and no longer follows the shared configuration.";
|
|
24
|
+
readonly resume: "Use the shared configuration again";
|
|
25
|
+
readonly resuming: "Switching…";
|
|
26
|
+
readonly noPublisher: "No administrator is sharing models right now.";
|
|
27
|
+
readonly modelsListTitle: "Models other users may use";
|
|
28
|
+
readonly modelsListDescription: "A model turned off here stays available in this workspace and is not offered to other users.";
|
|
29
|
+
readonly modelShareLabel: "Available to other users";
|
|
30
|
+
readonly modelsEmpty: "No models are configured in this workspace yet. Add them in Models settings first.";
|
|
31
|
+
readonly loadFailed: "The model configuration could not be loaded. Try again.";
|
|
14
32
|
};
|
|
15
33
|
/** Stable key set shared by every managed DataOps locale. */
|
|
16
34
|
export type ManagedDataOpsKey = keyof typeof en;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Same-origin model-sync calls from the DataOps settings surface. */
|
|
2
|
+
/** Route the plugin's Host half serves for model sharing. */
|
|
3
|
+
export declare const MODEL_SYNC_PATH = "/integrations/dataops/model-sync";
|
|
4
|
+
/** One model this workspace holds, and whether it is offered to other users. */
|
|
5
|
+
export interface ModelSyncModel {
|
|
6
|
+
/** Model id as the settings document stores it. */
|
|
7
|
+
id: string;
|
|
8
|
+
/** Whether other users may use it. */
|
|
9
|
+
shared: boolean;
|
|
10
|
+
}
|
|
11
|
+
/** Model sharing as this workspace currently stands. */
|
|
12
|
+
export interface ModelSyncStatus {
|
|
13
|
+
/** Whether this workspace follows a publisher. */
|
|
14
|
+
followState: 'following' | 'detached';
|
|
15
|
+
/** Whether an administrator currently shares models. */
|
|
16
|
+
publisherSharing: boolean;
|
|
17
|
+
/** How many models this workspace adopts from the publisher. */
|
|
18
|
+
adoptedCount: number;
|
|
19
|
+
/** Model ids kept private. */
|
|
20
|
+
privateModelIds: string[];
|
|
21
|
+
/** Publication time, when one is sharing. */
|
|
22
|
+
publishedAt: string | null;
|
|
23
|
+
/** Whether the current user may change what this workspace shares. */
|
|
24
|
+
canPublish: boolean;
|
|
25
|
+
/** Models this workspace holds. */
|
|
26
|
+
models: ModelSyncModel[];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Read the current model sharing state.
|
|
30
|
+
* @returns The status, or null when this workspace is not connected to DataOps.
|
|
31
|
+
*/
|
|
32
|
+
export declare function fetchModelSyncStatus(): Promise<ModelSyncStatus | null>;
|
|
33
|
+
/**
|
|
34
|
+
* Change what this workspace shares.
|
|
35
|
+
* @param body - Action and its parameters.
|
|
36
|
+
* @returns The status after the change, or null when the plugin is not active.
|
|
37
|
+
*/
|
|
38
|
+
export declare function postModelSyncAction(body: Record<string, unknown>): Promise<ModelSyncStatus | null>;
|
package/lib/types/index.d.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
/** DataOps-managed JWT intake and credential-backed MCP composition. */
|
|
2
2
|
import type { Context } from '@deepseek-ai/cordis';
|
|
3
3
|
import z from '@deepseek-ai/schemastery';
|
|
4
|
+
import { ModelSync } from './model-sync.ts';
|
|
4
5
|
/** Cordis plugin name for the DataOps-managed integration. */
|
|
5
6
|
export declare const name = "dataops-managed";
|
|
6
7
|
/** Host services required by managed JWT and MCP ownership. */
|
|
7
8
|
export declare const inject: string[];
|
|
8
9
|
export declare const MANAGED_AUTH_PATH = "/integrations/dataops/managed-auth";
|
|
10
|
+
/** This plugin's profile entry id, which owns its Config and sync state. */
|
|
11
|
+
export declare const MANAGED_PLUGIN_ID = "dataops-managed";
|
|
12
|
+
/** Same-origin route the workspace settings pages use to read and change model sharing. */
|
|
13
|
+
export declare const MODEL_SYNC_PATH = "/integrations/dataops/model-sync";
|
|
14
|
+
/** The live model-sync instance, once this plugin activated. */
|
|
15
|
+
export declare function currentModelSync(): ModelSync | undefined;
|
|
9
16
|
/** Configuration for the DataOps-managed MCP connection. */
|
|
10
17
|
export interface Config {
|
|
11
18
|
/** DataOps browser/API origin reachable from the DSH Host. */
|
|
@@ -16,6 +23,23 @@ export interface Config {
|
|
|
16
23
|
credentialRef: string;
|
|
17
24
|
/** Per-tool-call timeout forwarded to the MCP client. */
|
|
18
25
|
toolCallTimeoutMs: number;
|
|
26
|
+
/**
|
|
27
|
+
* Model-sync state this workspace keeps.
|
|
28
|
+
*
|
|
29
|
+
* Volatile, so the settings service stores it in this workspace's own document. It belongs
|
|
30
|
+
* to the workspace rather than to DataOps because it records whether the user here wrote
|
|
31
|
+
* their own models, which is a fact about this workspace.
|
|
32
|
+
*/
|
|
33
|
+
modelSync: ModelSyncState;
|
|
34
|
+
}
|
|
35
|
+
/** What this workspace remembers about sharing model configuration. */
|
|
36
|
+
export interface ModelSyncState {
|
|
37
|
+
/** The user here chose their own models, so adoption stops for them. */
|
|
38
|
+
detached: boolean;
|
|
39
|
+
/** Model ids adoption last wrote, so a later edit can be told apart from one of them. */
|
|
40
|
+
adoptedModelIds: string[];
|
|
41
|
+
/** Model ids the administrator keeps private; every other model is offered by default. */
|
|
42
|
+
privateModelIds: string[];
|
|
19
43
|
}
|
|
20
44
|
/** Schemastery parser for managed DataOps configuration. */
|
|
21
45
|
export declare const Config: z<Config>;
|