@skaile/forge-settings 1.3.2 → 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.
Files changed (46) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +20 -0
  3. package/dist/module.json +1 -1
  4. package/dist/runtime/components/ForgeProvidersPanel.d.vue.ts +13 -0
  5. package/dist/runtime/components/ForgeProvidersPanel.vue +461 -78
  6. package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +13 -0
  7. package/dist/runtime/server/harness-settings.d.ts +50 -12
  8. package/dist/runtime/server/harness-settings.js +93 -9
  9. package/dist/runtime/server/index.d.ts +2 -0
  10. package/dist/runtime/server/index.js +2 -0
  11. package/dist/runtime/server/provider-service.d.ts +26 -2
  12. package/dist/runtime/server/provider-service.js +16 -1
  13. package/dist/runtime/server/provider-write.d.ts +25 -9
  14. package/dist/runtime/server/provider-write.js +72 -4
  15. package/dist/runtime/shared/data-zone.d.ts +66 -0
  16. package/dist/runtime/shared/data-zone.js +79 -0
  17. package/dist/runtime/shared/harness-providers.d.ts +41 -2
  18. package/dist/runtime/shared/harness-providers.js +31 -8
  19. package/dist/runtime/shared/harnesses.d.ts +7 -0
  20. package/dist/runtime/shared/harnesses.js +18 -0
  21. package/dist/runtime/shared/provider-accounts.d.ts +215 -0
  22. package/dist/runtime/shared/provider-accounts.js +255 -0
  23. package/dist/runtime/shared/provider-registry.d.ts +38 -0
  24. package/dist/runtime/shared/provider-registry.js +16 -0
  25. package/dist/runtime/shared/settings-types.d.ts +7 -0
  26. package/dist/runtime/vue/index.d.ts +2 -0
  27. package/dist/runtime/vue/index.js +2 -0
  28. package/dist/runtime/vue/providers-panel-model.d.ts +98 -5
  29. package/dist/runtime/vue/providers-panel-model.js +13 -1
  30. package/dist/runtime/vue/useProvidersPanel.d.ts +66 -5
  31. package/dist/runtime/vue/useProvidersPanel.js +417 -29
  32. package/package.json +2 -2
  33. package/src/runtime/components/ForgeProvidersPanel.vue +477 -78
  34. package/src/runtime/server/harness-settings.ts +165 -21
  35. package/src/runtime/server/index.ts +2 -0
  36. package/src/runtime/server/provider-service.ts +53 -1
  37. package/src/runtime/server/provider-write.ts +129 -15
  38. package/src/runtime/shared/data-zone.ts +172 -0
  39. package/src/runtime/shared/harness-providers.ts +77 -6
  40. package/src/runtime/shared/harnesses.ts +25 -0
  41. package/src/runtime/shared/provider-accounts.ts +526 -0
  42. package/src/runtime/shared/provider-registry.ts +58 -0
  43. package/src/runtime/shared/settings-types.ts +7 -0
  44. package/src/runtime/vue/index.ts +2 -0
  45. package/src/runtime/vue/providers-panel-model.ts +123 -5
  46. 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`, `harnessProviders`, `enabledHarnesses`,
12
- * `lock*`), as a pure function over the request body and the stored state.
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 a
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
- ...info,
120
- label: HARNESS_LABELS[info.id] ?? info.name,
121
- experimental: isExperimentalHarness(info.id),
122
- enabled: enabled.has(info.id),
123
- detection: detections.find((d) => d.id === info.id) ?? { id: info.id, status: "unknown" },
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`, `harnessProviders` or
192
- * `enabledHarnesses` changed, i.e. whether running sessions now run on stale
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: { catalog: Iterable<string>; current: HarnessSettings; locks: LockFlags },
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
- ): { harness: string | undefined; model: string | undefined; providers: string[] | undefined } {
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
- return { harness, model, providers };
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. `kind`
81
- * may be set too — an entry without a `preset` (the settings modal's
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((key) => !Object.hasOwn(BODY_KEYS, key) && key !== "kind");
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`; fields it does not set (`kind`,
238
- * `transport`, `params`, …) keep their written value.
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, a body for an inherited
247
- * entry that is neither a patch nor over an own full entry, or any error
248
- * diagnostic for the entry (as `data.diagnostics`; nothing is written). 403:
249
- * the entry is `locked: true` in some document. 409: the document on disk is
250
- * not a registry.
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. A `PUT` removes every editable field it omits,
389
- * so an edit sends these back unchanged instead of dropping them.
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