@skaile/forge-settings 1.3.3 → 1.4.0
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/CHANGELOG.md +15 -0
- package/README.md +20 -0
- package/dist/module.json +1 -1
- package/dist/runtime/components/ForgeProvidersPanel.d.vue.ts +13 -0
- package/dist/runtime/components/ForgeProvidersPanel.vue +461 -78
- package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +13 -0
- package/dist/runtime/server/harness-settings.d.ts +50 -12
- package/dist/runtime/server/harness-settings.js +93 -9
- package/dist/runtime/server/index.d.ts +2 -0
- package/dist/runtime/server/index.js +2 -0
- package/dist/runtime/server/provider-service.d.ts +26 -2
- package/dist/runtime/server/provider-service.js +16 -1
- package/dist/runtime/server/provider-write.d.ts +25 -9
- package/dist/runtime/server/provider-write.js +72 -4
- package/dist/runtime/shared/data-zone.d.ts +66 -0
- package/dist/runtime/shared/data-zone.js +79 -0
- package/dist/runtime/shared/harness-providers.d.ts +41 -2
- package/dist/runtime/shared/harness-providers.js +31 -8
- package/dist/runtime/shared/harnesses.d.ts +7 -0
- package/dist/runtime/shared/harnesses.js +18 -0
- package/dist/runtime/shared/provider-accounts.d.ts +215 -0
- package/dist/runtime/shared/provider-accounts.js +255 -0
- package/dist/runtime/shared/provider-registry.d.ts +38 -0
- package/dist/runtime/shared/provider-registry.js +16 -0
- package/dist/runtime/shared/settings-types.d.ts +7 -0
- package/dist/runtime/vue/index.d.ts +2 -0
- package/dist/runtime/vue/index.js +2 -0
- package/dist/runtime/vue/providers-panel-model.d.ts +98 -5
- package/dist/runtime/vue/providers-panel-model.js +13 -1
- package/dist/runtime/vue/useProvidersPanel.d.ts +66 -5
- package/dist/runtime/vue/useProvidersPanel.js +417 -29
- package/package.json +2 -2
- package/src/runtime/components/ForgeProvidersPanel.vue +477 -78
- package/src/runtime/server/harness-settings.ts +165 -21
- package/src/runtime/server/index.ts +2 -0
- package/src/runtime/server/provider-service.ts +53 -1
- package/src/runtime/server/provider-write.ts +129 -15
- package/src/runtime/shared/data-zone.ts +172 -0
- package/src/runtime/shared/harness-providers.ts +77 -6
- package/src/runtime/shared/harnesses.ts +25 -0
- package/src/runtime/shared/provider-accounts.ts +526 -0
- package/src/runtime/shared/provider-registry.ts +58 -0
- package/src/runtime/shared/settings-types.ts +7 -0
- package/src/runtime/vue/index.ts +2 -0
- package/src/runtime/vue/providers-panel-model.ts +123 -5
- package/src/runtime/vue/useProvidersPanel.ts +586 -37
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
* What lives here, so hosts only read and write their own storage:
|
|
9
9
|
*
|
|
10
10
|
* - {@link validateHarnessSettingsPatch} — the rule set for a harness settings
|
|
11
|
-
* write (`harness`, `harnessModels`, `
|
|
12
|
-
* `lock*`), as a pure function over the request body and
|
|
11
|
+
* write (`harness`, `harnessModels`, `harnessRoles`, `harnessProviders`,
|
|
12
|
+
* `enabledHarnesses`, `lock*`), as a pure function over the request body and
|
|
13
|
+
* the stored state.
|
|
13
14
|
* - {@link resolveLockFlags} — the admin lock flags in force. Locks are
|
|
14
15
|
* `settings.json` fields only; there is no environment override.
|
|
15
|
-
* - {@link applyHarnessLayers} — which harness, model and provider pick
|
|
16
|
-
* session starts with, given the settings layers the host read.
|
|
16
|
+
* - {@link applyHarnessLayers} — which harness, model, roles and provider pick
|
|
17
|
+
* a session starts with, given the settings layers the host read.
|
|
17
18
|
* - {@link enabledHarnessIds} / {@link buildHarnessRows} — the harness catalog
|
|
18
19
|
* `GET /api/agents` serves.
|
|
19
20
|
*
|
|
@@ -22,11 +23,18 @@
|
|
|
22
23
|
|
|
23
24
|
import type { HarnessDetection } from "@skaile/workspaces/sdk/bridge";
|
|
24
25
|
import type { HarnessInfo } from "@skaile/workspaces/types";
|
|
26
|
+
import {
|
|
27
|
+
type HarnessProviderBlock,
|
|
28
|
+
type HarnessProviderCandidate,
|
|
29
|
+
harnessProviderBlock,
|
|
30
|
+
} from "../shared/harness-providers";
|
|
25
31
|
import {
|
|
26
32
|
DEFAULT_ENABLED_HARNESSES,
|
|
27
33
|
HARNESS_LABELS,
|
|
34
|
+
harnessRoleIds,
|
|
28
35
|
isExperimentalHarness,
|
|
29
36
|
} from "../shared/harnesses";
|
|
37
|
+
import type { DataZoneRule } from "../shared/provider-registry";
|
|
30
38
|
import type { SkaileSettings } from "../shared/settings-types";
|
|
31
39
|
|
|
32
40
|
/** The slice of {@link SkaileSettings} that selects, configures and locks harnesses. */
|
|
@@ -34,6 +42,7 @@ export type HarnessSettings = Pick<
|
|
|
34
42
|
SkaileSettings,
|
|
35
43
|
| "harness"
|
|
36
44
|
| "harnessModels"
|
|
45
|
+
| "harnessRoles"
|
|
37
46
|
| "harnessProviders"
|
|
38
47
|
| "enabledHarnesses"
|
|
39
48
|
| "lockHarness"
|
|
@@ -99,29 +108,65 @@ export type HarnessRow = HarnessInfo & {
|
|
|
99
108
|
enabled: boolean;
|
|
100
109
|
/** What `detectHarnesses()` found on this host: installed, version, login. */
|
|
101
110
|
detection: HarnessDetection;
|
|
111
|
+
/** The role ids the harness takes ({@link harnessRoleIds}); empty for a one-model harness. */
|
|
112
|
+
roles: string[];
|
|
113
|
+
/** The role → model selector assignments of `s.harnessRoles` for this harness (pass effective settings). */
|
|
114
|
+
roleAssignments: Record<string, string>;
|
|
115
|
+
/**
|
|
116
|
+
* Registry entries on a surface the harness speaks that the host's
|
|
117
|
+
* data-zone rule keeps out, by entry id (reason `blocked-by-rule`, with the
|
|
118
|
+
* message a table shows on the box it cannot tick). Empty without
|
|
119
|
+
* `providers` or under the rule `any`.
|
|
120
|
+
*/
|
|
121
|
+
blockedProviders: Record<string, HarnessProviderBlock>;
|
|
102
122
|
};
|
|
103
123
|
|
|
124
|
+
/** The registry entries {@link buildHarnessRows} checks against the host's data-zone rule. */
|
|
125
|
+
export interface HarnessRowProviders {
|
|
126
|
+
/** `GET /api/providers` rows (or any {@link HarnessProviderCandidate}s), in registry order. */
|
|
127
|
+
candidates: readonly HarnessProviderCandidate[];
|
|
128
|
+
/** The host's data-zone rule. Absent: `any`. */
|
|
129
|
+
dataZoneRule?: DataZoneRule;
|
|
130
|
+
}
|
|
131
|
+
|
|
104
132
|
/**
|
|
105
133
|
* The rows of `GET /api/agents`: one per harness, in `harnesses` order. Hosts
|
|
106
134
|
* filter to `enabled` unless the request asked for all (`?all=1`).
|
|
107
135
|
* `surfaces` and `providerPlurality` ride along from {@link HarnessInfo} so the
|
|
108
|
-
* Harness tab can offer each harness only the providers it can use
|
|
136
|
+
* Harness tab can offer each harness only the providers it can use; `roles`
|
|
137
|
+
* and `roleAssignments` drive the "Roles" badge and the models-per-task panel.
|
|
109
138
|
* `detections` is the host's `detectHarnesses()` answer; a harness it does not
|
|
110
139
|
* mention (or every harness, when it is omitted) reports `{ status: "unknown" }`.
|
|
140
|
+
* `providers`, when given, fills each row's `blockedProviders`.
|
|
111
141
|
*/
|
|
112
142
|
export function buildHarnessRows(
|
|
113
143
|
harnesses: HarnessInfo[],
|
|
114
|
-
s: Pick<HarnessSettings, "enabledHarnesses">,
|
|
144
|
+
s: Pick<HarnessSettings, "enabledHarnesses" | "harnessRoles">,
|
|
115
145
|
detections: readonly HarnessDetection[] = [],
|
|
146
|
+
providers?: HarnessRowProviders,
|
|
116
147
|
): HarnessRow[] {
|
|
117
148
|
const enabled = new Set(enabledHarnessIds(s));
|
|
118
|
-
return harnesses.map((info) =>
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
149
|
+
return harnesses.map((info) => {
|
|
150
|
+
const roles = harnessRoleIds(info.id);
|
|
151
|
+
const stored = stringEntries(s.harnessRoles?.[info.id]);
|
|
152
|
+
const roleAssignments: Record<string, string> = {};
|
|
153
|
+
for (const role of roles) if (stored[role]) roleAssignments[role] = stored[role];
|
|
154
|
+
const blockedProviders: Record<string, HarnessProviderBlock> = {};
|
|
155
|
+
for (const candidate of providers?.candidates ?? []) {
|
|
156
|
+
const block = harnessProviderBlock(info, candidate, providers?.dataZoneRule);
|
|
157
|
+
if (block) blockedProviders[candidate.id] = block;
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
...info,
|
|
161
|
+
label: HARNESS_LABELS[info.id] ?? info.name,
|
|
162
|
+
experimental: isExperimentalHarness(info.id),
|
|
163
|
+
enabled: enabled.has(info.id),
|
|
164
|
+
detection: detections.find((d) => d.id === info.id) ?? { id: info.id, status: "unknown" },
|
|
165
|
+
roles,
|
|
166
|
+
roleAssignments,
|
|
167
|
+
blockedProviders,
|
|
168
|
+
};
|
|
169
|
+
});
|
|
125
170
|
}
|
|
126
171
|
|
|
127
172
|
// ── validateHarnessSettingsPatch ────────────────────────────────────────────
|
|
@@ -180,6 +225,28 @@ function sameIdLists(a: Record<string, string[]>, b: Record<string, string[]>):
|
|
|
180
225
|
);
|
|
181
226
|
}
|
|
182
227
|
|
|
228
|
+
/** The non-empty role maps of a stored `harnessRoles` value. */
|
|
229
|
+
function roleMaps(value: unknown): Record<string, Record<string, string>> {
|
|
230
|
+
const maps: Record<string, Record<string, string>> = {};
|
|
231
|
+
if (!isObject(value)) return maps;
|
|
232
|
+
for (const [harnessId, roles] of Object.entries(value)) {
|
|
233
|
+
const entries = stringEntries(roles);
|
|
234
|
+
if (Object.keys(entries).length) maps[harnessId] = entries;
|
|
235
|
+
}
|
|
236
|
+
return maps;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function sameRoleMaps(
|
|
240
|
+
a: Record<string, Record<string, string>>,
|
|
241
|
+
b: Record<string, Record<string, string>>,
|
|
242
|
+
): boolean {
|
|
243
|
+
const keys = Object.keys(a);
|
|
244
|
+
return (
|
|
245
|
+
keys.length === Object.keys(b).length &&
|
|
246
|
+
keys.every((key) => b[key] !== undefined && sameEntries(a[key]!, b[key]))
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
183
250
|
/** What {@link validateHarnessSettingsPatch} hands back for a valid body. */
|
|
184
251
|
export type HarnessSettingsPatch = {
|
|
185
252
|
/**
|
|
@@ -188,9 +255,9 @@ export type HarnessSettingsPatch = {
|
|
|
188
255
|
*/
|
|
189
256
|
patch: Partial<HarnessSettings>;
|
|
190
257
|
/**
|
|
191
|
-
* Whether the effective `harness`, `harnessModels`, `
|
|
192
|
-
* `enabledHarnesses` changed, i.e. whether running
|
|
193
|
-
* defaults and the host should stop them.
|
|
258
|
+
* Whether the effective `harness`, `harnessModels`, `harnessRoles`,
|
|
259
|
+
* `harnessProviders` or `enabledHarnesses` changed, i.e. whether running
|
|
260
|
+
* sessions now run on stale defaults and the host should stop them.
|
|
194
261
|
*/
|
|
195
262
|
sessionAffecting: boolean;
|
|
196
263
|
};
|
|
@@ -206,6 +273,11 @@ export type HarnessSettingsPatch = {
|
|
|
206
273
|
* keys are present `harness` wins.
|
|
207
274
|
* - `harnessModels`: patched per harness id (catalog ids only); a value is a
|
|
208
275
|
* `providerId/modelId` selector, an empty string removes that harness's entry.
|
|
276
|
+
* - `harnessRoles`: patched per harness id, then per role. A harness must be in
|
|
277
|
+
* the catalog and take roles ({@link harnessRoleIds}); a role must be one of
|
|
278
|
+
* its roles; a value is a `providerId/modelId` selector, and one of
|
|
279
|
+
* `ctx.runnableModels` when that list is given. An empty string or `null`
|
|
280
|
+
* removes the role; `{}` or `null` for a harness removes all its roles.
|
|
209
281
|
* - `harnessProviders`: the registry entry ids picked per harness id. The modal
|
|
210
282
|
* sends the whole map, so it replaces the stored one: each value must be an
|
|
211
283
|
* array of strings (empty strings are dropped, duplicates collapse, order is
|
|
@@ -226,12 +298,19 @@ export type HarnessSettingsPatch = {
|
|
|
226
298
|
* @param ctx.catalog valid harness ids (`listHarnesses().map(d => d.id)`).
|
|
227
299
|
* @param ctx.current the stored settings the patch applies to (the instance file).
|
|
228
300
|
* @param ctx.locks the lock flags in force ({@link resolveLockFlags} of `current`).
|
|
301
|
+
* @param ctx.runnableModels optional: the selectors a session may run; when
|
|
302
|
+
* given, a `harnessRoles` value outside it is refused.
|
|
229
303
|
* @returns the patch, or the error message of the first violated rule (a
|
|
230
304
|
* string, mirroring `validateEnvSettingsUpdate`; hosts answer 400 with it).
|
|
231
305
|
*/
|
|
232
306
|
export function validateHarnessSettingsPatch(
|
|
233
307
|
body: unknown,
|
|
234
|
-
ctx: {
|
|
308
|
+
ctx: {
|
|
309
|
+
catalog: Iterable<string>;
|
|
310
|
+
current: HarnessSettings;
|
|
311
|
+
locks: LockFlags;
|
|
312
|
+
runnableModels?: Iterable<string>;
|
|
313
|
+
},
|
|
235
314
|
): HarnessSettingsPatch | string {
|
|
236
315
|
if (!isObject(body)) return "body must be an object";
|
|
237
316
|
if ("model" in body) return "model is set per harness - use harnessModels";
|
|
@@ -291,6 +370,52 @@ export function validateHarnessSettingsPatch(
|
|
|
291
370
|
}
|
|
292
371
|
}
|
|
293
372
|
|
|
373
|
+
if ("harnessRoles" in body) {
|
|
374
|
+
const raw = body.harnessRoles;
|
|
375
|
+
if (!isObject(raw)) return "harnessRoles must be an object";
|
|
376
|
+
const runnable = ctx.runnableModels ? new Set(ctx.runnableModels) : undefined;
|
|
377
|
+
const currentRoles = roleMaps(current.harnessRoles);
|
|
378
|
+
const nextRoles: Record<string, Record<string, string>> = {};
|
|
379
|
+
for (const [id, roles] of Object.entries(currentRoles)) nextRoles[id] = { ...roles };
|
|
380
|
+
for (const [id, roles] of Object.entries(raw)) {
|
|
381
|
+
if (!catalog.has(id)) return `harnessRoles: unknown harness "${id}"`;
|
|
382
|
+
const supported = harnessRoleIds(id);
|
|
383
|
+
if (!supported.length)
|
|
384
|
+
return `harnessRoles: harness "${id}" runs one model and takes no roles`;
|
|
385
|
+
if (roles === null) {
|
|
386
|
+
delete nextRoles[id];
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (!isObject(roles)) return `harnessRoles.${id} must be an object`;
|
|
390
|
+
if (!Object.keys(roles).length) {
|
|
391
|
+
delete nextRoles[id];
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
const next = { ...nextRoles[id] };
|
|
395
|
+
for (const [role, entry] of Object.entries(roles)) {
|
|
396
|
+
if (!supported.includes(role)) return `harnessRoles.${id}: unknown role "${role}"`;
|
|
397
|
+
if (entry !== null && typeof entry !== "string")
|
|
398
|
+
return `harnessRoles.${id}.${role} must be a string`;
|
|
399
|
+
const value = typeof entry === "string" ? entry.trim() : "";
|
|
400
|
+
if (!value) {
|
|
401
|
+
delete next[role];
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (!isModelSelector(value))
|
|
405
|
+
return `harnessRoles.${id}.${role} must be a "providerId/modelId" selector`;
|
|
406
|
+
if (runnable && !runnable.has(value))
|
|
407
|
+
return `harnessRoles.${id}.${role}: model "${value}" is not runnable`;
|
|
408
|
+
next[role] = value;
|
|
409
|
+
}
|
|
410
|
+
if (Object.keys(next).length) nextRoles[id] = next;
|
|
411
|
+
else delete nextRoles[id];
|
|
412
|
+
}
|
|
413
|
+
if (!sameRoleMaps(currentRoles, nextRoles)) {
|
|
414
|
+
patch.harnessRoles = Object.keys(nextRoles).length ? nextRoles : undefined;
|
|
415
|
+
sessionAffecting = true;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
294
419
|
if ("enabledHarnesses" in body) {
|
|
295
420
|
const raw = body.enabledHarnesses;
|
|
296
421
|
if (!Array.isArray(raw) || !raw.every((id) => typeof id === "string")) {
|
|
@@ -321,18 +446,20 @@ export function validateHarnessSettingsPatch(
|
|
|
321
446
|
|
|
322
447
|
// ── applyHarnessLayers ──────────────────────────────────────────────────────
|
|
323
448
|
|
|
324
|
-
/** One settings layer's say in which harness, model and provider pick a session gets. */
|
|
449
|
+
/** One settings layer's say in which harness, model, roles and provider pick a session gets. */
|
|
325
450
|
export type HarnessLayer = {
|
|
326
451
|
/** The harness id (the workspaces settings key `harness`). */
|
|
327
452
|
harness?: string;
|
|
328
453
|
/** An explicit model selector; wins over this layer's `harnessModels` entry. */
|
|
329
454
|
model?: string;
|
|
330
455
|
harnessModels?: Record<string, string>;
|
|
456
|
+
/** Role → model selector per harness id; layered per role. */
|
|
457
|
+
harnessRoles?: Record<string, Record<string, string>>;
|
|
331
458
|
harnessProviders?: Record<string, string[]>;
|
|
332
459
|
};
|
|
333
460
|
|
|
334
461
|
/**
|
|
335
|
-
* Resolve the harness, model and provider pick a session starts with.
|
|
462
|
+
* Resolve the harness, model, roles and provider pick a session starts with.
|
|
336
463
|
*
|
|
337
464
|
* `layers[0]` is the instance layer; later layers (workspace file, workspace
|
|
338
465
|
* overrides, chat, …) are more specific and override it per field — the last
|
|
@@ -346,12 +473,21 @@ export type HarnessLayer = {
|
|
|
346
473
|
* `layer.harnessProviders[harness]` — both keyed by the *resolved* harness, so
|
|
347
474
|
* a pick stored for another harness never applies.
|
|
348
475
|
*
|
|
476
|
+
* `roles` is layered per role of the resolved harness: for each role the last
|
|
477
|
+
* layer with a non-empty selector wins; `locks.model` freezes it to layer 0's
|
|
478
|
+
* roles, as it freezes `model`. No role set anywhere → `undefined`.
|
|
479
|
+
*
|
|
349
480
|
* Fields no layer sets come back `undefined`; the host supplies its defaults.
|
|
350
481
|
*/
|
|
351
482
|
export function applyHarnessLayers(
|
|
352
483
|
layers: HarnessLayer[],
|
|
353
484
|
locks: LockFlags,
|
|
354
|
-
): {
|
|
485
|
+
): {
|
|
486
|
+
harness: string | undefined;
|
|
487
|
+
model: string | undefined;
|
|
488
|
+
roles: Record<string, string> | undefined;
|
|
489
|
+
providers: string[] | undefined;
|
|
490
|
+
} {
|
|
355
491
|
/** The layer-0 value when `locked`, else the last layer's non-undefined value. */
|
|
356
492
|
const pick = <T>(
|
|
357
493
|
locked: boolean,
|
|
@@ -376,5 +512,13 @@ export function applyHarnessLayers(
|
|
|
376
512
|
const ids = harness === undefined ? undefined : layer.harnessProviders?.[harness];
|
|
377
513
|
return ids?.length ? ids : undefined;
|
|
378
514
|
});
|
|
379
|
-
|
|
515
|
+
let roles: Record<string, string> | undefined;
|
|
516
|
+
if (harness !== undefined) {
|
|
517
|
+
const merged: Record<string, string> = {};
|
|
518
|
+
const roleLayers = locks.model.value ? layers.slice(0, 1) : layers;
|
|
519
|
+
for (const layer of roleLayers)
|
|
520
|
+
Object.assign(merged, stringEntries(layer.harnessRoles?.[harness]));
|
|
521
|
+
if (Object.keys(merged).length) roles = merged;
|
|
522
|
+
}
|
|
523
|
+
return { harness, model, roles, providers };
|
|
380
524
|
}
|
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
* authentication and workspace permissions stay with the host.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
export * from "../shared/data-zone";
|
|
10
11
|
export * from "../shared/harness-providers";
|
|
11
12
|
export * from "../shared/harnesses";
|
|
12
13
|
export * from "../shared/model-roles";
|
|
14
|
+
export * from "../shared/provider-accounts";
|
|
13
15
|
export * from "../shared/provider-registry";
|
|
14
16
|
export type { DomainConfig, SkaileSettings } from "../shared/settings-types";
|
|
15
17
|
export * from "./credential-policy";
|
|
@@ -19,17 +19,21 @@
|
|
|
19
19
|
* `listModelsForHarness(harnessId, registry)` in the runtime.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
import { entryDataZone } from "../shared/data-zone";
|
|
22
23
|
import type {
|
|
23
24
|
AIProviderCapabilities,
|
|
24
25
|
AIProviderCredentialKind,
|
|
25
26
|
AIProviderKind,
|
|
26
27
|
AIProviderRegistryView,
|
|
27
28
|
AIProviderSurface,
|
|
29
|
+
AIProviderTransport,
|
|
28
30
|
AIProviderVoiceParams,
|
|
31
|
+
DataZone,
|
|
29
32
|
} from "../shared/provider-registry";
|
|
30
33
|
import {
|
|
31
34
|
entriesOfKind,
|
|
32
35
|
isEntryEnabled,
|
|
36
|
+
isModelAllowed,
|
|
33
37
|
isVoiceEntry,
|
|
34
38
|
parseCredentialRef,
|
|
35
39
|
registryEntries,
|
|
@@ -76,6 +80,14 @@ export interface ProviderInfo {
|
|
|
76
80
|
preset?: string;
|
|
77
81
|
/** Wire protocol of an `llm`/`embed` entry (its own or its preset's). */
|
|
78
82
|
surface?: AIProviderSurface;
|
|
83
|
+
/** How requests of an `llm`/`embed` entry reach its surface, when not `direct`. */
|
|
84
|
+
transport?: AIProviderTransport;
|
|
85
|
+
/**
|
|
86
|
+
* Routing parameters of an `llm`/`embed` entry (`resource`, `deployment`,
|
|
87
|
+
* `api_version`, `region`, `project`, `location`, …), when it has any.
|
|
88
|
+
* Voice entries carry theirs in {@link params}.
|
|
89
|
+
*/
|
|
90
|
+
transportParams?: Record<string, string>;
|
|
79
91
|
/** The entry's `enabled` flag; `false` only in a listing with `includeDisabled`. */
|
|
80
92
|
enabled: boolean;
|
|
81
93
|
/**
|
|
@@ -87,6 +99,21 @@ export interface ProviderInfo {
|
|
|
87
99
|
params?: AIProviderVoiceParams;
|
|
88
100
|
/** Frozen by the control plane: a lower layer cannot change or disable it. */
|
|
89
101
|
locked: boolean;
|
|
102
|
+
/** Where the entry processes data: its `data_zone`, else its preset's, else `transfer`. */
|
|
103
|
+
dataZone: DataZone;
|
|
104
|
+
/** The entry's `eu_processing` switch (an `eu_option` entry counts as EU only with it on). */
|
|
105
|
+
euProcessing: boolean;
|
|
106
|
+
/**
|
|
107
|
+
* The entry's `allowed_models`, when it sets one: the only models it may be
|
|
108
|
+
* used with (`[]` = none). Absent: every model.
|
|
109
|
+
*/
|
|
110
|
+
allowedModels?: string[];
|
|
111
|
+
/**
|
|
112
|
+
* Logical model ids an `llm`/`embed` entry declares (`model_map` keys, then
|
|
113
|
+
* ids only `capabilities` names), when it declares any. An entry without
|
|
114
|
+
* them lists its models through discovery.
|
|
115
|
+
*/
|
|
116
|
+
modelIds?: string[];
|
|
90
117
|
}
|
|
91
118
|
|
|
92
119
|
/** One selectable model of one provider. */
|
|
@@ -162,21 +189,45 @@ export function listAIProviders(
|
|
|
162
189
|
...(entry.base_url ? { baseUrl: entry.base_url } : {}),
|
|
163
190
|
...(entry.preset ? { preset: entry.preset } : {}),
|
|
164
191
|
...(!isVoiceEntry(entry) && entry.surface ? { surface: entry.surface } : {}),
|
|
192
|
+
...(!isVoiceEntry(entry) && entry.transport && entry.transport !== "direct"
|
|
193
|
+
? { transport: entry.transport }
|
|
194
|
+
: {}),
|
|
195
|
+
...(!isVoiceEntry(entry) && entry.params && Object.keys(entry.params).length > 0
|
|
196
|
+
? { transportParams: { ...(entry.params as Record<string, string>) } }
|
|
197
|
+
: {}),
|
|
165
198
|
enabled,
|
|
166
199
|
...(isVoiceEntry(entry) && entry.params ? { params: entry.params } : {}),
|
|
167
200
|
locked: entry.locked === true,
|
|
201
|
+
dataZone: entryDataZone(entry),
|
|
202
|
+
euProcessing: entry.eu_processing === true,
|
|
203
|
+
...(!isVoiceEntry(entry) && entry.allowed_models
|
|
204
|
+
? { allowedModels: [...entry.allowed_models] }
|
|
205
|
+
: {}),
|
|
206
|
+
...(!isVoiceEntry(entry) ? declaredModelIds(entry) : {}),
|
|
168
207
|
});
|
|
169
208
|
}
|
|
170
209
|
return providers;
|
|
171
210
|
}
|
|
172
211
|
|
|
212
|
+
/** `{ modelIds }` of an `llm`/`embed` entry that declares models, else `{}`. */
|
|
213
|
+
function declaredModelIds(entry: {
|
|
214
|
+
model_map?: Record<string, string>;
|
|
215
|
+
capabilities?: Record<string, unknown>;
|
|
216
|
+
}): { modelIds?: string[] } {
|
|
217
|
+
const ids = [
|
|
218
|
+
...new Set([...Object.keys(entry.model_map ?? {}), ...Object.keys(entry.capabilities ?? {})]),
|
|
219
|
+
];
|
|
220
|
+
return ids.length > 0 ? { modelIds: ids } : {};
|
|
221
|
+
}
|
|
222
|
+
|
|
173
223
|
/**
|
|
174
224
|
* List the models the registry declares, as `"<providerId>/<modelId>"`
|
|
175
225
|
* selectors.
|
|
176
226
|
*
|
|
177
227
|
* Sourced from each `llm`/`embed` entry's `model_map` (plus any id that only
|
|
178
228
|
* appears in `capabilities`) — the same two tables the runtime's model-reference
|
|
179
|
-
* resolver reads, so a selector built from this list always resolves.
|
|
229
|
+
* resolver reads, so a selector built from this list always resolves. A model
|
|
230
|
+
* outside the entry's `allowed_models` is left out, as the runtime refuses it.
|
|
180
231
|
*
|
|
181
232
|
* @param providerId - Restrict to one entry; omit for every enabled entry.
|
|
182
233
|
*/
|
|
@@ -193,6 +244,7 @@ export function listAIProviderModels(
|
|
|
193
244
|
...Object.keys(entry.capabilities ?? {}),
|
|
194
245
|
]);
|
|
195
246
|
for (const modelId of logicalIds) {
|
|
247
|
+
if (!isModelAllowed(entry, modelId)) continue;
|
|
196
248
|
const capabilities = entry.capabilities?.[modelId];
|
|
197
249
|
models.push({
|
|
198
250
|
id: `${id}/${modelId}`,
|
|
@@ -39,7 +39,9 @@ import {
|
|
|
39
39
|
type AIProviderPreset,
|
|
40
40
|
expandPreset,
|
|
41
41
|
} from "@skaile/workspaces/sdk/bridge";
|
|
42
|
+
import { isDataZone } from "../shared/data-zone";
|
|
42
43
|
import { isPlainObject } from "../shared/guards";
|
|
44
|
+
import { missingRequiredParams } from "../shared/provider-accounts";
|
|
43
45
|
import type { AIProviderEntry, AIProviderRegistry } from "../shared/provider-registry";
|
|
44
46
|
import { type CredentialPolicy, entryPolicyRefusal } from "./credential-policy";
|
|
45
47
|
import { catalogPreset, readProviderLayer, writeProviderLayer } from "./provider-layers";
|
|
@@ -77,9 +79,8 @@ const PATCH_REFUSAL =
|
|
|
77
79
|
"A workspace may only patch credential and enabled of an inherited entry; add it as a workspace entry to change anything else.";
|
|
78
80
|
|
|
79
81
|
/**
|
|
80
|
-
* Entry keys a `PUT` body may set, and that an omitting body removes.
|
|
81
|
-
* may be set too
|
|
82
|
-
* "Custom") has nothing else to supply it — but, omitted, it keeps its
|
|
82
|
+
* Entry keys a `PUT` body may set, and that an omitting body removes. The
|
|
83
|
+
* keys of {@link KEPT_BODY_KEYS} may be set too but, omitted, keep their
|
|
83
84
|
* written value like every other key of a stored entry.
|
|
84
85
|
*/
|
|
85
86
|
const BODY_KEYS: Record<string, true> = {
|
|
@@ -93,6 +94,44 @@ const BODY_KEYS: Record<string, true> = {
|
|
|
93
94
|
enabled: true,
|
|
94
95
|
};
|
|
95
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Keys a `PUT` body may set that an omitting body keeps. `kind`: an entry
|
|
99
|
+
* without a `preset` (the settings modal's "Custom") has nothing else to
|
|
100
|
+
* supply it. `params`: transport routing (`resource`, `deployment`,
|
|
101
|
+
* `api_version`, `region`, …) or voice parameters, which an edit that does
|
|
102
|
+
* not show them must not drop; `params: {}` removes them. `data_zone`,
|
|
103
|
+
* `eu_processing`, `allowed_models` ({@link NULLABLE_BODY_KEYS}): the zone
|
|
104
|
+
* override, the account's EU-processing switch and the model allow-list,
|
|
105
|
+
* each changed on its own by the settings panel; `null` removes them.
|
|
106
|
+
*/
|
|
107
|
+
const KEPT_BODY_KEYS: Record<string, true> = {
|
|
108
|
+
kind: true,
|
|
109
|
+
params: true,
|
|
110
|
+
data_zone: true,
|
|
111
|
+
eu_processing: true,
|
|
112
|
+
allowed_models: true,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** Kept keys a body removes by sending `null`. */
|
|
116
|
+
const NULLABLE_BODY_KEYS = ["data_zone", "eu_processing", "allowed_models"] as const;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A body's `allowed_models`, normalized (ids trimmed), or the refusal: a list
|
|
120
|
+
* of distinct, non-empty model ids. `[]` is valid and allows no model.
|
|
121
|
+
*/
|
|
122
|
+
function readAllowedModels(value: unknown): string[] | string {
|
|
123
|
+
const refusal = "allowed_models must be a list of distinct, non-empty model ids, or null";
|
|
124
|
+
if (!Array.isArray(value)) return refusal;
|
|
125
|
+
const ids: string[] = [];
|
|
126
|
+
for (const item of value) {
|
|
127
|
+
if (typeof item !== "string" || item.trim() === "") return refusal;
|
|
128
|
+
const id = item.trim();
|
|
129
|
+
if (ids.includes(id)) return `allowed_models lists '${id}' twice`;
|
|
130
|
+
ids.push(id);
|
|
131
|
+
}
|
|
132
|
+
return ids;
|
|
133
|
+
}
|
|
134
|
+
|
|
96
135
|
/** Entry fields a higher scope may set on an entry a lower document defines. */
|
|
97
136
|
export const PATCH_KEYS = ["credential", "enabled"] as const;
|
|
98
137
|
|
|
@@ -168,8 +207,29 @@ function readEntryBody(body: unknown): Record<string, unknown> | string {
|
|
|
168
207
|
) {
|
|
169
208
|
return PROVIDER_SECRET_REFUSAL;
|
|
170
209
|
}
|
|
171
|
-
const unknown = Object.keys(body).find(
|
|
210
|
+
const unknown = Object.keys(body).find(
|
|
211
|
+
(key) => !Object.hasOwn(BODY_KEYS, key) && !Object.hasOwn(KEPT_BODY_KEYS, key),
|
|
212
|
+
);
|
|
172
213
|
if (unknown) return `'${unknown}' is not a provider entry field`;
|
|
214
|
+
if (body.params !== undefined && !isPlainObject(body.params)) {
|
|
215
|
+
return "params must be a map of parameter name to value";
|
|
216
|
+
}
|
|
217
|
+
if (body.data_zone !== undefined && body.data_zone !== null && !isDataZone(body.data_zone)) {
|
|
218
|
+
return "data_zone must be one of eu, eu_option, transfer, local, or null";
|
|
219
|
+
}
|
|
220
|
+
if (
|
|
221
|
+
body.eu_processing !== undefined &&
|
|
222
|
+
body.eu_processing !== null &&
|
|
223
|
+
typeof body.eu_processing !== "boolean"
|
|
224
|
+
) {
|
|
225
|
+
return "eu_processing must be true, false or null";
|
|
226
|
+
}
|
|
227
|
+
let allowedModels: string[] | undefined;
|
|
228
|
+
if (body.allowed_models !== undefined && body.allowed_models !== null) {
|
|
229
|
+
const read = readAllowedModels(body.allowed_models);
|
|
230
|
+
if (typeof read === "string") return read;
|
|
231
|
+
allowedModels = read;
|
|
232
|
+
}
|
|
173
233
|
if (body.preset !== undefined && !catalogPreset(body.preset)) {
|
|
174
234
|
return `Unknown preset '${String(body.preset)}'`;
|
|
175
235
|
}
|
|
@@ -179,7 +239,7 @@ function readEntryBody(body: unknown): Record<string, unknown> | string {
|
|
|
179
239
|
if (ref !== undefined && (typeof ref !== "string" || !parseCredentialRef(ref)))
|
|
180
240
|
return CREDENTIAL_REF_REFUSAL;
|
|
181
241
|
}
|
|
182
|
-
return body;
|
|
242
|
+
return allowedModels ? { ...body, allowed_models: allowedModels } : body;
|
|
183
243
|
}
|
|
184
244
|
|
|
185
245
|
/**
|
|
@@ -230,12 +290,40 @@ function isPatchOrAbsent(entry: unknown): boolean {
|
|
|
230
290
|
);
|
|
231
291
|
}
|
|
232
292
|
|
|
293
|
+
/** The `preset` the lowest-to-highest lower documents give entry `id`, the last one winning. */
|
|
294
|
+
function lowerPreset(lowerEntries: unknown[]): unknown {
|
|
295
|
+
let preset: unknown;
|
|
296
|
+
for (const entry of lowerEntries)
|
|
297
|
+
if (isPlainObject(entry) && entry.preset !== undefined) preset = entry.preset;
|
|
298
|
+
return preset;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The required parameters of the entry's catalog preset that neither the
|
|
303
|
+
* entry nor a lower document's entry under the same id supplies. An entry
|
|
304
|
+
* without a catalog preset has none here; the SDK's schema checks its
|
|
305
|
+
* transport's parameters.
|
|
306
|
+
*/
|
|
307
|
+
function missingPresetParams(entry: Record<string, unknown>, lowerEntries: unknown[]): string[] {
|
|
308
|
+
const preset = catalogPreset(entry.preset ?? lowerPreset(lowerEntries));
|
|
309
|
+
if (!preset) return [];
|
|
310
|
+
const params: Record<string, unknown> = {};
|
|
311
|
+
for (const lower of lowerEntries) {
|
|
312
|
+
if (isPlainObject(lower) && isPlainObject(lower.params)) Object.assign(params, lower.params);
|
|
313
|
+
}
|
|
314
|
+
if (isPlainObject(entry.params)) Object.assign(params, entry.params);
|
|
315
|
+
return missingRequiredParams(preset, params);
|
|
316
|
+
}
|
|
317
|
+
|
|
233
318
|
/**
|
|
234
319
|
* Upsert entry `id` in the scope's document.
|
|
235
320
|
*
|
|
236
321
|
* - **Own entry** — the body sets the editable fields (`BODY_KEYS`) — one it
|
|
237
|
-
* omits is removed — and optionally `kind
|
|
238
|
-
* `
|
|
322
|
+
* omits is removed — and optionally `kind`, `params`, `data_zone`,
|
|
323
|
+
* `eu_processing` and `allowed_models`; fields it does not set (`kind`,
|
|
324
|
+
* `transport`, `params`, `data_zone`, …) keep their written value,
|
|
325
|
+
* `params: {}` removes the parameters and `null` removes `data_zone`,
|
|
326
|
+
* `eu_processing` or `allowed_models` (`[]` allows no model).
|
|
239
327
|
* - **Patch** — when a lower document defines `id` and the body holds only
|
|
240
328
|
* {@link PATCH_KEYS}, the body is merged into the scope's stored entry for
|
|
241
329
|
* `id` (created when absent), leaving every other stored field alone.
|
|
@@ -243,11 +331,16 @@ function isPatchOrAbsent(entry: unknown): boolean {
|
|
|
243
331
|
* The file and its directory are created when absent; other entries are kept
|
|
244
332
|
* as written.
|
|
245
333
|
*
|
|
246
|
-
* 400: invalid id, secret-bearing or malformed body
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
334
|
+
* 400: invalid id, secret-bearing or malformed body (`data_zone` outside
|
|
335
|
+
* eu | eu_option | transfer | local, a non-boolean `eu_processing`, an
|
|
336
|
+
* `allowed_models` that is not a list of distinct non-empty ids), a body for
|
|
337
|
+
* an inherited entry that is neither a patch nor over an own full entry, a
|
|
338
|
+
* preset's required parameter left out of `params` (named in the message,
|
|
339
|
+
* `data.missingParams`), or any error diagnostic for the entry (as
|
|
340
|
+
* `data.diagnostics`; nothing is written — `params` keys a transport does not
|
|
341
|
+
* accept are refused this way, by the SDK's schema). 403: the entry is
|
|
342
|
+
* `locked: true` in some document. 409: the document on disk is not a
|
|
343
|
+
* registry.
|
|
251
344
|
*/
|
|
252
345
|
export function upsertProviderEntry(
|
|
253
346
|
scope: ProviderWriteScope,
|
|
@@ -294,6 +387,16 @@ export function upsertProviderEntry(
|
|
|
294
387
|
)
|
|
295
388
|
: {};
|
|
296
389
|
entry = { ...kept, ...fields };
|
|
390
|
+
if (isPlainObject(entry.params) && Object.keys(entry.params).length === 0) delete entry.params;
|
|
391
|
+
for (const key of NULLABLE_BODY_KEYS) if (entry[key] === null) delete entry[key];
|
|
392
|
+
const missing = missingPresetParams(entry, lowerEntries);
|
|
393
|
+
if (missing.length > 0) {
|
|
394
|
+
throw createError({
|
|
395
|
+
statusCode: 400,
|
|
396
|
+
statusMessage: `Provider '${id}' needs ${missing.map((key) => `params.${key}`).join(", ")} (required by preset '${String(entry.preset ?? lowerPreset(lowerEntries))}')`,
|
|
397
|
+
data: { missingParams: missing },
|
|
398
|
+
});
|
|
399
|
+
}
|
|
297
400
|
}
|
|
298
401
|
const next = { ...providers, [id]: entry };
|
|
299
402
|
|
|
@@ -336,9 +439,15 @@ export interface ProviderPresetSummary {
|
|
|
336
439
|
label: string;
|
|
337
440
|
kind: AIProviderPreset["kind"];
|
|
338
441
|
surface?: AIProviderPreset["surface"];
|
|
442
|
+
/** How requests reach the surface; absent = `direct`. Azure transports offer Entra ID sign-in. */
|
|
443
|
+
transport?: AIProviderPreset["transport"];
|
|
339
444
|
base_url?: string;
|
|
340
445
|
base_url_env?: string;
|
|
341
446
|
credential: { kind: AIProviderPreset["credential"]["kind"]; env?: string };
|
|
447
|
+
/** Default routing / voice parameters. */
|
|
448
|
+
params?: Record<string, string>;
|
|
449
|
+
/** `params` keys an entry naming this preset must supply. */
|
|
450
|
+
required_params?: string[];
|
|
342
451
|
}
|
|
343
452
|
|
|
344
453
|
/** Catalog presets (all of them by default), in catalog order. */
|
|
@@ -350,9 +459,12 @@ export function providerPresetSummaries(
|
|
|
350
459
|
label: preset.label,
|
|
351
460
|
kind: preset.kind,
|
|
352
461
|
surface: preset.surface,
|
|
462
|
+
...(preset.transport ? { transport: preset.transport } : {}),
|
|
353
463
|
base_url: preset.base_url,
|
|
354
464
|
base_url_env: preset.base_url_env,
|
|
355
465
|
credential: { kind: preset.credential.kind, env: preset.credential.env },
|
|
466
|
+
...(preset.params ? { params: { ...preset.params } } : {}),
|
|
467
|
+
...(preset.required_params?.length ? { required_params: [...preset.required_params] } : {}),
|
|
356
468
|
}));
|
|
357
469
|
}
|
|
358
470
|
|
|
@@ -385,17 +497,19 @@ export type ProviderLayerName = ProviderWriteScope["name"];
|
|
|
385
497
|
|
|
386
498
|
/**
|
|
387
499
|
* The defining document's values for the `PUT`-editable fields a settings UI
|
|
388
|
-
* edit dialog does not show
|
|
389
|
-
* so an edit sends these back
|
|
500
|
+
* edit dialog does not show (or shows only in part, as `params`). A `PUT`
|
|
501
|
+
* removes every editable field it omits, so an edit sends these back
|
|
502
|
+
* unchanged instead of dropping them.
|
|
390
503
|
*/
|
|
391
504
|
export interface ProviderLayerFields {
|
|
392
505
|
label?: unknown;
|
|
393
506
|
headers?: unknown;
|
|
394
507
|
model_map?: unknown;
|
|
395
508
|
enabled?: unknown;
|
|
509
|
+
params?: unknown;
|
|
396
510
|
}
|
|
397
511
|
|
|
398
|
-
const LAYER_FIELD_KEYS = ["label", "headers", "model_map", "enabled"] as const;
|
|
512
|
+
const LAYER_FIELD_KEYS = ["label", "headers", "model_map", "enabled", "params"] as const;
|
|
399
513
|
|
|
400
514
|
/**
|
|
401
515
|
* Tag each provider view of a scope with every registry document that holds
|