@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
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* therefore read the raw `providers` map and write it back unchanged apart
|
|
8
8
|
* from the entry being edited.
|
|
9
9
|
*/
|
|
10
|
-
import { type AIProviderPreset } from "@skaile/
|
|
10
|
+
import { type AIProviderPreset } from "@skaile/provider-registry/catalog";
|
|
11
11
|
/**
|
|
12
12
|
* The file a write to the layer at `path` goes to: the `.yaml` spelling when
|
|
13
13
|
* only that one exists, so an edit never shadows a hand-written document.
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
saveProviderRegistryFile
|
|
5
|
-
} from "@skaile/workspaces/core";
|
|
6
|
-
import { AI_PROVIDER_PRESETS } from "@skaile/workspaces/sdk/bridge";
|
|
2
|
+
import { AI_PROVIDER_PRESETS } from "@skaile/provider-registry/catalog";
|
|
3
|
+
import { saveProviderRegistryFile } from "@skaile/provider-registry/files";
|
|
7
4
|
import { parse as parseYaml } from "yaml";
|
|
8
5
|
import { isPlainObject } from "../shared/guards.js";
|
|
6
|
+
const PROVIDERS_FILENAME = "providers.yml";
|
|
9
7
|
const PROVIDERS_FILENAME_ALIAS = "providers.yaml";
|
|
10
8
|
export function providerLayerDocument(path) {
|
|
11
9
|
if (existsSync(path)) return path;
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* `model_map`/`capabilities`; harness-backed discovery is
|
|
19
19
|
* `listModelsForHarness(harnessId, registry)` in the runtime.
|
|
20
20
|
*/
|
|
21
|
-
import type { AIProviderCapabilities, AIProviderCredentialKind, AIProviderKind, AIProviderRegistryView, AIProviderSurface, AIProviderVoiceParams } from "../shared/provider-registry.js";
|
|
21
|
+
import type { AIProviderCapabilities, AIProviderCredentialKind, AIProviderDataZone, AIProviderKind, AIProviderRegistryView, AIProviderSurface, AIProviderTransport, AIProviderVoiceParams } from "../shared/provider-registry.js";
|
|
22
22
|
import { type CredentialSource, type CredentialStoreOptions } from "./credential-store.js";
|
|
23
23
|
/** One registry entry, as a settings UI needs to see it. */
|
|
24
24
|
export interface ProviderInfo {
|
|
@@ -52,6 +52,14 @@ export interface ProviderInfo {
|
|
|
52
52
|
preset?: string;
|
|
53
53
|
/** Wire protocol of an `llm`/`embed` entry (its own or its preset's). */
|
|
54
54
|
surface?: AIProviderSurface;
|
|
55
|
+
/** How requests of an `llm`/`embed` entry reach its surface, when not `direct`. */
|
|
56
|
+
transport?: AIProviderTransport;
|
|
57
|
+
/**
|
|
58
|
+
* Routing parameters of an `llm`/`embed` entry (`resource`, `deployment`,
|
|
59
|
+
* `api_version`, `region`, `project`, `location`, …), when it has any.
|
|
60
|
+
* Voice entries carry theirs in {@link params}.
|
|
61
|
+
*/
|
|
62
|
+
transportParams?: Record<string, string>;
|
|
55
63
|
/** The entry's `enabled` flag; `false` only in a listing with `includeDisabled`. */
|
|
56
64
|
enabled: boolean;
|
|
57
65
|
/**
|
|
@@ -63,6 +71,21 @@ export interface ProviderInfo {
|
|
|
63
71
|
params?: AIProviderVoiceParams;
|
|
64
72
|
/** Frozen by the control plane: a lower layer cannot change or disable it. */
|
|
65
73
|
locked: boolean;
|
|
74
|
+
/** Where the entry processes data: its `data_zone`, else its preset's, else `transfer`. */
|
|
75
|
+
dataZone: AIProviderDataZone;
|
|
76
|
+
/** The entry's `eu_processing` switch (an `eu_option` entry counts as EU only with it on). */
|
|
77
|
+
euProcessing: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* The entry's `allowed_models`, when it sets one: the only models it may be
|
|
80
|
+
* used with (`[]` = none). Absent: every model.
|
|
81
|
+
*/
|
|
82
|
+
allowedModels?: string[];
|
|
83
|
+
/**
|
|
84
|
+
* Logical model ids an `llm`/`embed` entry declares (`model_map` keys, then
|
|
85
|
+
* ids only `capabilities` names), when it declares any. An entry without
|
|
86
|
+
* them lists its models through discovery.
|
|
87
|
+
*/
|
|
88
|
+
modelIds?: string[];
|
|
66
89
|
}
|
|
67
90
|
/** One selectable model of one provider. */
|
|
68
91
|
export interface ModelInfo {
|
|
@@ -110,7 +133,8 @@ export declare function listAIProviders(registry: AIProviderRegistryView, option
|
|
|
110
133
|
*
|
|
111
134
|
* Sourced from each `llm`/`embed` entry's `model_map` (plus any id that only
|
|
112
135
|
* appears in `capabilities`) — the same two tables the runtime's model-reference
|
|
113
|
-
* resolver reads, so a selector built from this list always resolves.
|
|
136
|
+
* resolver reads, so a selector built from this list always resolves. A model
|
|
137
|
+
* outside the entry's `allowed_models` is left out, as the runtime refuses it.
|
|
114
138
|
*
|
|
115
139
|
* @param providerId - Restrict to one entry; omit for every enabled entry.
|
|
116
140
|
*/
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { entryDataZone } from "../shared/data-zone.js";
|
|
1
2
|
import {
|
|
2
3
|
entriesOfKind,
|
|
3
4
|
isEntryEnabled,
|
|
5
|
+
isModelAllowed,
|
|
4
6
|
isVoiceEntry,
|
|
5
7
|
parseCredentialRef,
|
|
6
8
|
registryEntries
|
|
@@ -32,13 +34,25 @@ export function listAIProviders(registry, options = {}) {
|
|
|
32
34
|
...entry.base_url ? { baseUrl: entry.base_url } : {},
|
|
33
35
|
...entry.preset ? { preset: entry.preset } : {},
|
|
34
36
|
...!isVoiceEntry(entry) && entry.surface ? { surface: entry.surface } : {},
|
|
37
|
+
...!isVoiceEntry(entry) && entry.transport && entry.transport !== "direct" ? { transport: entry.transport } : {},
|
|
38
|
+
...!isVoiceEntry(entry) && entry.params && Object.keys(entry.params).length > 0 ? { transportParams: { ...entry.params } } : {},
|
|
35
39
|
enabled,
|
|
36
40
|
...isVoiceEntry(entry) && entry.params ? { params: entry.params } : {},
|
|
37
|
-
locked: entry.locked === true
|
|
41
|
+
locked: entry.locked === true,
|
|
42
|
+
dataZone: entryDataZone(entry),
|
|
43
|
+
euProcessing: entry.eu_processing === true,
|
|
44
|
+
...!isVoiceEntry(entry) && entry.allowed_models ? { allowedModels: [...entry.allowed_models] } : {},
|
|
45
|
+
...!isVoiceEntry(entry) ? declaredModelIds(entry) : {}
|
|
38
46
|
});
|
|
39
47
|
}
|
|
40
48
|
return providers;
|
|
41
49
|
}
|
|
50
|
+
function declaredModelIds(entry) {
|
|
51
|
+
const ids = [
|
|
52
|
+
.../* @__PURE__ */ new Set([...Object.keys(entry.model_map ?? {}), ...Object.keys(entry.capabilities ?? {})])
|
|
53
|
+
];
|
|
54
|
+
return ids.length > 0 ? { modelIds: ids } : {};
|
|
55
|
+
}
|
|
42
56
|
export function listAIProviderModels(registry, providerId) {
|
|
43
57
|
const models = [];
|
|
44
58
|
for (const kind of ["llm", "embed"]) {
|
|
@@ -49,6 +63,7 @@ export function listAIProviderModels(registry, providerId) {
|
|
|
49
63
|
...Object.keys(entry.capabilities ?? {})
|
|
50
64
|
]);
|
|
51
65
|
for (const modelId of logicalIds) {
|
|
66
|
+
if (!isModelAllowed(entry, modelId)) continue;
|
|
52
67
|
const capabilities = entry.capabilities?.[modelId];
|
|
53
68
|
models.push({
|
|
54
69
|
id: `${id}/${modelId}`,
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* Auth stays in the app: it builds the {@link ProviderWriteScope} (paths, env,
|
|
23
23
|
* lock state), asks {@link providerScopeWritable}, and answers 403 itself.
|
|
24
24
|
*/
|
|
25
|
-
import { type AIProviderPreset } from "@skaile/
|
|
25
|
+
import { type AIProviderPreset } from "@skaile/provider-registry/catalog";
|
|
26
26
|
import type { AIProviderRegistry } from "../shared/provider-registry.js";
|
|
27
27
|
import { type CredentialPolicy } from "./credential-policy.js";
|
|
28
28
|
/**
|
|
@@ -74,8 +74,11 @@ export declare function providerScopeWritable(scope: ProviderWriteScope, caller:
|
|
|
74
74
|
* Upsert entry `id` in the scope's document.
|
|
75
75
|
*
|
|
76
76
|
* - **Own entry** — the body sets the editable fields (`BODY_KEYS`) — one it
|
|
77
|
-
* omits is removed — and optionally `kind
|
|
78
|
-
* `
|
|
77
|
+
* omits is removed — and optionally `kind`, `params`, `data_zone`,
|
|
78
|
+
* `eu_processing` and `allowed_models`; fields it does not set (`kind`,
|
|
79
|
+
* `transport`, `params`, `data_zone`, …) keep their written value,
|
|
80
|
+
* `params: {}` removes the parameters and `null` removes `data_zone`,
|
|
81
|
+
* `eu_processing` or `allowed_models` (`[]` allows no model).
|
|
79
82
|
* - **Patch** — when a lower document defines `id` and the body holds only
|
|
80
83
|
* {@link PATCH_KEYS}, the body is merged into the scope's stored entry for
|
|
81
84
|
* `id` (created when absent), leaving every other stored field alone.
|
|
@@ -83,11 +86,16 @@ export declare function providerScopeWritable(scope: ProviderWriteScope, caller:
|
|
|
83
86
|
* The file and its directory are created when absent; other entries are kept
|
|
84
87
|
* as written.
|
|
85
88
|
*
|
|
86
|
-
* 400: invalid id, secret-bearing or malformed body
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
89
|
+
* 400: invalid id, secret-bearing or malformed body (`data_zone` outside
|
|
90
|
+
* eu | eu_option | transfer | local, a non-boolean `eu_processing`, an
|
|
91
|
+
* `allowed_models` that is not a list of distinct non-empty ids), a body for
|
|
92
|
+
* an inherited entry that is neither a patch nor over an own full entry, a
|
|
93
|
+
* preset's required parameter left out of `params` (named in the message,
|
|
94
|
+
* `data.missingParams`), or any error diagnostic for the entry (as
|
|
95
|
+
* `data.diagnostics`; nothing is written — `params` keys a transport does not
|
|
96
|
+
* accept are refused this way, by the SDK's schema). 403: the entry is
|
|
97
|
+
* `locked: true` in some document. 409: the document on disk is not a
|
|
98
|
+
* registry.
|
|
91
99
|
*/
|
|
92
100
|
export declare function upsertProviderEntry(scope: ProviderWriteScope, id: string, body: unknown): {
|
|
93
101
|
id: string;
|
|
@@ -107,12 +115,18 @@ export interface ProviderPresetSummary {
|
|
|
107
115
|
label: string;
|
|
108
116
|
kind: AIProviderPreset["kind"];
|
|
109
117
|
surface?: AIProviderPreset["surface"];
|
|
118
|
+
/** How requests reach the surface; absent = `direct`. Azure transports offer Entra ID sign-in. */
|
|
119
|
+
transport?: AIProviderPreset["transport"];
|
|
110
120
|
base_url?: string;
|
|
111
121
|
base_url_env?: string;
|
|
112
122
|
credential: {
|
|
113
123
|
kind: AIProviderPreset["credential"]["kind"];
|
|
114
124
|
env?: string;
|
|
115
125
|
};
|
|
126
|
+
/** Default routing / voice parameters. */
|
|
127
|
+
params?: Record<string, string>;
|
|
128
|
+
/** `params` keys an entry naming this preset must supply. */
|
|
129
|
+
required_params?: string[];
|
|
116
130
|
}
|
|
117
131
|
/** Catalog presets (all of them by default), in catalog order. */
|
|
118
132
|
export declare function providerPresetSummaries(presets?: AIProviderPreset[]): ProviderPresetSummary[];
|
|
@@ -129,14 +143,16 @@ export declare function detectedProviderPresets(env: Record<string, string | und
|
|
|
129
143
|
export type ProviderLayerName = ProviderWriteScope["name"];
|
|
130
144
|
/**
|
|
131
145
|
* The defining document's values for the `PUT`-editable fields a settings UI
|
|
132
|
-
* edit dialog does not show
|
|
133
|
-
* so an edit sends these back
|
|
146
|
+
* edit dialog does not show (or shows only in part, as `params`). A `PUT`
|
|
147
|
+
* removes every editable field it omits, so an edit sends these back
|
|
148
|
+
* unchanged instead of dropping them.
|
|
134
149
|
*/
|
|
135
150
|
export interface ProviderLayerFields {
|
|
136
151
|
label?: unknown;
|
|
137
152
|
headers?: unknown;
|
|
138
153
|
model_map?: unknown;
|
|
139
154
|
enabled?: unknown;
|
|
155
|
+
params?: unknown;
|
|
140
156
|
}
|
|
141
157
|
/**
|
|
142
158
|
* Tag each provider view of a scope with every registry document that holds
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import {
|
|
2
2
|
isSafeProviderId,
|
|
3
|
-
loadProviderRegistryFile,
|
|
4
3
|
mergeRegistries,
|
|
5
4
|
parseCredentialRef,
|
|
6
5
|
validateMergedRegistry,
|
|
7
6
|
validateRegistry
|
|
8
|
-
} from "@skaile/
|
|
7
|
+
} from "@skaile/provider-registry";
|
|
9
8
|
import {
|
|
10
9
|
AI_PROVIDER_PRESETS,
|
|
11
10
|
expandPreset
|
|
12
|
-
} from "@skaile/
|
|
11
|
+
} from "@skaile/provider-registry/catalog";
|
|
12
|
+
import { loadProviderRegistryFile } from "@skaile/provider-registry/files";
|
|
13
|
+
import { isDataZone } from "../shared/data-zone.js";
|
|
13
14
|
import { isPlainObject } from "../shared/guards.js";
|
|
15
|
+
import { missingRequiredParams } from "../shared/provider-accounts.js";
|
|
14
16
|
import { entryPolicyRefusal } from "./credential-policy.js";
|
|
15
17
|
import { catalogPreset, readProviderLayer, writeProviderLayer } from "./provider-layers.js";
|
|
16
18
|
export class ProviderWriteError extends Error {
|
|
@@ -41,6 +43,26 @@ const BODY_KEYS = {
|
|
|
41
43
|
model_map: true,
|
|
42
44
|
enabled: true
|
|
43
45
|
};
|
|
46
|
+
const KEPT_BODY_KEYS = {
|
|
47
|
+
kind: true,
|
|
48
|
+
params: true,
|
|
49
|
+
data_zone: true,
|
|
50
|
+
eu_processing: true,
|
|
51
|
+
allowed_models: true
|
|
52
|
+
};
|
|
53
|
+
const NULLABLE_BODY_KEYS = ["data_zone", "eu_processing", "allowed_models"];
|
|
54
|
+
function readAllowedModels(value) {
|
|
55
|
+
const refusal = "allowed_models must be a list of distinct, non-empty model ids, or null";
|
|
56
|
+
if (!Array.isArray(value)) return refusal;
|
|
57
|
+
const ids = [];
|
|
58
|
+
for (const item of value) {
|
|
59
|
+
if (typeof item !== "string" || item.trim() === "") return refusal;
|
|
60
|
+
const id = item.trim();
|
|
61
|
+
if (ids.includes(id)) return `allowed_models lists '${id}' twice`;
|
|
62
|
+
ids.push(id);
|
|
63
|
+
}
|
|
64
|
+
return ids;
|
|
65
|
+
}
|
|
44
66
|
export const PATCH_KEYS = ["credential", "enabled"];
|
|
45
67
|
const SECRET_KEYS = { apiKey: true, api_key: true, token: true };
|
|
46
68
|
export function providerScopeWritable(scope, caller) {
|
|
@@ -74,8 +96,25 @@ function readEntryBody(body) {
|
|
|
74
96
|
if (Object.keys(body).some((key) => Object.hasOwn(SECRET_KEYS, key)) || credentialKeys.some((key) => key === "value" || Object.hasOwn(SECRET_KEYS, key))) {
|
|
75
97
|
return PROVIDER_SECRET_REFUSAL;
|
|
76
98
|
}
|
|
77
|
-
const unknown = Object.keys(body).find(
|
|
99
|
+
const unknown = Object.keys(body).find(
|
|
100
|
+
(key) => !Object.hasOwn(BODY_KEYS, key) && !Object.hasOwn(KEPT_BODY_KEYS, key)
|
|
101
|
+
);
|
|
78
102
|
if (unknown) return `'${unknown}' is not a provider entry field`;
|
|
103
|
+
if (body.params !== void 0 && !isPlainObject(body.params)) {
|
|
104
|
+
return "params must be a map of parameter name to value";
|
|
105
|
+
}
|
|
106
|
+
if (body.data_zone !== void 0 && body.data_zone !== null && !isDataZone(body.data_zone)) {
|
|
107
|
+
return "data_zone must be one of eu, eu_option, transfer, local, or null";
|
|
108
|
+
}
|
|
109
|
+
if (body.eu_processing !== void 0 && body.eu_processing !== null && typeof body.eu_processing !== "boolean") {
|
|
110
|
+
return "eu_processing must be true, false or null";
|
|
111
|
+
}
|
|
112
|
+
let allowedModels;
|
|
113
|
+
if (body.allowed_models !== void 0 && body.allowed_models !== null) {
|
|
114
|
+
const read = readAllowedModels(body.allowed_models);
|
|
115
|
+
if (typeof read === "string") return read;
|
|
116
|
+
allowedModels = read;
|
|
117
|
+
}
|
|
79
118
|
if (body.preset !== void 0 && !catalogPreset(body.preset)) {
|
|
80
119
|
return `Unknown preset '${String(body.preset)}'`;
|
|
81
120
|
}
|
|
@@ -85,7 +124,7 @@ function readEntryBody(body) {
|
|
|
85
124
|
if (ref !== void 0 && (typeof ref !== "string" || !parseCredentialRef(ref)))
|
|
86
125
|
return CREDENTIAL_REF_REFUSAL;
|
|
87
126
|
}
|
|
88
|
-
return body;
|
|
127
|
+
return allowedModels ? { ...body, allowed_models: allowedModels } : body;
|
|
89
128
|
}
|
|
90
129
|
function entryErrors(scope, providers, id) {
|
|
91
130
|
const layer = mergeLayerOf(scope);
|
|
@@ -118,6 +157,22 @@ function isPatchOrAbsent(entry) {
|
|
|
118
157
|
if (entry === void 0) return true;
|
|
119
158
|
return isPlainObject(entry) && Object.keys(entry).every((key) => PATCH_KEYS.includes(key));
|
|
120
159
|
}
|
|
160
|
+
function lowerPreset(lowerEntries) {
|
|
161
|
+
let preset;
|
|
162
|
+
for (const entry of lowerEntries)
|
|
163
|
+
if (isPlainObject(entry) && entry.preset !== void 0) preset = entry.preset;
|
|
164
|
+
return preset;
|
|
165
|
+
}
|
|
166
|
+
function missingPresetParams(entry, lowerEntries) {
|
|
167
|
+
const preset = catalogPreset(entry.preset ?? lowerPreset(lowerEntries));
|
|
168
|
+
if (!preset) return [];
|
|
169
|
+
const params = {};
|
|
170
|
+
for (const lower of lowerEntries) {
|
|
171
|
+
if (isPlainObject(lower) && isPlainObject(lower.params)) Object.assign(params, lower.params);
|
|
172
|
+
}
|
|
173
|
+
if (isPlainObject(entry.params)) Object.assign(params, entry.params);
|
|
174
|
+
return missingRequiredParams(preset, params);
|
|
175
|
+
}
|
|
121
176
|
export function upsertProviderEntry(scope, id, body) {
|
|
122
177
|
if (!isSafeProviderId(id)) {
|
|
123
178
|
throw createError({
|
|
@@ -150,6 +205,16 @@ export function upsertProviderEntry(scope, id, body) {
|
|
|
150
205
|
Object.entries(existing).filter(([key]) => !Object.hasOwn(BODY_KEYS, key))
|
|
151
206
|
) : {};
|
|
152
207
|
entry = { ...kept, ...fields };
|
|
208
|
+
if (isPlainObject(entry.params) && Object.keys(entry.params).length === 0) delete entry.params;
|
|
209
|
+
for (const key of NULLABLE_BODY_KEYS) if (entry[key] === null) delete entry[key];
|
|
210
|
+
const missing = missingPresetParams(entry, lowerEntries);
|
|
211
|
+
if (missing.length > 0) {
|
|
212
|
+
throw createError({
|
|
213
|
+
statusCode: 400,
|
|
214
|
+
statusMessage: `Provider '${id}' needs ${missing.map((key) => `params.${key}`).join(", ")} (required by preset '${String(entry.preset ?? lowerPreset(lowerEntries))}')`,
|
|
215
|
+
data: { missingParams: missing }
|
|
216
|
+
});
|
|
217
|
+
}
|
|
153
218
|
}
|
|
154
219
|
const next = { ...providers, [id]: entry };
|
|
155
220
|
const refusal = entryPolicyRefusal(entry, scope.policy);
|
|
@@ -183,9 +248,12 @@ export function providerPresetSummaries(presets = Object.values(AI_PROVIDER_PRES
|
|
|
183
248
|
label: preset.label,
|
|
184
249
|
kind: preset.kind,
|
|
185
250
|
surface: preset.surface,
|
|
251
|
+
...preset.transport ? { transport: preset.transport } : {},
|
|
186
252
|
base_url: preset.base_url,
|
|
187
253
|
base_url_env: preset.base_url_env,
|
|
188
|
-
credential: { kind: preset.credential.kind, env: preset.credential.env }
|
|
254
|
+
credential: { kind: preset.credential.kind, env: preset.credential.env },
|
|
255
|
+
...preset.params ? { params: { ...preset.params } } : {},
|
|
256
|
+
...preset.required_params?.length ? { required_params: [...preset.required_params] } : {}
|
|
189
257
|
}));
|
|
190
258
|
}
|
|
191
259
|
export function detectedProviderPresets(env, registry) {
|
|
@@ -200,7 +268,7 @@ export function detectedProviderPresets(env, registry) {
|
|
|
200
268
|
})
|
|
201
269
|
);
|
|
202
270
|
}
|
|
203
|
-
const LAYER_FIELD_KEYS = ["label", "headers", "model_map", "enabled"];
|
|
271
|
+
const LAYER_FIELD_KEYS = ["label", "headers", "model_map", "enabled", "params"];
|
|
204
272
|
export function withProviderLayers(views, scope) {
|
|
205
273
|
const own = readLayerLeniently(scope.file);
|
|
206
274
|
const lower = scope.below.map((file) => readLayerLeniently(file));
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
import type { AIProviderRegistryView, AIProviderVoiceEntry, AIProviderVoiceKind, AIProviderVoiceParams } from "../shared/provider-registry.js";
|
|
12
|
+
/**
|
|
13
|
+
* Settings fields that name a voice entry and override its parameters — the
|
|
14
|
+
* same field names as the Workspaces `settings.json` document, so a
|
|
15
|
+
* `SkaileSettings` (`@skaile/forge-settings/workspaces`) can be passed as is.
|
|
16
|
+
*/
|
|
17
|
+
export interface VoiceSettings {
|
|
18
|
+
/** Registry id of the `kind: stt` entry. */
|
|
19
|
+
sttProvider?: string;
|
|
20
|
+
/** Registry id of the `kind: tts` entry. */
|
|
21
|
+
ttsProvider?: string;
|
|
22
|
+
/** Overrides the stt entry's `language` parameter. */
|
|
23
|
+
sttLanguage?: string;
|
|
24
|
+
/** Overrides the tts entry's `voice_id` parameter. */
|
|
25
|
+
ttsVoice?: string;
|
|
26
|
+
}
|
|
27
|
+
/** The `kind: stt|tts` entry a request runs against, with its parameters resolved. */
|
|
28
|
+
export interface VoiceProviderSelection {
|
|
29
|
+
/** Registry id of the entry. */
|
|
30
|
+
providerId: string;
|
|
31
|
+
entry: AIProviderVoiceEntry;
|
|
32
|
+
/** Endpoint from the entry; absent when the entry inherits its preset's default. */
|
|
33
|
+
baseUrl?: string;
|
|
34
|
+
/**
|
|
35
|
+
* The entry's `params` with overrides applied — Deepgram `{model, language}`,
|
|
36
|
+
* ElevenLabs `{voice_id, output_format}`. This is the only place a forge app
|
|
37
|
+
* should read voice defaults from.
|
|
38
|
+
*/
|
|
39
|
+
params: AIProviderVoiceParams;
|
|
40
|
+
/** True when settings named no usable entry and registry order decided. */
|
|
41
|
+
fallback: boolean;
|
|
42
|
+
/** Non-fatal notes, e.g. settings naming an entry the registry does not have. */
|
|
43
|
+
warnings: string[];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Resolve which registry entry serves speech-to-text or text-to-speech.
|
|
47
|
+
*
|
|
48
|
+
* Selection is `settings.sttProvider`/`settings.ttsProvider`, falling back to
|
|
49
|
+
* the first enabled entry of that kind in registry order when the setting is
|
|
50
|
+
* absent or names an entry that is gone — a missing setting must not silence an
|
|
51
|
+
* app that has a perfectly good entry configured.
|
|
52
|
+
*
|
|
53
|
+
* Parameter precedence, lowest first: the entry's `params`, then the settings
|
|
54
|
+
* overrides (`sttLanguage` → `language`, `ttsVoice` → `voice_id`), then
|
|
55
|
+
* `overrides` from the request. The per-request override is the contract
|
|
56
|
+
* forge-concept already honoured and the other apps silently dropped.
|
|
57
|
+
*
|
|
58
|
+
* @returns The selection, or `undefined` when the registry has no enabled entry
|
|
59
|
+
* of that kind.
|
|
60
|
+
*/
|
|
61
|
+
export declare function resolveVoiceProvider(registry: AIProviderRegistryView, kind: AIProviderVoiceKind, settings?: VoiceSettings, overrides?: AIProviderVoiceParams): VoiceProviderSelection | undefined;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {
|
|
2
|
+
entriesOfKind,
|
|
3
|
+
selectVoiceEntryId,
|
|
4
|
+
VOICE_OVERRIDE_PARAM
|
|
5
|
+
} from "../shared/provider-registry.js";
|
|
6
|
+
export function resolveVoiceProvider(registry, kind, settings = {}, overrides = {}) {
|
|
7
|
+
const candidates = entriesOfKind(registry, kind);
|
|
8
|
+
if (candidates.length === 0) return void 0;
|
|
9
|
+
const warnings = [];
|
|
10
|
+
const wanted = (kind === "stt" ? settings.sttProvider : settings.ttsProvider)?.trim();
|
|
11
|
+
const ids = candidates.map(([id]) => id);
|
|
12
|
+
const providerId = selectVoiceEntryId(ids, wanted);
|
|
13
|
+
const entry = candidates[ids.indexOf(providerId)][1];
|
|
14
|
+
if (wanted && wanted !== providerId) {
|
|
15
|
+
warnings.push(
|
|
16
|
+
`settings name '${wanted}' for ${kind}, which is not an enabled ${kind} entry \u2014 using '${providerId}' instead`
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
const fallback = wanted !== providerId;
|
|
20
|
+
const settingsOverride = {};
|
|
21
|
+
const override = (kind === "stt" ? settings.sttLanguage : settings.ttsVoice)?.trim();
|
|
22
|
+
if (override) settingsOverride[VOICE_OVERRIDE_PARAM[kind]] = override;
|
|
23
|
+
return {
|
|
24
|
+
providerId,
|
|
25
|
+
entry,
|
|
26
|
+
...entry.base_url ? { baseUrl: entry.base_url } : {},
|
|
27
|
+
params: { ...entry.params, ...settingsOverride, ...overrides },
|
|
28
|
+
fallback,
|
|
29
|
+
warnings
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
import { type AIProviderDataZone, type AIProviderDataZoneRule, type AIProviderRegistry, type AIProviderRegistryView, type MergedAIProviderRegistry } from "./provider-registry.js";
|
|
18
|
+
/**
|
|
19
|
+
* The `data_zone` of each catalog preset, by preset id. Copied from
|
|
20
|
+
* `@skaile/provider-registry/catalog`'s `AI_PROVIDER_PRESETS`; change it there
|
|
21
|
+
* first. A preset this table does not name is `transfer`.
|
|
22
|
+
*/
|
|
23
|
+
export declare const PRESET_DATA_ZONES: Readonly<Record<string, AIProviderDataZone>>;
|
|
24
|
+
/** What a provider row's zone chip says. */
|
|
25
|
+
export declare const DATA_ZONE_LABELS: Readonly<Record<AIProviderDataZone, string>>;
|
|
26
|
+
/** What a host calls each rule when it names the rule that keeps an entry out. */
|
|
27
|
+
export declare const DATA_ZONE_RULE_LABELS: Readonly<Record<AIProviderDataZoneRule, string>>;
|
|
28
|
+
/** Whether `value` is one of `AI_PROVIDER_DATA_ZONES`. */
|
|
29
|
+
export declare function isDataZone(value: unknown): value is AIProviderDataZone;
|
|
30
|
+
/** Whether `value` is one of `AI_PROVIDER_DATA_ZONE_RULES`. */
|
|
31
|
+
export declare function isDataZoneRule(value: unknown): value is AIProviderDataZoneRule;
|
|
32
|
+
/** The zone of catalog preset `presetId`, or `undefined` for an id the catalog does not name. */
|
|
33
|
+
export declare function presetDataZone(presetId: string | undefined): AIProviderDataZone | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* Where an entry processes data: its own `data_zone`, else the zone of the
|
|
36
|
+
* catalog preset it names, else `transfer` (an unknown endpoint is assumed to
|
|
37
|
+
* leave the EU).
|
|
38
|
+
*/
|
|
39
|
+
export declare function entryDataZone(entry: {
|
|
40
|
+
data_zone?: AIProviderDataZone;
|
|
41
|
+
preset?: string;
|
|
42
|
+
}): AIProviderDataZone;
|
|
43
|
+
/**
|
|
44
|
+
* Whether `rule` allows an entry in `zone`: `any` allows all; `eu` allows
|
|
45
|
+
* `eu` and `local`; `eu_options` additionally allows `eu_option` when
|
|
46
|
+
* `euProcessing` is `true`.
|
|
47
|
+
*/
|
|
48
|
+
export declare function dataZoneAllows(rule: AIProviderDataZoneRule, zone: AIProviderDataZone, euProcessing: boolean | undefined): boolean;
|
|
49
|
+
/** Whether `rule` allows `entry` ({@link dataZoneAllows} over {@link entryDataZone}). */
|
|
50
|
+
export declare function entryAllowedByDataZone(entry: {
|
|
51
|
+
data_zone?: AIProviderDataZone;
|
|
52
|
+
preset?: string;
|
|
53
|
+
eu_processing?: boolean;
|
|
54
|
+
}, rule: AIProviderDataZoneRule): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Why `rule` keeps an entry in `zone` out, naming the rule, or `undefined`
|
|
57
|
+
* when the rule allows it. Secret-free, safe to render.
|
|
58
|
+
*/
|
|
59
|
+
export declare function dataZoneRefusal(rule: AIProviderDataZoneRule, zone: AIProviderDataZone, euProcessing: boolean | undefined): string | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* The registry with only the entries `rule` allows, every kind alike
|
|
62
|
+
* (`llm`, `embed`, `stt`, `tts`). Order is kept; the input is not mutated. A
|
|
63
|
+
* merged registry comes back merged, its `order`, `origin` and `locked`
|
|
64
|
+
* narrowed to the kept entries.
|
|
65
|
+
*/
|
|
66
|
+
export declare function filterRegistryByDataZone(registry: AIProviderRegistry, rule: AIProviderDataZoneRule): AIProviderRegistry;
|
|
67
|
+
export declare function filterRegistryByDataZone(registry: MergedAIProviderRegistry, rule: AIProviderDataZoneRule): MergedAIProviderRegistry;
|
|
68
|
+
export declare function filterRegistryByDataZone(registry: AIProviderRegistryView, rule: AIProviderDataZoneRule): AIProviderRegistryView;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AI_PROVIDER_DATA_ZONE_RULES,
|
|
3
|
+
AI_PROVIDER_DATA_ZONES,
|
|
4
|
+
isMergedRegistry
|
|
5
|
+
} from "./provider-registry.js";
|
|
6
|
+
export const PRESET_DATA_ZONES = {
|
|
7
|
+
anthropic: "transfer",
|
|
8
|
+
openai: "transfer",
|
|
9
|
+
google: "transfer",
|
|
10
|
+
groq: "transfer",
|
|
11
|
+
deepseek: "transfer",
|
|
12
|
+
openrouter: "transfer",
|
|
13
|
+
mistral: "eu",
|
|
14
|
+
xai: "transfer",
|
|
15
|
+
together: "transfer",
|
|
16
|
+
fireworks: "transfer",
|
|
17
|
+
bedrock: "eu_option",
|
|
18
|
+
vertex: "eu_option",
|
|
19
|
+
"azure-openai": "eu_option",
|
|
20
|
+
foundry: "eu_option",
|
|
21
|
+
ollama: "local",
|
|
22
|
+
lmstudio: "local",
|
|
23
|
+
vllm: "local",
|
|
24
|
+
litellm: "transfer",
|
|
25
|
+
deepgram: "transfer",
|
|
26
|
+
elevenlabs: "transfer"
|
|
27
|
+
};
|
|
28
|
+
export const DATA_ZONE_LABELS = {
|
|
29
|
+
eu: "EU",
|
|
30
|
+
eu_option: "EU option",
|
|
31
|
+
transfer: "Transfer",
|
|
32
|
+
local: "Local"
|
|
33
|
+
};
|
|
34
|
+
export const DATA_ZONE_RULE_LABELS = {
|
|
35
|
+
any: "Anywhere",
|
|
36
|
+
eu_options: "EU, or EU option with EU processing on",
|
|
37
|
+
eu: "EU only"
|
|
38
|
+
};
|
|
39
|
+
export function isDataZone(value) {
|
|
40
|
+
return typeof value === "string" && AI_PROVIDER_DATA_ZONES.includes(value);
|
|
41
|
+
}
|
|
42
|
+
export function isDataZoneRule(value) {
|
|
43
|
+
return typeof value === "string" && AI_PROVIDER_DATA_ZONE_RULES.includes(value);
|
|
44
|
+
}
|
|
45
|
+
export function presetDataZone(presetId) {
|
|
46
|
+
return presetId !== void 0 && Object.hasOwn(PRESET_DATA_ZONES, presetId) ? PRESET_DATA_ZONES[presetId] : void 0;
|
|
47
|
+
}
|
|
48
|
+
export function entryDataZone(entry) {
|
|
49
|
+
return entry.data_zone ?? presetDataZone(entry.preset) ?? "transfer";
|
|
50
|
+
}
|
|
51
|
+
export function dataZoneAllows(rule, zone, euProcessing) {
|
|
52
|
+
if (rule === "any") return true;
|
|
53
|
+
if (zone === "eu" || zone === "local") return true;
|
|
54
|
+
return rule === "eu_options" && zone === "eu_option" && euProcessing === true;
|
|
55
|
+
}
|
|
56
|
+
export function entryAllowedByDataZone(entry, rule) {
|
|
57
|
+
return dataZoneAllows(rule, entryDataZone(entry), entry.eu_processing);
|
|
58
|
+
}
|
|
59
|
+
export function dataZoneRefusal(rule, zone, euProcessing) {
|
|
60
|
+
if (dataZoneAllows(rule, zone, euProcessing)) return void 0;
|
|
61
|
+
const why = zone === "eu_option" && rule === "eu_options" ? "EU processing is not on for this account" : `this provider is ${DATA_ZONE_LABELS[zone]}`;
|
|
62
|
+
return `Blocked by the data-zone rule "${DATA_ZONE_RULE_LABELS[rule]}": ${why}.`;
|
|
63
|
+
}
|
|
64
|
+
export function filterRegistryByDataZone(registry, rule) {
|
|
65
|
+
const entries = isMergedRegistry(registry) ? registry.providers : registry;
|
|
66
|
+
const providers = {};
|
|
67
|
+
for (const [id, entry] of Object.entries(entries)) {
|
|
68
|
+
if (entryAllowedByDataZone(entry, rule)) providers[id] = entry;
|
|
69
|
+
}
|
|
70
|
+
if (!isMergedRegistry(registry)) return providers;
|
|
71
|
+
const kept = (id) => Object.hasOwn(providers, id);
|
|
72
|
+
return {
|
|
73
|
+
...registry,
|
|
74
|
+
providers,
|
|
75
|
+
order: registry.order.filter(kept),
|
|
76
|
+
origin: Object.fromEntries(Object.entries(registry.origin).filter(([id]) => kept(id))),
|
|
77
|
+
locked: registry.locked.filter(kept)
|
|
78
|
+
};
|
|
79
|
+
}
|