@skaile/forge-settings 1.3.3 → 2.0.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 (88) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +52 -9
  3. package/dist/module.json +1 -1
  4. package/dist/runtime/ai-sdk/index.d.ts +33 -26
  5. package/dist/runtime/ai-sdk/index.js +34 -16
  6. package/dist/runtime/components/ForgeProvidersPanel.d.vue.ts +13 -0
  7. package/dist/runtime/components/ForgeProvidersPanel.vue +461 -78
  8. package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +13 -0
  9. package/dist/runtime/editor/index.d.ts +55 -16
  10. package/dist/runtime/editor/index.js +82 -40
  11. package/dist/runtime/model/selector.d.ts +16 -0
  12. package/dist/runtime/model/selector.js +12 -0
  13. package/dist/runtime/server/credential-store.d.ts +16 -4
  14. package/dist/runtime/server/credential-store.js +3 -4
  15. package/dist/runtime/server/env-layers.d.ts +1 -1
  16. package/dist/runtime/server/env-layers.js +3 -3
  17. package/dist/runtime/server/index.d.ts +9 -8
  18. package/dist/runtime/server/index.js +3 -3
  19. package/dist/runtime/server/provider-discovery.d.ts +28 -8
  20. package/dist/runtime/server/provider-discovery.js +57 -33
  21. package/dist/runtime/server/provider-layers.d.ts +1 -1
  22. package/dist/runtime/server/provider-layers.js +3 -5
  23. package/dist/runtime/server/provider-service.d.ts +26 -2
  24. package/dist/runtime/server/provider-service.js +16 -1
  25. package/dist/runtime/server/provider-write.d.ts +26 -10
  26. package/dist/runtime/server/provider-write.js +75 -7
  27. package/dist/runtime/server/voice-provider.d.ts +61 -0
  28. package/dist/runtime/server/voice-provider.js +31 -0
  29. package/dist/runtime/shared/data-zone.d.ts +68 -0
  30. package/dist/runtime/shared/data-zone.js +79 -0
  31. package/dist/runtime/shared/harness-providers.d.ts +42 -3
  32. package/dist/runtime/shared/harness-providers.js +31 -8
  33. package/dist/runtime/shared/harnesses.d.ts +7 -0
  34. package/dist/runtime/shared/harnesses.js +18 -0
  35. package/dist/runtime/shared/provider-accounts.d.ts +215 -0
  36. package/dist/runtime/shared/provider-accounts.js +255 -0
  37. package/dist/runtime/shared/provider-registry.d.ts +19 -182
  38. package/dist/runtime/shared/provider-registry.js +25 -65
  39. package/dist/runtime/vue/index.d.ts +2 -0
  40. package/dist/runtime/vue/index.js +2 -0
  41. package/dist/runtime/vue/providers-panel-model.d.ts +98 -5
  42. package/dist/runtime/vue/providers-panel-model.js +13 -1
  43. package/dist/runtime/vue/useProvidersPanel.d.ts +66 -5
  44. package/dist/runtime/vue/useProvidersPanel.js +417 -29
  45. package/dist/runtime/{server → workspaces}/harness-settings.d.ts +51 -13
  46. package/dist/runtime/{server → workspaces}/harness-settings.js +93 -9
  47. package/dist/runtime/workspaces/index.d.ts +16 -0
  48. package/dist/runtime/workspaces/index.js +5 -0
  49. package/dist/runtime/workspaces/language-model.d.ts +45 -0
  50. package/dist/runtime/workspaces/language-model.js +26 -0
  51. package/dist/runtime/workspaces/model-endpoint.d.ts +38 -0
  52. package/dist/runtime/workspaces/model-endpoint.js +54 -0
  53. package/dist/runtime/workspaces/provider-settings.d.ts +35 -0
  54. package/dist/runtime/{server → workspaces}/provider-settings.js +0 -31
  55. package/dist/runtime/{server → workspaces}/settings-defaults.d.ts +1 -1
  56. package/dist/runtime/{shared → workspaces}/settings-types.d.ts +11 -4
  57. package/package.json +11 -2
  58. package/src/runtime/ai-sdk/index.ts +88 -47
  59. package/src/runtime/components/ForgeProvidersPanel.vue +477 -78
  60. package/src/runtime/editor/index.ts +158 -63
  61. package/src/runtime/model/selector.ts +31 -0
  62. package/src/runtime/server/credential-store.ts +20 -10
  63. package/src/runtime/server/env-layers.ts +4 -4
  64. package/src/runtime/server/index.ts +9 -8
  65. package/src/runtime/server/provider-discovery.ts +114 -48
  66. package/src/runtime/server/provider-layers.ts +6 -6
  67. package/src/runtime/server/provider-service.ts +53 -1
  68. package/src/runtime/server/provider-write.ts +132 -18
  69. package/src/runtime/server/voice-provider.ts +110 -0
  70. package/src/runtime/shared/data-zone.ts +179 -0
  71. package/src/runtime/shared/harness-providers.ts +78 -7
  72. package/src/runtime/shared/harnesses.ts +25 -0
  73. package/src/runtime/shared/provider-accounts.ts +526 -0
  74. package/src/runtime/shared/provider-registry.ts +82 -256
  75. package/src/runtime/vue/index.ts +2 -0
  76. package/src/runtime/vue/providers-panel-model.ts +123 -5
  77. package/src/runtime/vue/useProvidersPanel.ts +589 -37
  78. package/src/runtime/{server → workspaces}/harness-settings.ts +166 -22
  79. package/src/runtime/workspaces/index.ts +17 -0
  80. package/src/runtime/workspaces/language-model.ts +82 -0
  81. package/src/runtime/workspaces/model-endpoint.ts +100 -0
  82. package/src/runtime/workspaces/provider-settings.ts +96 -0
  83. package/src/runtime/{server → workspaces}/settings-defaults.ts +1 -1
  84. package/src/runtime/{shared → workspaces}/settings-types.ts +11 -4
  85. package/dist/runtime/server/provider-settings.d.ts +0 -78
  86. package/src/runtime/server/provider-settings.ts +0 -192
  87. /package/dist/runtime/{server → workspaces}/settings-defaults.js +0 -0
  88. /package/dist/runtime/{shared → workspaces}/settings-types.js +0 -0
