@skaile/forge-settings 0.0.0-stage → 1.0.1
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 +28 -0
- package/LICENSE +21 -0
- package/README.md +94 -2
- package/dist/module.d.mts +17 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +23 -0
- package/dist/runtime/ai-sdk/index.d.ts +46 -0
- package/dist/runtime/ai-sdk/index.js +31 -0
- package/dist/runtime/components/ForgeProvidersPanel.d.vue.ts +21 -0
- package/dist/runtime/components/ForgeProvidersPanel.vue +435 -0
- package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +21 -0
- package/dist/runtime/editor/index.d.ts +58 -0
- package/dist/runtime/editor/index.js +72 -0
- package/dist/runtime/model/errors.d.ts +32 -0
- package/dist/runtime/model/errors.js +19 -0
- package/dist/runtime/server/credential-policy.d.ts +61 -0
- package/dist/runtime/server/credential-policy.js +65 -0
- package/dist/runtime/server/credential-store.d.ts +184 -0
- package/dist/runtime/server/credential-store.js +234 -0
- package/dist/runtime/server/env-layers.d.ts +62 -0
- package/dist/runtime/server/env-layers.js +183 -0
- package/dist/runtime/server/env.d.ts +30 -0
- package/dist/runtime/server/env.js +15 -0
- package/dist/runtime/server/harness-settings.d.ts +150 -0
- package/dist/runtime/server/harness-settings.js +166 -0
- package/dist/runtime/server/index.d.ts +23 -0
- package/dist/runtime/server/index.js +14 -0
- package/dist/runtime/server/provider-discovery.d.ts +73 -0
- package/dist/runtime/server/provider-discovery.js +77 -0
- package/dist/runtime/server/provider-layers.d.ts +27 -0
- package/dist/runtime/server/provider-layers.js +35 -0
- package/dist/runtime/server/provider-service.d.ts +126 -0
- package/dist/runtime/server/provider-service.js +87 -0
- package/dist/runtime/server/provider-settings.d.ts +78 -0
- package/dist/runtime/server/provider-settings.js +81 -0
- package/dist/runtime/server/provider-write.d.ts +154 -0
- package/dist/runtime/server/provider-write.js +221 -0
- package/dist/runtime/server/settings-defaults.d.ts +26 -0
- package/dist/runtime/server/settings-defaults.js +46 -0
- package/dist/runtime/shared/env-vars.d.ts +55 -0
- package/dist/runtime/shared/env-vars.js +22 -0
- package/dist/runtime/shared/guards.d.ts +2 -0
- package/dist/runtime/shared/guards.js +3 -0
- package/dist/runtime/shared/harness-providers.d.ts +62 -0
- package/dist/runtime/shared/harness-providers.js +38 -0
- package/dist/runtime/shared/harnesses.d.ts +19 -0
- package/dist/runtime/shared/harnesses.js +15 -0
- package/dist/runtime/shared/provider-registry.d.ts +255 -0
- package/dist/runtime/shared/provider-registry.js +112 -0
- package/dist/runtime/shared/settings-types.d.ts +84 -0
- package/dist/runtime/shared/settings-types.js +0 -0
- package/dist/runtime/shims-vue.d.ts +8 -0
- package/dist/runtime/vue/index.d.ts +14 -0
- package/dist/runtime/vue/index.js +6 -0
- package/dist/runtime/vue/providers-panel-model.d.ts +147 -0
- package/dist/runtime/vue/providers-panel-model.js +37 -0
- package/dist/runtime/vue/settings-modal-scope.d.ts +62 -0
- package/dist/runtime/vue/settings-modal-scope.js +2 -0
- package/dist/runtime/vue/useProvidersPanel.d.ts +115 -0
- package/dist/runtime/vue/useProvidersPanel.js +633 -0
- package/dist/types.d.mts +9 -0
- package/package.json +117 -4
- package/src/runtime/ai-sdk/index.ts +88 -0
- package/src/runtime/components/ForgeProvidersPanel.vue +449 -0
- package/src/runtime/editor/index.ts +146 -0
- package/src/runtime/model/errors.ts +52 -0
- package/src/runtime/server/credential-policy.ts +139 -0
- package/src/runtime/server/credential-store.ts +473 -0
- package/src/runtime/server/env-layers.ts +287 -0
- package/src/runtime/server/env.ts +46 -0
- package/src/runtime/server/harness-settings.ts +373 -0
- package/src/runtime/server/index.ts +24 -0
- package/src/runtime/server/provider-discovery.ts +164 -0
- package/src/runtime/server/provider-layers.ts +67 -0
- package/src/runtime/server/provider-service.ts +244 -0
- package/src/runtime/server/provider-settings.ts +192 -0
- package/src/runtime/server/provider-write.ts +433 -0
- package/src/runtime/server/settings-defaults.ts +87 -0
- package/src/runtime/shared/env-vars.ts +82 -0
- package/src/runtime/shared/guards.ts +4 -0
- package/src/runtime/shared/harness-providers.ts +118 -0
- package/src/runtime/shared/harnesses.ts +34 -0
- package/src/runtime/shared/provider-registry.ts +391 -0
- package/src/runtime/shared/settings-types.ts +87 -0
- package/src/runtime/shims-vue.d.ts +8 -0
- package/src/runtime/vue/index.ts +15 -0
- package/src/runtime/vue/providers-panel-model.ts +203 -0
- package/src/runtime/vue/settings-modal-scope.ts +66 -0
- package/src/runtime/vue/useProvidersPanel.ts +930 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editing the provider registry's on-disk layers over HTTP: the instance
|
|
3
|
+
* layer (admins) and a workspace's `<root>/.skaile/providers.yml` (write
|
|
4
|
+
* access, unless the admin locked providers).
|
|
5
|
+
*
|
|
6
|
+
* A layer file holds references, never secrets: a body that carries a key
|
|
7
|
+
* value is refused before anything else, and `credential.ref` must be one of
|
|
8
|
+
* the four reference forms. Every write is gated on the registry it would
|
|
9
|
+
* produce — the scope's layers merged over the catalog presets the entries
|
|
10
|
+
* name, as the spawned child merges them — so an entry the child would drop is
|
|
11
|
+
* refused with the upstream diagnostics instead of being written.
|
|
12
|
+
*
|
|
13
|
+
* An entry a lower document defines can be **patched** from a higher scope:
|
|
14
|
+
* a body holding only {@link PATCH_KEYS} is stored under that id in the
|
|
15
|
+
* scope's own document, where the registry merge applies it field by field
|
|
16
|
+
* over the inherited entry. Removing it again (`removeProviderEntry`) is the
|
|
17
|
+
* patch's reset.
|
|
18
|
+
*
|
|
19
|
+
* Writes never touch live sessions: a running chat keeps its endpoint until
|
|
20
|
+
* its model or harness changes.
|
|
21
|
+
*
|
|
22
|
+
* Auth stays in the app: it builds the {@link ProviderWriteScope} (paths, env,
|
|
23
|
+
* lock state), asks {@link providerScopeWritable}, and answers 403 itself.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
type AIProviderDiagnostic,
|
|
28
|
+
type AIProviderRegistryLayer,
|
|
29
|
+
type AIProviderRegistryPatch,
|
|
30
|
+
isSafeProviderId,
|
|
31
|
+
loadProviderRegistryFile,
|
|
32
|
+
mergeRegistries,
|
|
33
|
+
parseCredentialRef,
|
|
34
|
+
validateMergedRegistry,
|
|
35
|
+
validateRegistry,
|
|
36
|
+
} from "@skaile/workspaces/core";
|
|
37
|
+
import {
|
|
38
|
+
AI_PROVIDER_PRESETS,
|
|
39
|
+
type AIProviderPreset,
|
|
40
|
+
expandPreset,
|
|
41
|
+
} from "@skaile/workspaces/sdk/bridge";
|
|
42
|
+
import { isPlainObject } from "../shared/guards";
|
|
43
|
+
import type { AIProviderEntry, AIProviderRegistry } from "../shared/provider-registry";
|
|
44
|
+
import { type CredentialPolicy, entryPolicyRefusal } from "./credential-policy";
|
|
45
|
+
import { catalogPreset, readProviderLayer, writeProviderLayer } from "./provider-layers";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A refused provider write. Framework-free: a Nitro / h3 host answers a thrown
|
|
49
|
+
* error carrying `statusCode`, `statusMessage` and `data` with exactly that
|
|
50
|
+
* status, message and body, the same as one made by `createError`.
|
|
51
|
+
*/
|
|
52
|
+
export class ProviderWriteError extends Error {
|
|
53
|
+
readonly statusCode: number;
|
|
54
|
+
readonly statusMessage: string;
|
|
55
|
+
readonly data?: unknown;
|
|
56
|
+
constructor(input: { statusCode: number; statusMessage: string; data?: unknown }) {
|
|
57
|
+
super(input.statusMessage);
|
|
58
|
+
this.name = "ProviderWriteError";
|
|
59
|
+
this.statusCode = input.statusCode;
|
|
60
|
+
this.statusMessage = input.statusMessage;
|
|
61
|
+
if (input.data !== undefined) this.data = input.data;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function createError(input: { statusCode: number; statusMessage: string; data?: unknown }) {
|
|
66
|
+
return new ProviderWriteError(input);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The refusal for a body that carries a secret instead of a reference. */
|
|
70
|
+
export const PROVIDER_SECRET_REFUSAL =
|
|
71
|
+
"Secrets go into the scope's .env; reference them as env:NAME";
|
|
72
|
+
|
|
73
|
+
const CREDENTIAL_REF_REFUSAL =
|
|
74
|
+
"credential.ref must be a reference, not a secret: env:NAME, cmd:<shell>, platform:<configId> or keyring:<service>/<account>";
|
|
75
|
+
|
|
76
|
+
const PATCH_REFUSAL =
|
|
77
|
+
"A workspace may only patch credential and enabled of an inherited entry; add it as a workspace entry to change anything else.";
|
|
78
|
+
|
|
79
|
+
/**
|
|
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
|
|
83
|
+
* written value like every other key of a stored entry.
|
|
84
|
+
*/
|
|
85
|
+
const BODY_KEYS: Record<string, true> = {
|
|
86
|
+
preset: true,
|
|
87
|
+
label: true,
|
|
88
|
+
surface: true,
|
|
89
|
+
base_url: true,
|
|
90
|
+
headers: true,
|
|
91
|
+
credential: true,
|
|
92
|
+
model_map: true,
|
|
93
|
+
enabled: true,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Entry fields a higher scope may set on an entry a lower document defines. */
|
|
97
|
+
export const PATCH_KEYS = ["credential", "enabled"] as const;
|
|
98
|
+
|
|
99
|
+
/** Keys that only ever hold a secret value. */
|
|
100
|
+
const SECRET_KEYS: Record<string, true> = { apiKey: true, api_key: true, token: true };
|
|
101
|
+
|
|
102
|
+
/** One registry layer file a write goes to, and the layers merged beneath it. */
|
|
103
|
+
export interface ProviderWriteScope {
|
|
104
|
+
/** Which registry the caller edits. */
|
|
105
|
+
name: "instance" | "workspace";
|
|
106
|
+
/** The scope's own document (its `.yaml` spelling when only that one exists). */
|
|
107
|
+
file: string;
|
|
108
|
+
/** Documents merged beneath it, lowest first; empty for the lowest layer. */
|
|
109
|
+
below: string[];
|
|
110
|
+
/** The scope's effective environment, as `env:` credential references resolve against it. */
|
|
111
|
+
env: Record<string, string | undefined>;
|
|
112
|
+
/** The control plane's provider lock is in force: only an admin may write. */
|
|
113
|
+
lock: boolean;
|
|
114
|
+
/**
|
|
115
|
+
* The host's credential policy. A write whose resulting entry names a
|
|
116
|
+
* reference or environment variable the policy refuses is a 400 and nothing
|
|
117
|
+
* is written. Absent: no restriction.
|
|
118
|
+
*/
|
|
119
|
+
policy?: CredentialPolicy;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Whether the caller may write the scope's document: an admin always; anyone
|
|
124
|
+
* else only to a workspace document while providers are not locked.
|
|
125
|
+
*/
|
|
126
|
+
export function providerScopeWritable(
|
|
127
|
+
scope: ProviderWriteScope,
|
|
128
|
+
caller: { isAdmin: boolean },
|
|
129
|
+
): boolean {
|
|
130
|
+
if (caller.isAdmin) return true;
|
|
131
|
+
return scope.name !== "instance" && !scope.lock;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The merge layer a scope's own document feeds. */
|
|
135
|
+
function mergeLayerOf(scope: ProviderWriteScope): "user" | "workspace" {
|
|
136
|
+
return scope.name === "instance" ? "user" : "workspace";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A layer document's entries; a document that is not a registry reads as empty, as the loader skips it. */
|
|
140
|
+
function readLayerLeniently(file: string): Record<string, unknown> {
|
|
141
|
+
try {
|
|
142
|
+
return readProviderLayer(file);
|
|
143
|
+
} catch {
|
|
144
|
+
return {};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The scope's document for an edit; 409 when it cannot be read back safely. */
|
|
149
|
+
function editableLayer(scope: ProviderWriteScope): Record<string, unknown> {
|
|
150
|
+
try {
|
|
151
|
+
return readProviderLayer(scope.file);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
throw createError({
|
|
154
|
+
statusCode: 409,
|
|
155
|
+
statusMessage: `${(err as Error).message} - fix it by hand first`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The entry fields a `PUT` body sets, or the refusal. */
|
|
161
|
+
function readEntryBody(body: unknown): Record<string, unknown> | string {
|
|
162
|
+
if (!isPlainObject(body)) return "Body must be a provider entry object";
|
|
163
|
+
const credential = body.credential;
|
|
164
|
+
const credentialKeys = isPlainObject(credential) ? Object.keys(credential) : [];
|
|
165
|
+
if (
|
|
166
|
+
Object.keys(body).some((key) => Object.hasOwn(SECRET_KEYS, key)) ||
|
|
167
|
+
credentialKeys.some((key) => key === "value" || Object.hasOwn(SECRET_KEYS, key))
|
|
168
|
+
) {
|
|
169
|
+
return PROVIDER_SECRET_REFUSAL;
|
|
170
|
+
}
|
|
171
|
+
const unknown = Object.keys(body).find((key) => !Object.hasOwn(BODY_KEYS, key) && key !== "kind");
|
|
172
|
+
if (unknown) return `'${unknown}' is not a provider entry field`;
|
|
173
|
+
if (body.preset !== undefined && !catalogPreset(body.preset)) {
|
|
174
|
+
return `Unknown preset '${String(body.preset)}'`;
|
|
175
|
+
}
|
|
176
|
+
if (credential !== undefined) {
|
|
177
|
+
if (!isPlainObject(credential)) return "credential must be { kind, ref }";
|
|
178
|
+
const ref = credential.ref;
|
|
179
|
+
if (ref !== undefined && (typeof ref !== "string" || !parseCredentialRef(ref)))
|
|
180
|
+
return CREDENTIAL_REF_REFUSAL;
|
|
181
|
+
}
|
|
182
|
+
return body;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Error diagnostics for entry `id` in the registry the scope would hold with
|
|
187
|
+
* `providers` as its own layer: the layer check (types, enums, literal
|
|
188
|
+
* secrets) plus the merged gate over the lower layers and the catalog presets
|
|
189
|
+
* the merged entries name. Only the edited entry's errors count — a broken
|
|
190
|
+
* entry elsewhere is not this write's doing.
|
|
191
|
+
*/
|
|
192
|
+
function entryErrors(
|
|
193
|
+
scope: ProviderWriteScope,
|
|
194
|
+
providers: Record<string, unknown>,
|
|
195
|
+
id: string,
|
|
196
|
+
): AIProviderDiagnostic[] {
|
|
197
|
+
const layer = mergeLayerOf(scope);
|
|
198
|
+
const lower = scope.below.map((file) => loadProviderRegistryFile(file, "user"));
|
|
199
|
+
const own = validateRegistry({ version: 1, providers }, { source: scope.file, layer });
|
|
200
|
+
const fileLayers: AIProviderRegistryLayer[] = [
|
|
201
|
+
...lower.map((file) => ({ layer: file.layer, providers: file.document.providers })),
|
|
202
|
+
{ layer, providers: own.document.providers },
|
|
203
|
+
];
|
|
204
|
+
const catalog: AIProviderRegistryPatch = {};
|
|
205
|
+
for (const [entryId, patch] of Object.entries(mergeRegistries(fileLayers).providers)) {
|
|
206
|
+
const preset = catalogPreset(patch.preset);
|
|
207
|
+
if (preset) catalog[entryId] = expandPreset(preset);
|
|
208
|
+
}
|
|
209
|
+
const merged = validateMergedRegistry(
|
|
210
|
+
mergeRegistries([...fileLayers, { layer: "catalog", providers: catalog }]),
|
|
211
|
+
{
|
|
212
|
+
sources: [
|
|
213
|
+
...lower.map((file) => ({ label: file.path, providers: file.document.providers })),
|
|
214
|
+
{ label: scope.file, providers: own.document.providers },
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
const path = `providers.${id}`;
|
|
219
|
+
return [...own.diagnostics, ...merged.diagnostics].filter(
|
|
220
|
+
(d) => d.severity === "error" && (d.path === path || d.path.startsWith(`${path}.`)),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Whether a stored entry holds only patch fields (or is absent) — nothing a workspace could not have inherited. */
|
|
225
|
+
function isPatchOrAbsent(entry: unknown): boolean {
|
|
226
|
+
if (entry === undefined) return true;
|
|
227
|
+
return (
|
|
228
|
+
isPlainObject(entry) &&
|
|
229
|
+
Object.keys(entry).every((key) => (PATCH_KEYS as readonly string[]).includes(key))
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Upsert entry `id` in the scope's document.
|
|
235
|
+
*
|
|
236
|
+
* - **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.
|
|
239
|
+
* - **Patch** — when a lower document defines `id` and the body holds only
|
|
240
|
+
* {@link PATCH_KEYS}, the body is merged into the scope's stored entry for
|
|
241
|
+
* `id` (created when absent), leaving every other stored field alone.
|
|
242
|
+
*
|
|
243
|
+
* The file and its directory are created when absent; other entries are kept
|
|
244
|
+
* as written.
|
|
245
|
+
*
|
|
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.
|
|
251
|
+
*/
|
|
252
|
+
export function upsertProviderEntry(
|
|
253
|
+
scope: ProviderWriteScope,
|
|
254
|
+
id: string,
|
|
255
|
+
body: unknown,
|
|
256
|
+
): { id: string; entry: Record<string, unknown> } {
|
|
257
|
+
if (!isSafeProviderId(id)) {
|
|
258
|
+
throw createError({
|
|
259
|
+
statusCode: 400,
|
|
260
|
+
statusMessage: `'${id}' is not a valid provider id - use [a-z0-9][a-z0-9_-]*`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
const fields = readEntryBody(body);
|
|
264
|
+
if (typeof fields === "string") throw createError({ statusCode: 400, statusMessage: fields });
|
|
265
|
+
|
|
266
|
+
const providers = editableLayer(scope);
|
|
267
|
+
const existing = providers[id];
|
|
268
|
+
const lowerEntries = scope.below.map((file) => readLayerLeniently(file)[id]);
|
|
269
|
+
const inherited = lowerEntries.some((entry) => entry !== undefined);
|
|
270
|
+
|
|
271
|
+
if ([...lowerEntries, existing].some((entry) => isPlainObject(entry) && entry.locked === true)) {
|
|
272
|
+
throw createError({
|
|
273
|
+
statusCode: 403,
|
|
274
|
+
statusMessage: `Provider '${id}' is locked by the control plane`,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const fieldKeys = Object.keys(fields);
|
|
279
|
+
const isPatch =
|
|
280
|
+
inherited &&
|
|
281
|
+
fieldKeys.length > 0 &&
|
|
282
|
+
fieldKeys.every((key) => (PATCH_KEYS as readonly string[]).includes(key));
|
|
283
|
+
if (inherited && !isPatch && isPatchOrAbsent(existing)) {
|
|
284
|
+
throw createError({ statusCode: 400, statusMessage: PATCH_REFUSAL });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
let entry: Record<string, unknown>;
|
|
288
|
+
if (isPatch) {
|
|
289
|
+
entry = { ...(isPlainObject(existing) ? existing : {}), ...fields };
|
|
290
|
+
} else {
|
|
291
|
+
const kept = isPlainObject(existing)
|
|
292
|
+
? Object.fromEntries(
|
|
293
|
+
Object.entries(existing).filter(([key]) => !Object.hasOwn(BODY_KEYS, key)),
|
|
294
|
+
)
|
|
295
|
+
: {};
|
|
296
|
+
entry = { ...kept, ...fields };
|
|
297
|
+
}
|
|
298
|
+
const next = { ...providers, [id]: entry };
|
|
299
|
+
|
|
300
|
+
const refusal = entryPolicyRefusal(entry as unknown as AIProviderEntry, scope.policy);
|
|
301
|
+
if (refusal) throw createError({ statusCode: 400, statusMessage: refusal });
|
|
302
|
+
|
|
303
|
+
const diagnostics = entryErrors(scope, next, id);
|
|
304
|
+
if (diagnostics.length > 0) {
|
|
305
|
+
throw createError({
|
|
306
|
+
statusCode: 400,
|
|
307
|
+
statusMessage: `Invalid provider entry '${id}': ${diagnostics.map((d) => d.path).join(", ")}`,
|
|
308
|
+
data: { diagnostics },
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
writeProviderLayer(scope.file, next);
|
|
312
|
+
return { id, entry };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Remove entry `id` from the scope's document; 404 when that document does
|
|
317
|
+
* not define it. On a patched entry that deletes the patch and the inherited
|
|
318
|
+
* entry shows through again.
|
|
319
|
+
*/
|
|
320
|
+
export function removeProviderEntry(scope: ProviderWriteScope, id: string): { id: string } {
|
|
321
|
+
const providers = editableLayer(scope);
|
|
322
|
+
if (!Object.hasOwn(providers, id)) {
|
|
323
|
+
throw createError({
|
|
324
|
+
statusCode: 404,
|
|
325
|
+
statusMessage: `No provider '${id}' in the ${scope.name} registry`,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
const { [id]: _removed, ...rest } = providers;
|
|
329
|
+
writeProviderLayer(scope.file, rest);
|
|
330
|
+
return { id };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** A catalog preset as the settings modal lists it. */
|
|
334
|
+
export interface ProviderPresetSummary {
|
|
335
|
+
id: string;
|
|
336
|
+
label: string;
|
|
337
|
+
kind: AIProviderPreset["kind"];
|
|
338
|
+
surface?: AIProviderPreset["surface"];
|
|
339
|
+
base_url?: string;
|
|
340
|
+
base_url_env?: string;
|
|
341
|
+
credential: { kind: AIProviderPreset["credential"]["kind"]; env?: string };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Catalog presets (all of them by default), in catalog order. */
|
|
345
|
+
export function providerPresetSummaries(
|
|
346
|
+
presets: AIProviderPreset[] = Object.values(AI_PROVIDER_PRESETS),
|
|
347
|
+
): ProviderPresetSummary[] {
|
|
348
|
+
return presets.map((preset) => ({
|
|
349
|
+
id: preset.id,
|
|
350
|
+
label: preset.label,
|
|
351
|
+
kind: preset.kind,
|
|
352
|
+
surface: preset.surface,
|
|
353
|
+
base_url: preset.base_url,
|
|
354
|
+
base_url_env: preset.base_url_env,
|
|
355
|
+
credential: { kind: preset.credential.kind, env: preset.credential.env },
|
|
356
|
+
}));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Presets a scope could configure right now: their `credential.env` is set
|
|
361
|
+
* in `env`, and no entry of `registry` is the preset's id or names it as its
|
|
362
|
+
* `preset`.
|
|
363
|
+
*
|
|
364
|
+
* @param env - The scope's layered environment.
|
|
365
|
+
* @param registry - The scope's merged registry.
|
|
366
|
+
*/
|
|
367
|
+
export function detectedProviderPresets(
|
|
368
|
+
env: Record<string, string | undefined>,
|
|
369
|
+
registry: AIProviderRegistry,
|
|
370
|
+
): ProviderPresetSummary[] {
|
|
371
|
+
const used = new Set(Object.keys(registry));
|
|
372
|
+
for (const entry of Object.values(registry)) {
|
|
373
|
+
if (entry.preset !== undefined) used.add(entry.preset);
|
|
374
|
+
}
|
|
375
|
+
return providerPresetSummaries(
|
|
376
|
+
Object.values(AI_PROVIDER_PRESETS).filter((preset) => {
|
|
377
|
+
const name = preset.credential.env;
|
|
378
|
+
return name !== undefined && Boolean(env[name]?.trim()) && !used.has(preset.id);
|
|
379
|
+
}),
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Which registry document holds a provider entry the scope lists. */
|
|
384
|
+
export type ProviderLayerName = ProviderWriteScope["name"];
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* 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.
|
|
390
|
+
*/
|
|
391
|
+
export interface ProviderLayerFields {
|
|
392
|
+
label?: unknown;
|
|
393
|
+
headers?: unknown;
|
|
394
|
+
model_map?: unknown;
|
|
395
|
+
enabled?: unknown;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const LAYER_FIELD_KEYS = ["label", "headers", "model_map", "enabled"] as const;
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Tag each provider view of a scope with every registry document that holds
|
|
402
|
+
* its id — `layers`, lowest first: `"instance"` for each lower document, then
|
|
403
|
+
* the scope's own name when its own document holds it. An id no document
|
|
404
|
+
* holds (not expected) is attributed to the lowest layer of the scope.
|
|
405
|
+
* `layerFields` are the {@link ProviderLayerFields} of the scope's own entry,
|
|
406
|
+
* or of the lowest document holding the id when the scope has none.
|
|
407
|
+
*/
|
|
408
|
+
export function withProviderLayers<T extends { id: string }>(
|
|
409
|
+
views: T[],
|
|
410
|
+
scope: ProviderWriteScope,
|
|
411
|
+
): Array<T & { layers: ProviderLayerName[]; layerFields: ProviderLayerFields }> {
|
|
412
|
+
const own = readLayerLeniently(scope.file);
|
|
413
|
+
const lower = scope.below.map((file) => readLayerLeniently(file));
|
|
414
|
+
return views.map((view) => {
|
|
415
|
+
const layers = lower
|
|
416
|
+
.filter((doc) => Object.hasOwn(doc, view.id))
|
|
417
|
+
.map((): ProviderLayerName => "instance");
|
|
418
|
+
if (Object.hasOwn(own, view.id)) layers.push(scope.name);
|
|
419
|
+
const deduped = [...new Set(layers)];
|
|
420
|
+
if (deduped.length === 0) deduped.push(scope.below.length > 0 ? "instance" : scope.name);
|
|
421
|
+
|
|
422
|
+
const entry = Object.hasOwn(own, view.id)
|
|
423
|
+
? own[view.id]
|
|
424
|
+
: lower.find((doc) => Object.hasOwn(doc, view.id))?.[view.id];
|
|
425
|
+
const layerFields: ProviderLayerFields = {};
|
|
426
|
+
if (isPlainObject(entry)) {
|
|
427
|
+
for (const key of LAYER_FIELD_KEYS) {
|
|
428
|
+
if (entry[key] !== undefined) layerFields[key] = entry[key];
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return { ...view, layers: deduped, layerFields };
|
|
432
|
+
});
|
|
433
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings defaults and the legacy harness-key migration.
|
|
3
|
+
*
|
|
4
|
+
* Storage stays with the host: this module reads and writes nothing except the
|
|
5
|
+
* one `settings.json` {@link migrateLegacyHarnessSettings} is pointed at.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { SETTINGS_DEFAULTS as CORE_DEFAULTS } from "@skaile/workspaces/sdk/core";
|
|
10
|
+
import type { SkaileSettings } from "../shared/settings-types";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* App-level defaults merged on top of core defaults.
|
|
14
|
+
*
|
|
15
|
+
* No voice defaults live here. `sttProvider`/`ttsProvider` default to the first
|
|
16
|
+
* enabled entry of that kind in registry order (see `resolveVoiceProvider`),
|
|
17
|
+
* and the language and voice id default to the chosen entry's `params`.
|
|
18
|
+
*/
|
|
19
|
+
const APP_DEFAULTS: Partial<SkaileSettings> = {
|
|
20
|
+
domains: {
|
|
21
|
+
enabled: [
|
|
22
|
+
"skaileup-conceptualization",
|
|
23
|
+
"skaileup-implementation",
|
|
24
|
+
"skaileup-evaluate",
|
|
25
|
+
"dev-security",
|
|
26
|
+
],
|
|
27
|
+
default: "skaileup-conceptualization",
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Merged default settings for forge apps.
|
|
33
|
+
*
|
|
34
|
+
* Combines core defaults with the app-specific domain defaults. Used as the
|
|
35
|
+
* lowest-priority layer during settings resolution.
|
|
36
|
+
*/
|
|
37
|
+
export const SETTINGS_DEFAULTS: SkaileSettings = { ...CORE_DEFAULTS, ...APP_DEFAULTS };
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Harness settings keys renamed, old name → new name. `driver` → `harness` is
|
|
41
|
+
* the `@skaile/workspaces` 6 rename (read as an alias for one major there too);
|
|
42
|
+
* the rest were renamed in forge-common 6.0.0.
|
|
43
|
+
*/
|
|
44
|
+
const LEGACY_HARNESS_KEYS: Readonly<Record<string, string>> = {
|
|
45
|
+
driver: "harness",
|
|
46
|
+
driverModels: "harnessModels",
|
|
47
|
+
driverProviders: "harnessProviders",
|
|
48
|
+
enabledBackends: "enabledHarnesses",
|
|
49
|
+
lockBackend: "lockHarness",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Rewrite one `settings.json` from the legacy harness keys to the current
|
|
54
|
+
* ones. Each legacy key is moved to its new name and removed; when the new key
|
|
55
|
+
* already exists it wins and the legacy value is dropped. A file without legacy
|
|
56
|
+
* keys (or one that is missing / not a JSON object) is left byte-identical.
|
|
57
|
+
*
|
|
58
|
+
* @param settingsPath - Absolute path to a `settings.json`.
|
|
59
|
+
* @returns The legacy keys that were rewritten (empty when nothing changed).
|
|
60
|
+
*/
|
|
61
|
+
export function migrateLegacyHarnessSettings(settingsPath: string): { mapped: string[] } {
|
|
62
|
+
const mapped: string[] = [];
|
|
63
|
+
if (!existsSync(settingsPath)) return { mapped };
|
|
64
|
+
|
|
65
|
+
let settings: unknown;
|
|
66
|
+
try {
|
|
67
|
+
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
68
|
+
} catch {
|
|
69
|
+
return { mapped };
|
|
70
|
+
}
|
|
71
|
+
if (typeof settings !== "object" || settings === null || Array.isArray(settings)) {
|
|
72
|
+
return { mapped };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const doc = settings as Record<string, unknown>;
|
|
76
|
+
for (const [legacy, current] of Object.entries(LEGACY_HARNESS_KEYS)) {
|
|
77
|
+
if (!(legacy in doc)) continue;
|
|
78
|
+
if (!(current in doc)) doc[current] = doc[legacy];
|
|
79
|
+
delete doc[legacy];
|
|
80
|
+
mapped.push(legacy);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (mapped.length > 0) {
|
|
84
|
+
writeFileSync(settingsPath, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
|
|
85
|
+
}
|
|
86
|
+
return { mapped };
|
|
87
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment-layer vocabulary shared by the server helpers
|
|
3
|
+
* (`server/utils/env-layers.ts`) and the settings modal's Providers tab.
|
|
4
|
+
*
|
|
5
|
+
* Browser-safe on purpose — no `node:` imports — so the client refuses the
|
|
6
|
+
* same names the server refuses, from one source.
|
|
7
|
+
*
|
|
8
|
+
* Layers, lowest to highest precedence: `process` (boot env), `instance`
|
|
9
|
+
* (`$FORGE_HOME/.env`), `workspace` (`<workspace root>/.env`). The nearest
|
|
10
|
+
* layer defining a non-empty value wins.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** One source of environment variables. */
|
|
14
|
+
export type EnvLayerId = "process" | "instance" | "workspace";
|
|
15
|
+
|
|
16
|
+
/** Layer precedence, lowest first. */
|
|
17
|
+
export const ENV_LAYER_ORDER: readonly EnvLayerId[] = ["process", "instance", "workspace"];
|
|
18
|
+
|
|
19
|
+
/** Names an editable `.env` layer accepts. */
|
|
20
|
+
export const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
|
21
|
+
|
|
22
|
+
/** Names no settings UI may write: they steer the process or forge itself. */
|
|
23
|
+
export const ENV_VAR_DENYLIST: readonly string[] = [
|
|
24
|
+
"PATH",
|
|
25
|
+
"HOME",
|
|
26
|
+
"NODE_OPTIONS",
|
|
27
|
+
"LD_PRELOAD",
|
|
28
|
+
"DYLD_INSERT_LIBRARIES",
|
|
29
|
+
"FORGE_HOME",
|
|
30
|
+
"FORGE_CREDENTIAL_KEY",
|
|
31
|
+
"FORGE_CREDENTIAL_STORE",
|
|
32
|
+
"SKAILE_HOME",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/** Refusal text for a name an editable layer must not hold, or `null` when it is fine. */
|
|
36
|
+
export function validateEnvVarName(name: string): string | null {
|
|
37
|
+
if (!ENV_VAR_NAME_PATTERN.test(name)) {
|
|
38
|
+
return `Invalid variable name "${name}": use upper-case letters, digits and underscores, not starting with a digit`;
|
|
39
|
+
}
|
|
40
|
+
if (ENV_VAR_DENYLIST.includes(name)) {
|
|
41
|
+
return `"${name}" cannot be set from settings`;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One variable as the browser sees it — never the value. */
|
|
47
|
+
export interface EnvVarView {
|
|
48
|
+
name: string;
|
|
49
|
+
/** Last 4 characters when the value has at least 8, else `""`. */
|
|
50
|
+
hint: string;
|
|
51
|
+
length: number;
|
|
52
|
+
/** Layer whose value wins. */
|
|
53
|
+
source: EnvLayerId;
|
|
54
|
+
/** Lower layers that also define the name (their value is shadowed). */
|
|
55
|
+
shadows: EnvLayerId[];
|
|
56
|
+
/** Registry entry ids whose credential references `env:<name>`. */
|
|
57
|
+
referencedBy: string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A registry `env:` reference no layer defines. */
|
|
61
|
+
export interface EnvSettingsMissing {
|
|
62
|
+
name: string;
|
|
63
|
+
referencedBy: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Response of `GET`/`PUT <environmentUrl>`. */
|
|
67
|
+
export interface EnvSettingsDocument {
|
|
68
|
+
scope: "instance" | "workspace";
|
|
69
|
+
/** Whether the caller may `PUT` to this document's layer. */
|
|
70
|
+
editable: boolean;
|
|
71
|
+
/** The layer this document edits. */
|
|
72
|
+
layer: "instance" | "workspace";
|
|
73
|
+
/** Sorted by name. */
|
|
74
|
+
vars: EnvVarView[];
|
|
75
|
+
missing: EnvSettingsMissing[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Request body of `PUT <environmentUrl>`. */
|
|
79
|
+
export interface EnvSettingsUpdate {
|
|
80
|
+
set: Record<string, string>;
|
|
81
|
+
unset: string[];
|
|
82
|
+
}
|