@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.
- package/CHANGELOG.md +40 -0
- package/README.md +52 -9
- package/dist/module.json +1 -1
- package/dist/runtime/ai-sdk/index.d.ts +33 -26
- package/dist/runtime/ai-sdk/index.js +34 -16
- 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/editor/index.d.ts +55 -16
- package/dist/runtime/editor/index.js +82 -40
- package/dist/runtime/model/selector.d.ts +16 -0
- package/dist/runtime/model/selector.js +12 -0
- package/dist/runtime/server/credential-store.d.ts +16 -4
- package/dist/runtime/server/credential-store.js +3 -4
- package/dist/runtime/server/env-layers.d.ts +1 -1
- package/dist/runtime/server/env-layers.js +3 -3
- package/dist/runtime/server/index.d.ts +9 -8
- package/dist/runtime/server/index.js +3 -3
- package/dist/runtime/server/provider-discovery.d.ts +28 -8
- package/dist/runtime/server/provider-discovery.js +57 -33
- package/dist/runtime/server/provider-layers.d.ts +1 -1
- package/dist/runtime/server/provider-layers.js +3 -5
- 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 +26 -10
- package/dist/runtime/server/provider-write.js +75 -7
- package/dist/runtime/server/voice-provider.d.ts +61 -0
- package/dist/runtime/server/voice-provider.js +31 -0
- package/dist/runtime/shared/data-zone.d.ts +68 -0
- package/dist/runtime/shared/data-zone.js +79 -0
- package/dist/runtime/shared/harness-providers.d.ts +42 -3
- 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 +19 -182
- package/dist/runtime/shared/provider-registry.js +25 -65
- 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/dist/runtime/{server → workspaces}/harness-settings.d.ts +51 -13
- package/dist/runtime/{server → workspaces}/harness-settings.js +93 -9
- package/dist/runtime/workspaces/index.d.ts +16 -0
- package/dist/runtime/workspaces/index.js +5 -0
- package/dist/runtime/workspaces/language-model.d.ts +45 -0
- package/dist/runtime/workspaces/language-model.js +26 -0
- package/dist/runtime/workspaces/model-endpoint.d.ts +38 -0
- package/dist/runtime/workspaces/model-endpoint.js +54 -0
- package/dist/runtime/workspaces/provider-settings.d.ts +35 -0
- package/dist/runtime/{server → workspaces}/provider-settings.js +0 -31
- package/dist/runtime/{server → workspaces}/settings-defaults.d.ts +1 -1
- package/dist/runtime/{shared → workspaces}/settings-types.d.ts +11 -4
- package/package.json +11 -2
- package/src/runtime/ai-sdk/index.ts +88 -47
- package/src/runtime/components/ForgeProvidersPanel.vue +477 -78
- package/src/runtime/editor/index.ts +158 -63
- package/src/runtime/model/selector.ts +31 -0
- package/src/runtime/server/credential-store.ts +20 -10
- package/src/runtime/server/env-layers.ts +4 -4
- package/src/runtime/server/index.ts +9 -8
- package/src/runtime/server/provider-discovery.ts +114 -48
- package/src/runtime/server/provider-layers.ts +6 -6
- package/src/runtime/server/provider-service.ts +53 -1
- package/src/runtime/server/provider-write.ts +132 -18
- package/src/runtime/server/voice-provider.ts +110 -0
- package/src/runtime/shared/data-zone.ts +179 -0
- package/src/runtime/shared/harness-providers.ts +78 -7
- package/src/runtime/shared/harnesses.ts +25 -0
- package/src/runtime/shared/provider-accounts.ts +526 -0
- package/src/runtime/shared/provider-registry.ts +82 -256
- package/src/runtime/vue/index.ts +2 -0
- package/src/runtime/vue/providers-panel-model.ts +123 -5
- package/src/runtime/vue/useProvidersPanel.ts +589 -37
- package/src/runtime/{server → workspaces}/harness-settings.ts +166 -22
- package/src/runtime/workspaces/index.ts +17 -0
- package/src/runtime/workspaces/language-model.ts +82 -0
- package/src/runtime/workspaces/model-endpoint.ts +100 -0
- package/src/runtime/workspaces/provider-settings.ts +96 -0
- package/src/runtime/{server → workspaces}/settings-defaults.ts +1 -1
- package/src/runtime/{shared → workspaces}/settings-types.ts +11 -4
- package/dist/runtime/server/provider-settings.d.ts +0 -78
- package/src/runtime/server/provider-settings.ts +0 -192
- /package/dist/runtime/{server → workspaces}/settings-defaults.js +0 -0
- /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
|
-
|
|
14
|
-
|
|
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/
|
|
35
|
+
} from "@skaile/provider-registry";
|
|
37
36
|
import {
|
|
38
37
|
AI_PROVIDER_PRESETS,
|
|
39
38
|
type AIProviderPreset,
|
|
40
39
|
expandPreset,
|
|
41
|
-
} from "@skaile/
|
|
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.
|
|
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
|
|
@@ -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
|
+
}
|