@@ -9,15 +9,15 @@
9
9
  */
10
10
 
11
11
  import { existsSync, readFileSync } from "node:fs";
12
- import {
13
- type AIProviderRegistryPatch,
14
- PROVIDERS_FILENAME,
15
- saveProviderRegistryFile,
16
- } from "@skaile/workspaces/core";
17
- import { AI_PROVIDER_PRESETS, type AIProviderPreset } from "@skaile/workspaces/sdk/bridge";
12
+ import type { AIProviderRegistryPatch } from "@skaile/provider-registry";
13
+ import { AI_PROVIDER_PRESETS, type AIProviderPreset } from "@skaile/provider-registry/catalog";
14
+ import { saveProviderRegistryFile } from "@skaile/provider-registry/files";
18
15
  import { parse as parseYaml } from "yaml";
19
16
  import { isPlainObject } from "../shared/guards";
20
17
 
18
+ /** A layer document's file name, as every registry host spells it. */
19
+ const PROVIDERS_FILENAME = "providers.yml";
20
+
21
21
  /** The `.yaml` spelling of {@link PROVIDERS_FILENAME}. */
22
22
  const PROVIDERS_FILENAME_ALIAS = "providers.yaml";
23
23
 
@@ -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,
26
+ AIProviderDataZone,
25
27
  AIProviderKind,
26
28
  AIProviderRegistryView,
27
29
  AIProviderSurface,
30
+ AIProviderTransport,
28
31
  AIProviderVoiceParams,
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: AIProviderDataZone;
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}`,
@@ -28,18 +28,20 @@ import {
28
28
  type AIProviderRegistryLayer,
29
29
  type AIProviderRegistryPatch,
30
30
  isSafeProviderId,
31
- loadProviderRegistryFile,
32
31
  mergeRegistries,
33
32
  parseCredentialRef,
34
33
  validateMergedRegistry,
35
34
  validateRegistry,
36
- } from "@skaile/workspaces/core";
35
+ } from "@skaile/provider-registry";
37
36
  import {
38
37
  AI_PROVIDER_PRESETS,
39
38
  type AIProviderPreset,
40
39
  expandPreset,
41
- } from "@skaile/workspaces/sdk/bridge";
40
+ } from "@skaile/provider-registry/catalog";
41
+ import { loadProviderRegistryFile } from "@skaile/provider-registry/files";
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
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Voice selection: which `kind: stt|tts` registry entry serves a request and
3
+ * with which parameters.
4
+ *
5
+ * `sttProvider`/`ttsProvider` are registry ids of `kind: stt|tts` entries; the
6
+ * endpoint and the voice parameters live on the entry, and
7
+ * `sttLanguage`/`ttsVoice` are per-request overrides of two of those
8
+ * parameters. Resolving all of that used to be three apps' worth of hardcoded
9
+ * endpoints and voice ids.
10
+ */
11
+
12
+ import type {
13
+ AIProviderRegistryView,
14
+ AIProviderVoiceEntry,
15
+ AIProviderVoiceKind,
16
+ AIProviderVoiceParams,
17
+ } from "../shared/provider-registry";
18
+ import {
19
+ entriesOfKind,
20
+ selectVoiceEntryId,
21
+ VOICE_OVERRIDE_PARAM,
22
+ } from "../shared/provider-registry";
23
+
24
+ /**
25
+ * Settings fields that name a voice entry and override its parameters — the
26
+ * same field names as the Workspaces `settings.json` document, so a
27
+ * `SkaileSettings` (`@skaile/forge-settings/workspaces`) can be passed as is.
28
+ */
29
+ export interface VoiceSettings {
30
+ /** Registry id of the `kind: stt` entry. */
31
+ sttProvider?: string;
32
+ /** Registry id of the `kind: tts` entry. */
33
+ ttsProvider?: string;
34
+ /** Overrides the stt entry's `language` parameter. */
35
+ sttLanguage?: string;
36
+ /** Overrides the tts entry's `voice_id` parameter. */
37
+ ttsVoice?: string;
38
+ }
39
+
40
+ /** The `kind: stt|tts` entry a request runs against, with its parameters resolved. */
41
+ export interface VoiceProviderSelection {
42
+ /** Registry id of the entry. */
43
+ providerId: string;
44
+ entry: AIProviderVoiceEntry;
45
+ /** Endpoint from the entry; absent when the entry inherits its preset's default. */
46
+ baseUrl?: string;
47
+ /**
48
+ * The entry's `params` with overrides applied — Deepgram `{model, language}`,
49
+ * ElevenLabs `{voice_id, output_format}`. This is the only place a forge app
50
+ * should read voice defaults from.
51
+ */
52
+ params: AIProviderVoiceParams;
53
+ /** True when settings named no usable entry and registry order decided. */
54
+ fallback: boolean;
55
+ /** Non-fatal notes, e.g. settings naming an entry the registry does not have. */
56
+ warnings: string[];
57
+ }
58
+
59
+ /**
60
+ * Resolve which registry entry serves speech-to-text or text-to-speech.
61
+ *
62
+ * Selection is `settings.sttProvider`/`settings.ttsProvider`, falling back to
63
+ * the first enabled entry of that kind in registry order when the setting is
64
+ * absent or names an entry that is gone — a missing setting must not silence an
65
+ * app that has a perfectly good entry configured.
66
+ *
67
+ * Parameter precedence, lowest first: the entry's `params`, then the settings
68
+ * overrides (`sttLanguage` → `language`, `ttsVoice` → `voice_id`), then
69
+ * `overrides` from the request. The per-request override is the contract
70
+ * forge-concept already honoured and the other apps silently dropped.
71
+ *
72
+ * @returns The selection, or `undefined` when the registry has no enabled entry
73
+ * of that kind.
74
+ */
75
+ export function resolveVoiceProvider(
76
+ registry: AIProviderRegistryView,
77
+ kind: AIProviderVoiceKind,
78
+ settings: VoiceSettings = {},
79
+ overrides: AIProviderVoiceParams = {},
80
+ ): VoiceProviderSelection | undefined {
81
+ const candidates = entriesOfKind(registry, kind);
82
+ if (candidates.length === 0) return undefined;
83
+
84
+ const warnings: string[] = [];
85
+ const wanted = (kind === "stt" ? settings.sttProvider : settings.ttsProvider)?.trim();
86
+ const ids = candidates.map(([id]) => id);
87
+ // `candidates` is non-empty, so the shared rule always names one of these ids.
88
+ const providerId = selectVoiceEntryId(ids, wanted) as string;
89
+ const entry = candidates[ids.indexOf(providerId)][1];
90
+ if (wanted && wanted !== providerId) {
91
+ warnings.push(
92
+ `settings name '${wanted}' for ${kind}, which is not an enabled ${kind} entry — ` +
93
+ `using '${providerId}' instead`,
94
+ );
95
+ }
96
+ const fallback = wanted !== providerId;
97
+
98
+ const settingsOverride: AIProviderVoiceParams = {};
99
+ const override = (kind === "stt" ? settings.sttLanguage : settings.ttsVoice)?.trim();
100
+ if (override) settingsOverride[VOICE_OVERRIDE_PARAM[kind]] = override;
101
+
102
+ return {
103
+ providerId,
104
+ entry,
105
+ ...(entry.base_url ? { baseUrl: entry.base_url } : {}),
106
+ params: { ...entry.params, ...settingsOverride, ...overrides },
107
+ fallback,
108
+ warnings,
109
+ };
110
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Data zones: where a provider entry processes data, and which entries a
3
+ * host's data-zone rule ("Where may AI process data?") keeps.
4
+ *
5
+ * `@skaile/provider-registry` owns the vocabulary (`AI_PROVIDER_DATA_ZONES`,
6
+ * `AI_PROVIDER_DATA_ZONE_RULES`, re-exported by `./provider-registry`) and the
7
+ * catalog's `data_zone` column with `entryDataZone`, `entryAllowedByDataZone`
8
+ * and `filterRegistryByDataZone` in its `/catalog` entry. forge-settings keeps
9
+ * the browser-side view here so the settings panel does not ship the catalog
10
+ * (and its generated model table) to the browser: the same semantics, plus
11
+ * labels, refusal wording and a filter that keeps a merged registry merged.
12
+ * {@link PRESET_DATA_ZONES} is a copy of the catalog's column; a test holds it
13
+ * equal to `@skaile/provider-registry/catalog`.
14
+ *
15
+ * Browser-safe on purpose — no `node:` imports.
16
+ */
17
+
18
+ import {
19
+ AI_PROVIDER_DATA_ZONE_RULES,
20
+ AI_PROVIDER_DATA_ZONES,
21
+ type AIProviderDataZone,
22
+ type AIProviderDataZoneRule,
23
+ type AIProviderRegistry,
24
+ type AIProviderRegistryView,
25
+ isMergedRegistry,
26
+ type MergedAIProviderRegistry,
27
+ } from "./provider-registry";
28
+
29
+ /**
30
+ * The `data_zone` of each catalog preset, by preset id. Copied from
31
+ * `@skaile/provider-registry/catalog`'s `AI_PROVIDER_PRESETS`; change it there
32
+ * first. A preset this table does not name is `transfer`.
33
+ */
34
+ export const PRESET_DATA_ZONES: Readonly<Record<string, AIProviderDataZone>> = {
35
+ anthropic: "transfer",
36
+ openai: "transfer",
37
+ google: "transfer",
38
+ groq: "transfer",
39
+ deepseek: "transfer",
40
+ openrouter: "transfer",
41
+ mistral: "eu",
42
+ xai: "transfer",
43
+ together: "transfer",
44
+ fireworks: "transfer",
45
+ bedrock: "eu_option",
46
+ vertex: "eu_option",
47
+ "azure-openai": "eu_option",
48
+ foundry: "eu_option",
49
+ ollama: "local",
50
+ lmstudio: "local",
51
+ vllm: "local",
52
+ litellm: "transfer",
53
+ deepgram: "transfer",
54
+ elevenlabs: "transfer",
55
+ };
56
+
57
+ /** What a provider row's zone chip says. */
58
+ export const DATA_ZONE_LABELS: Readonly<Record<AIProviderDataZone, string>> = {
59
+ eu: "EU",
60
+ eu_option: "EU option",
61
+ transfer: "Transfer",
62
+ local: "Local",
63
+ };
64
+
65
+ /** What a host calls each rule when it names the rule that keeps an entry out. */
66
+ export const DATA_ZONE_RULE_LABELS: Readonly<Record<AIProviderDataZoneRule, string>> = {
67
+ any: "Anywhere",
68
+ eu_options: "EU, or EU option with EU processing on",
69
+ eu: "EU only",
70
+ };
71
+
72
+ /** Whether `value` is one of `AI_PROVIDER_DATA_ZONES`. */
73
+ export function isDataZone(value: unknown): value is AIProviderDataZone {
74
+ return typeof value === "string" && (AI_PROVIDER_DATA_ZONES as readonly string[]).includes(value);
75
+ }
76
+
77
+ /** Whether `value` is one of `AI_PROVIDER_DATA_ZONE_RULES`. */
78
+ export function isDataZoneRule(value: unknown): value is AIProviderDataZoneRule {
79
+ return (
80
+ typeof value === "string" && (AI_PROVIDER_DATA_ZONE_RULES as readonly string[]).includes(value)
81
+ );
82
+ }
83
+
84
+ /** The zone of catalog preset `presetId`, or `undefined` for an id the catalog does not name. */
85
+ export function presetDataZone(presetId: string | undefined): AIProviderDataZone | undefined {
86
+ return presetId !== undefined && Object.hasOwn(PRESET_DATA_ZONES, presetId)
87
+ ? PRESET_DATA_ZONES[presetId]
88
+ : undefined;
89
+ }
90
+
91
+ /**
92
+ * Where an entry processes data: its own `data_zone`, else the zone of the
93
+ * catalog preset it names, else `transfer` (an unknown endpoint is assumed to
94
+ * leave the EU).
95
+ */
96
+ export function entryDataZone(entry: {
97
+ data_zone?: AIProviderDataZone;
98
+ preset?: string;
99
+ }): AIProviderDataZone {
100
+ return entry.data_zone ?? presetDataZone(entry.preset) ?? "transfer";
101
+ }
102
+
103
+ /**
104
+ * Whether `rule` allows an entry in `zone`: `any` allows all; `eu` allows
105
+ * `eu` and `local`; `eu_options` additionally allows `eu_option` when
106
+ * `euProcessing` is `true`.
107
+ */
108
+ export function dataZoneAllows(
109
+ rule: AIProviderDataZoneRule,
110
+ zone: AIProviderDataZone,
111
+ euProcessing: boolean | undefined,
112
+ ): boolean {
113
+ if (rule === "any") return true;
114
+ if (zone === "eu" || zone === "local") return true;
115
+ return rule === "eu_options" && zone === "eu_option" && euProcessing === true;
116
+ }
117
+
118
+ /** Whether `rule` allows `entry` ({@link dataZoneAllows} over {@link entryDataZone}). */
119
+ export function entryAllowedByDataZone(
120
+ entry: { data_zone?: AIProviderDataZone; preset?: string; eu_processing?: boolean },
121
+ rule: AIProviderDataZoneRule,
122
+ ): boolean {
123
+ return dataZoneAllows(rule, entryDataZone(entry), entry.eu_processing);
124
+ }
125
+
126
+ /**
127
+ * Why `rule` keeps an entry in `zone` out, naming the rule, or `undefined`
128
+ * when the rule allows it. Secret-free, safe to render.
129
+ */
130
+ export function dataZoneRefusal(
131
+ rule: AIProviderDataZoneRule,
132
+ zone: AIProviderDataZone,
133
+ euProcessing: boolean | undefined,
134
+ ): string | undefined {
135
+ if (dataZoneAllows(rule, zone, euProcessing)) return undefined;
136
+ const why =
137
+ zone === "eu_option" && rule === "eu_options"
138
+ ? "EU processing is not on for this account"
139
+ : `this provider is ${DATA_ZONE_LABELS[zone]}`;
140
+ return `Blocked by the data-zone rule "${DATA_ZONE_RULE_LABELS[rule]}": ${why}.`;
141
+ }
142
+
143
+ /**
144
+ * The registry with only the entries `rule` allows, every kind alike
145
+ * (`llm`, `embed`, `stt`, `tts`). Order is kept; the input is not mutated. A
146
+ * merged registry comes back merged, its `order`, `origin` and `locked`
147
+ * narrowed to the kept entries.
148
+ */
149
+ export function filterRegistryByDataZone(
150
+ registry: AIProviderRegistry,
151
+ rule: AIProviderDataZoneRule,
152
+ ): AIProviderRegistry;
153
+ export function filterRegistryByDataZone(
154
+ registry: MergedAIProviderRegistry,
155
+ rule: AIProviderDataZoneRule,
156
+ ): MergedAIProviderRegistry;
157
+ export function filterRegistryByDataZone(
158
+ registry: AIProviderRegistryView,
159
+ rule: AIProviderDataZoneRule,
160
+ ): AIProviderRegistryView;
161
+ export function filterRegistryByDataZone(
162
+ registry: AIProviderRegistryView,
163
+ rule: AIProviderDataZoneRule,
164
+ ): AIProviderRegistryView {
165
+ const entries = isMergedRegistry(registry) ? registry.providers : registry;
166
+ const providers: AIProviderRegistry = {};
167
+ for (const [id, entry] of Object.entries(entries)) {
168
+ if (entryAllowedByDataZone(entry, rule)) providers[id] = entry;
169
+ }
170
+ if (!isMergedRegistry(registry)) return providers;
171
+ const kept = (id: string) => Object.hasOwn(providers, id);
172
+ return {
173
+ ...registry,
174
+ providers,
175
+ order: registry.order.filter(kept),
176
+ origin: Object.fromEntries(Object.entries(registry.origin).filter(([id]) => kept(id))),
177
+ locked: registry.locked.filter(kept),
178
+ };
179
+ }