@skaile/forge-settings 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +94 -2
  4. package/dist/module.d.mts +17 -0
  5. package/dist/module.json +9 -0
  6. package/dist/module.mjs +23 -0
  7. package/dist/runtime/ai-sdk/index.d.ts +45 -0
  8. package/dist/runtime/ai-sdk/index.js +39 -0
  9. package/dist/runtime/components/ForgeProvidersPanel.d.vue.ts +21 -0
  10. package/dist/runtime/components/ForgeProvidersPanel.vue +435 -0
  11. package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +21 -0
  12. package/dist/runtime/editor/index.d.ts +58 -0
  13. package/dist/runtime/editor/index.js +83 -0
  14. package/dist/runtime/model/errors.d.ts +24 -0
  15. package/dist/runtime/model/errors.js +10 -0
  16. package/dist/runtime/server/credential-policy.d.ts +60 -0
  17. package/dist/runtime/server/credential-policy.js +64 -0
  18. package/dist/runtime/server/credential-store.d.ts +184 -0
  19. package/dist/runtime/server/credential-store.js +234 -0
  20. package/dist/runtime/server/env-layers.d.ts +62 -0
  21. package/dist/runtime/server/env-layers.js +185 -0
  22. package/dist/runtime/server/env.d.ts +30 -0
  23. package/dist/runtime/server/env.js +15 -0
  24. package/dist/runtime/server/harness-settings.d.ts +150 -0
  25. package/dist/runtime/server/harness-settings.js +166 -0
  26. package/dist/runtime/server/index.d.ts +23 -0
  27. package/dist/runtime/server/index.js +14 -0
  28. package/dist/runtime/server/provider-discovery.d.ts +73 -0
  29. package/dist/runtime/server/provider-discovery.js +77 -0
  30. package/dist/runtime/server/provider-layers.d.ts +27 -0
  31. package/dist/runtime/server/provider-layers.js +37 -0
  32. package/dist/runtime/server/provider-service.d.ts +126 -0
  33. package/dist/runtime/server/provider-service.js +87 -0
  34. package/dist/runtime/server/provider-settings.d.ts +78 -0
  35. package/dist/runtime/server/provider-settings.js +81 -0
  36. package/dist/runtime/server/provider-write.d.ts +154 -0
  37. package/dist/runtime/server/provider-write.js +223 -0
  38. package/dist/runtime/server/settings-defaults.d.ts +26 -0
  39. package/dist/runtime/server/settings-defaults.js +46 -0
  40. package/dist/runtime/shared/env-vars.d.ts +55 -0
  41. package/dist/runtime/shared/env-vars.js +22 -0
  42. package/dist/runtime/shared/harness-providers.d.ts +62 -0
  43. package/dist/runtime/shared/harness-providers.js +38 -0
  44. package/dist/runtime/shared/harnesses.d.ts +19 -0
  45. package/dist/runtime/shared/harnesses.js +15 -0
  46. package/dist/runtime/shared/provider-registry.d.ts +255 -0
  47. package/dist/runtime/shared/provider-registry.js +112 -0
  48. package/dist/runtime/shared/settings-types.d.ts +84 -0
  49. package/dist/runtime/shared/settings-types.js +0 -0
  50. package/dist/runtime/shims-vue.d.ts +8 -0
  51. package/dist/runtime/vue/index.d.ts +14 -0
  52. package/dist/runtime/vue/index.js +6 -0
  53. package/dist/runtime/vue/settings-modal-scope.d.ts +62 -0
  54. package/dist/runtime/vue/settings-modal-scope.js +2 -0
  55. package/dist/runtime/vue/useProvidersPanel.d.ts +232 -0
  56. package/dist/runtime/vue/useProvidersPanel.js +655 -0
  57. package/dist/types.d.mts +9 -0
  58. package/package.json +117 -4
  59. package/src/runtime/ai-sdk/index.ts +91 -0
  60. package/src/runtime/components/ForgeProvidersPanel.vue +449 -0
  61. package/src/runtime/editor/index.ts +157 -0
  62. package/src/runtime/model/errors.ts +31 -0
  63. package/src/runtime/server/credential-policy.ts +137 -0
  64. package/src/runtime/server/credential-store.ts +473 -0
  65. package/src/runtime/server/env-layers.ts +290 -0
  66. package/src/runtime/server/env.ts +46 -0
  67. package/src/runtime/server/harness-settings.ts +373 -0
  68. package/src/runtime/server/index.ts +24 -0
  69. package/src/runtime/server/provider-discovery.ts +164 -0
  70. package/src/runtime/server/provider-layers.ts +70 -0
  71. package/src/runtime/server/provider-service.ts +244 -0
  72. package/src/runtime/server/provider-settings.ts +192 -0
  73. package/src/runtime/server/provider-write.ts +438 -0
  74. package/src/runtime/server/settings-defaults.ts +87 -0
  75. package/src/runtime/shared/env-vars.ts +82 -0
  76. package/src/runtime/shared/harness-providers.ts +118 -0
  77. package/src/runtime/shared/harnesses.ts +34 -0
  78. package/src/runtime/shared/provider-registry.ts +391 -0
  79. package/src/runtime/shared/settings-types.ts +87 -0
  80. package/src/runtime/shims-vue.d.ts +8 -0
  81. package/src/runtime/vue/index.ts +15 -0
  82. package/src/runtime/vue/settings-modal-scope.ts +66 -0
  83. package/src/runtime/vue/useProvidersPanel.ts +1087 -0
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Harness settings — the one implementation both forge hosts share.
3
+ *
4
+ * A harness is a coding-agent product; `@skaile/workspaces` 6 names the adapter
5
+ * that runs it the same way (`listHarnesses()`), and the selecting settings key
6
+ * is `harness`.
7
+ *
8
+ * What lives here, so hosts only read and write their own storage:
9
+ *
10
+ * - {@link validateHarnessSettingsPatch} — the rule set for a harness settings
11
+ * write (`harness`, `harnessModels`, `harnessProviders`, `enabledHarnesses`,
12
+ * `lock*`), as a pure function over the request body and the stored state.
13
+ * - {@link resolveLockFlags} — the admin lock flags in force. Locks are
14
+ * `settings.json` fields only; there is no environment override.
15
+ * - {@link applyHarnessLayers} — which harness, model and provider pick a
16
+ * session starts with, given the settings layers the host read.
17
+ * - {@link enabledHarnessIds} / {@link buildHarnessRows} — the harness catalog
18
+ * `GET /api/agents` serves.
19
+ *
20
+ * Pure functions over plain values: no Node built-ins, no environment, no file access.
21
+ */
22
+
23
+ import type { HarnessInfo } from "@skaile/workspaces/types";
24
+ import {
25
+ DEFAULT_ENABLED_HARNESSES,
26
+ HARNESS_LABELS,
27
+ isExperimentalHarness,
28
+ } from "../shared/harnesses";
29
+ import type { SkaileSettings } from "../shared/settings-types";
30
+
31
+ /** The slice of {@link SkaileSettings} that selects, configures and locks harnesses. */
32
+ export type HarnessSettings = Pick<
33
+ SkaileSettings,
34
+ | "harness"
35
+ | "harnessModels"
36
+ | "harnessProviders"
37
+ | "enabledHarnesses"
38
+ | "lockHarness"
39
+ | "lockProvider"
40
+ | "lockModel"
41
+ | "lockAudio"
42
+ >;
43
+
44
+ /** One lock flag and where its value comes from. */
45
+ export type LockFlagStatus = { value: boolean; source: "settings" | "default" };
46
+
47
+ /** The admin lock flags in force on an instance. */
48
+ export type LockFlags = {
49
+ harness: LockFlagStatus;
50
+ provider: LockFlagStatus;
51
+ model: LockFlagStatus;
52
+ audio: LockFlagStatus;
53
+ };
54
+
55
+ /** Stored `lock*` setting → the {@link LockFlags} member it controls. */
56
+ const LOCK_FIELDS = [
57
+ ["lockHarness", "harness"],
58
+ ["lockProvider", "provider"],
59
+ ["lockModel", "model"],
60
+ ["lockAudio", "audio"],
61
+ ] as const satisfies ReadonlyArray<readonly [keyof HarnessSettings, keyof LockFlags]>;
62
+
63
+ /**
64
+ * The lock flags a stored settings document carries: a boolean `lock*` field is
65
+ * `{ value, source: "settings" }`, anything else `{ value: false, source:
66
+ * "default" }`. Pass the instance settings (locks describe the instance, not a
67
+ * workspace or a user).
68
+ */
69
+ export function resolveLockFlags(stored: Partial<HarnessSettings> | null | undefined): LockFlags {
70
+ const flag = (value: unknown): LockFlagStatus =>
71
+ typeof value === "boolean"
72
+ ? { value, source: "settings" }
73
+ : { value: false, source: "default" };
74
+ return {
75
+ harness: flag(stored?.lockHarness),
76
+ provider: flag(stored?.lockProvider),
77
+ model: flag(stored?.lockModel),
78
+ audio: flag(stored?.lockAudio),
79
+ };
80
+ }
81
+
82
+ /**
83
+ * The harness ids this instance offers: its `enabledHarnesses`, else
84
+ * {@link DEFAULT_ENABLED_HARNESSES}. A missing or empty list means the default
85
+ * set.
86
+ */
87
+ export function enabledHarnessIds(s: Pick<HarnessSettings, "enabledHarnesses">): string[] {
88
+ return s.enabledHarnesses?.length ? s.enabledHarnesses : [...DEFAULT_ENABLED_HARNESSES];
89
+ }
90
+
91
+ /** A `GET /api/agents` row: the harness's own info plus how this instance presents it. */
92
+ export type HarnessRow = HarnessInfo & {
93
+ /** Display label ({@link HARNESS_LABELS}, else the harness's own `name`). */
94
+ label: string;
95
+ /** Outside {@link DEFAULT_ENABLED_HARNESSES}: opt-in only. */
96
+ experimental: boolean;
97
+ /** Whether the instance's `enabledHarnesses` offers it. */
98
+ enabled: boolean;
99
+ };
100
+
101
+ /**
102
+ * The rows of `GET /api/agents`: one per harness, in `harnesses` order. Hosts
103
+ * filter to `enabled` unless the request asked for all (`?all=1`).
104
+ * `surfaces` and `providerPlurality` ride along from {@link HarnessInfo} so the
105
+ * Harness tab can offer each harness only the providers it can use.
106
+ */
107
+ export function buildHarnessRows(
108
+ harnesses: HarnessInfo[],
109
+ s: Pick<HarnessSettings, "enabledHarnesses">,
110
+ ): HarnessRow[] {
111
+ const enabled = new Set(enabledHarnessIds(s));
112
+ return harnesses.map((info) => ({
113
+ ...info,
114
+ label: HARNESS_LABELS[info.id] ?? info.name,
115
+ experimental: isExperimentalHarness(info.id),
116
+ enabled: enabled.has(info.id),
117
+ }));
118
+ }
119
+
120
+ // ── validateHarnessSettingsPatch ────────────────────────────────────────────
121
+
122
+ const isObject = (value: unknown): value is Record<string, unknown> =>
123
+ typeof value === "object" && value !== null && !Array.isArray(value);
124
+
125
+ /** A non-empty string, else undefined. */
126
+ const nonEmpty = (value: unknown): string | undefined =>
127
+ typeof value === "string" && value ? value : undefined;
128
+
129
+ /** `"providerId/modelId"` with both halves non-empty. */
130
+ function isModelSelector(value: string): boolean {
131
+ const slash = value.indexOf("/");
132
+ return slash > 0 && slash < value.length - 1;
133
+ }
134
+
135
+ /** The non-empty string entries of an object value. */
136
+ function stringEntries(value: unknown): Record<string, string> {
137
+ const entries: Record<string, string> = {};
138
+ if (!isObject(value)) return entries;
139
+ for (const [id, entry] of Object.entries(value))
140
+ if (typeof entry === "string" && entry) entries[id] = entry;
141
+ return entries;
142
+ }
143
+
144
+ function sameEntries(a: Record<string, string>, b: Record<string, string>): boolean {
145
+ const keys = Object.keys(a);
146
+ return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]);
147
+ }
148
+
149
+ /** The non-empty, deduplicated id lists of an object value (the provider pick per harness). */
150
+ function idLists(value: unknown): Record<string, string[]> {
151
+ const lists: Record<string, string[]> = {};
152
+ if (!isObject(value)) return lists;
153
+ for (const [harnessId, ids] of Object.entries(value)) {
154
+ if (!Array.isArray(ids)) continue;
155
+ const kept = [
156
+ ...new Set(ids.filter((id): id is string => typeof id === "string" && id !== "")),
157
+ ];
158
+ if (kept.length) lists[harnessId] = kept;
159
+ }
160
+ return lists;
161
+ }
162
+
163
+ function sameIdLists(a: Record<string, string[]>, b: Record<string, string[]>): boolean {
164
+ const keys = Object.keys(a);
165
+ return (
166
+ keys.length === Object.keys(b).length &&
167
+ keys.every(
168
+ (key) =>
169
+ b[key] !== undefined &&
170
+ a[key]!.length === b[key].length &&
171
+ a[key]!.every((id, i) => id === b[key]![i]),
172
+ )
173
+ );
174
+ }
175
+
176
+ /** What {@link validateHarnessSettingsPatch} hands back for a valid body. */
177
+ export type HarnessSettingsPatch = {
178
+ /**
179
+ * The fields to write; a key whose value is `undefined` is removed from the
180
+ * stored document. Only fields that carry something to write appear.
181
+ */
182
+ patch: Partial<HarnessSettings>;
183
+ /**
184
+ * Whether the effective `harness`, `harnessModels`, `harnessProviders` or
185
+ * `enabledHarnesses` changed, i.e. whether running sessions now run on stale
186
+ * defaults and the host should stop them.
187
+ */
188
+ sessionAffecting: boolean;
189
+ };
190
+
191
+ /**
192
+ * Validate the harness part of a settings write and compute what to store.
193
+ * Keys outside the harness set are ignored — the host validates its own.
194
+ *
195
+ * - `model`: refused — the model is set per harness (`harnessModels`).
196
+ * - `harness`: a catalog harness id that is enabled; empty/null removes it
197
+ * (→ `"omp"`). The body key `driver` (the pre-6.0 spelling) is an alias for
198
+ * one major: it is validated the same way and stored as `harness`. When both
199
+ * keys are present `harness` wins.
200
+ * - `harnessModels`: patched per harness id (catalog ids only); a value is a
201
+ * `providerId/modelId` selector, an empty string removes that harness's entry.
202
+ * - `harnessProviders`: the registry entry ids picked per harness id. The modal
203
+ * sends the whole map, so it replaces the stored one: each value must be an
204
+ * array of strings (empty strings are dropped, duplicates collapse, order is
205
+ * kept), a harness whose array ends up empty is removed from the map, and the
206
+ * key appears in `patch` only when the map changed. Ids are not checked
207
+ * against the registry — a pick that is no longer eligible is shown as stale
208
+ * in the modal.
209
+ * - `enabledHarnesses`: catalog ids, always including the resulting `harness`.
210
+ * - `lockHarness` / `lockProvider` / `lockModel` / `lockAudio`: booleans,
211
+ * written only when they differ from `ctx.locks`.
212
+ *
213
+ * `ctx.locks` is used only to skip no-op lock writes; it is **not** enforced
214
+ * here. Whether the caller may write at all (admin, instance scope) and
215
+ * whether a locked field may be overridden is the host's decision, made before
216
+ * this runs.
217
+ *
218
+ * @param body the request body, untrusted.
219
+ * @param ctx.catalog valid harness ids (`listHarnesses().map(d => d.id)`).
220
+ * @param ctx.current the stored settings the patch applies to (the instance file).
221
+ * @param ctx.locks the lock flags in force ({@link resolveLockFlags} of `current`).
222
+ * @returns the patch, or the error message of the first violated rule (a
223
+ * string, mirroring `validateEnvSettingsUpdate`; hosts answer 400 with it).
224
+ */
225
+ export function validateHarnessSettingsPatch(
226
+ body: unknown,
227
+ ctx: { catalog: Iterable<string>; current: HarnessSettings; locks: LockFlags },
228
+ ): HarnessSettingsPatch | string {
229
+ if (!isObject(body)) return "body must be an object";
230
+ if ("model" in body) return "model is set per harness - use harnessModels";
231
+
232
+ const { current, locks } = ctx;
233
+ const catalog = new Set(ctx.catalog);
234
+ const patch: Partial<HarnessSettings> = {};
235
+ let sessionAffecting = false;
236
+
237
+ const currentHarness = nonEmpty(current.harness) ?? "omp";
238
+ let nextHarness = currentHarness;
239
+ const harnessKey = "harness" in body ? "harness" : "driver" in body ? "driver" : undefined;
240
+ if (harnessKey) {
241
+ const raw = body[harnessKey];
242
+ if (raw !== null && raw !== undefined && typeof raw !== "string")
243
+ return `${harnessKey} must be a string`;
244
+ const value = typeof raw === "string" ? raw.trim() : "";
245
+ if (value && !catalog.has(value)) return `Unknown harness "${value}"`;
246
+ patch.harness = value || undefined;
247
+ nextHarness = value || "omp";
248
+ if (nextHarness !== currentHarness) sessionAffecting = true;
249
+ }
250
+
251
+ if ("harnessModels" in body) {
252
+ const raw = body.harnessModels;
253
+ if (!isObject(raw)) return "harnessModels must be an object";
254
+ const currentModels = stringEntries(current.harnessModels);
255
+ const nextModels = { ...currentModels };
256
+ for (const [id, entry] of Object.entries(raw)) {
257
+ if (!catalog.has(id)) return `harnessModels: unknown harness "${id}"`;
258
+ if (typeof entry !== "string") return `harnessModels.${id} must be a string`;
259
+ const value = entry.trim();
260
+ if (!value) {
261
+ delete nextModels[id];
262
+ continue;
263
+ }
264
+ if (!isModelSelector(value))
265
+ return `harnessModels.${id} must be a "providerId/modelId" selector`;
266
+ nextModels[id] = value;
267
+ }
268
+ patch.harnessModels = Object.keys(nextModels).length ? nextModels : undefined;
269
+ if (!sameEntries(currentModels, nextModels)) sessionAffecting = true;
270
+ }
271
+
272
+ if ("harnessProviders" in body) {
273
+ const raw = body.harnessProviders;
274
+ if (!isObject(raw)) return "harnessProviders must be an object";
275
+ for (const [id, ids] of Object.entries(raw)) {
276
+ if (!Array.isArray(ids) || !ids.every((entry) => typeof entry === "string")) {
277
+ return `harnessProviders.${id} must be an array of provider ids`;
278
+ }
279
+ }
280
+ const next = idLists(raw);
281
+ if (!sameIdLists(idLists(current.harnessProviders), next)) {
282
+ patch.harnessProviders = Object.keys(next).length ? next : undefined;
283
+ sessionAffecting = true;
284
+ }
285
+ }
286
+
287
+ if ("enabledHarnesses" in body) {
288
+ const raw = body.enabledHarnesses;
289
+ if (!Array.isArray(raw) || !raw.every((id) => typeof id === "string")) {
290
+ return "enabledHarnesses must be an array of harness ids";
291
+ }
292
+ const unknownId = raw.find((id) => !catalog.has(id));
293
+ if (unknownId !== undefined) return `enabledHarnesses: unknown harness "${unknownId}"`;
294
+ const enabled = [...new Set(raw as string[])];
295
+ if (!enabled.includes(nextHarness))
296
+ return `enabledHarnesses must include the active harness "${nextHarness}"`;
297
+ patch.enabledHarnesses = enabled;
298
+ const before = enabledHarnessIds(current);
299
+ if (enabled.length !== before.length || enabled.some((id) => !before.includes(id)))
300
+ sessionAffecting = true;
301
+ } else if (harnessKey && !enabledHarnessIds(current).includes(nextHarness)) {
302
+ return `Harness "${nextHarness}" is not enabled`;
303
+ }
304
+
305
+ for (const [field, lock] of LOCK_FIELDS) {
306
+ const wanted = body[field];
307
+ if (wanted === undefined) continue;
308
+ if (typeof wanted !== "boolean") return `${field} must be a boolean`;
309
+ if (wanted !== locks[lock].value) patch[field] = wanted;
310
+ }
311
+
312
+ return { patch, sessionAffecting };
313
+ }
314
+
315
+ // ── applyHarnessLayers ──────────────────────────────────────────────────────
316
+
317
+ /** One settings layer's say in which harness, model and provider pick a session gets. */
318
+ export type HarnessLayer = {
319
+ /** The harness id (the workspaces settings key `harness`). */
320
+ harness?: string;
321
+ /** An explicit model selector; wins over this layer's `harnessModels` entry. */
322
+ model?: string;
323
+ harnessModels?: Record<string, string>;
324
+ harnessProviders?: Record<string, string[]>;
325
+ };
326
+
327
+ /**
328
+ * Resolve the harness, model and provider pick a session starts with.
329
+ *
330
+ * `layers[0]` is the instance layer; later layers (workspace file, workspace
331
+ * overrides, chat, …) are more specific and override it per field — the last
332
+ * layer that sets a field wins. A locked field (`locks.harness` → `harness`,
333
+ * `locks.model` → `model`, `locks.provider` → `providers`) ignores every later
334
+ * layer: layer 0's value is the answer, even when it is unset.
335
+ *
336
+ * "Sets" means a non-empty `harness`/`model` string or a non-empty provider
337
+ * array; `""` and `[]` inherit. A layer's model is `layer.model`, else
338
+ * `layer.harnessModels[harness]`; its providers are
339
+ * `layer.harnessProviders[harness]` — both keyed by the *resolved* harness, so
340
+ * a pick stored for another harness never applies.
341
+ *
342
+ * Fields no layer sets come back `undefined`; the host supplies its defaults.
343
+ */
344
+ export function applyHarnessLayers(
345
+ layers: HarnessLayer[],
346
+ locks: LockFlags,
347
+ ): { harness: string | undefined; model: string | undefined; providers: string[] | undefined } {
348
+ /** The layer-0 value when `locked`, else the last layer's non-undefined value. */
349
+ const pick = <T>(
350
+ locked: boolean,
351
+ read: (layer: HarnessLayer) => T | undefined,
352
+ ): T | undefined => {
353
+ if (locked) return layers[0] ? read(layers[0]) : undefined;
354
+ for (let i = layers.length - 1; i >= 0; i--) {
355
+ const value = read(layers[i]!);
356
+ if (value !== undefined) return value;
357
+ }
358
+ return undefined;
359
+ };
360
+
361
+ const harness = pick(locks.harness.value, (layer) => nonEmpty(layer.harness));
362
+ const model = pick(
363
+ locks.model.value,
364
+ (layer) =>
365
+ nonEmpty(layer.model) ??
366
+ (harness === undefined ? undefined : nonEmpty(layer.harnessModels?.[harness])),
367
+ );
368
+ const providers = pick(locks.provider.value, (layer) => {
369
+ const ids = harness === undefined ? undefined : layer.harnessProviders?.[harness];
370
+ return ids?.length ? ids : undefined;
371
+ });
372
+ return { harness, model, providers };
373
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `@skaile/forge-settings/server` — provider and settings operations for a
3
+ * host server: the registry vocabulary and layer editing, the credential
4
+ * store, env layers, harness settings, model discovery.
5
+ *
6
+ * Imports no Vue, no editor, no AI SDK and no agent session code. Storage,
7
+ * authentication and workspace permissions stay with the host.
8
+ */
9
+
10
+ export * from "../shared/harness-providers";
11
+ export * from "../shared/harnesses";
12
+ export * from "../shared/provider-registry";
13
+ export type { DomainConfig, SkaileSettings } from "../shared/settings-types";
14
+ export * from "./credential-policy";
15
+ export * from "./credential-store";
16
+ export * from "./env";
17
+ export * from "./env-layers";
18
+ export * from "./harness-settings";
19
+ export * from "./provider-discovery";
20
+ export * from "./provider-layers";
21
+ export * from "./provider-service";
22
+ export * from "./provider-settings";
23
+ export * from "./provider-write";
24
+ export * from "./settings-defaults";
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Live model discovery for one registry entry.
3
+ *
4
+ * `listAIProviderModels` (provider-service) answers from the registry's own
5
+ * `model_map`; this module asks the provider's REST API. The two are different
6
+ * facts and the result says which one it is:
7
+ *
8
+ * - `live` — the provider answered; `models` is its list.
9
+ * - `static` — the live request failed or the entry has no key, and `models` is
10
+ * the offline catalog of its preset. It is a fallback, never a live answer.
11
+ * - `unsupported` — the entry has no catalog preset, so there is nothing to ask;
12
+ * a host lets the admin type a model id.
13
+ * - `error` — nothing usable. The message is scrubbed.
14
+ *
15
+ * The error text is built here, never forwarded: a remote response body is
16
+ * never part of it, and every secret this call resolved (or the host names in
17
+ * `redact`) is removed from whatever text remains before it leaves the module.
18
+ *
19
+ * The credential policy (see `credential-policy.ts`) is applied to the entry
20
+ * before its credential is resolved, and `env:` references read from the
21
+ * explicit `env` only.
22
+ */
23
+
24
+ import { resolveEntryBaseUrl } from "@skaile/workspaces/core";
25
+ import { fetchProviderModels, getStaticModels } from "@skaile/workspaces/sdk/bridge";
26
+ import type { AIProviderRegistryView } from "../shared/provider-registry";
27
+ import { isEntryEnabled, registryEntries } from "../shared/provider-registry";
28
+ import { type CredentialPolicy, entryPolicyRefusal } from "./credential-policy";
29
+ import { resolveEntryCredential } from "./credential-store";
30
+
31
+ /** One model a provider offers. */
32
+ export interface DiscoveredModel {
33
+ id: string;
34
+ name: string;
35
+ }
36
+
37
+ /** Outcome of {@link discoverProviderModels}. */
38
+ export type ModelDiscoveryResult =
39
+ | { status: "live"; providerId: string; models: DiscoveredModel[] }
40
+ | {
41
+ status: "static";
42
+ providerId: string;
43
+ models: DiscoveredModel[];
44
+ /** Why the live request did not produce the list. Scrubbed. */
45
+ reason: string;
46
+ }
47
+ | { status: "unsupported"; providerId: string; reason: string }
48
+ | { status: "error"; providerId: string; error: string };
49
+
50
+ /** Options for {@link discoverProviderModels}. */
51
+ export interface DiscoverProviderModelsOptions {
52
+ /** Environment `env:` references and base-URL variables are read from. Required: no ambient process. */
53
+ env: Record<string, string | undefined>;
54
+ /** Which credential references may be resolved. Absent: no restriction. */
55
+ policy?: CredentialPolicy;
56
+ /** Extra strings to remove from error text, besides the secrets this call resolves. */
57
+ redact?: readonly string[];
58
+ /** Local encrypted store for `keyring:` references. */
59
+ key?: string;
60
+ path?: string;
61
+ }
62
+
63
+ /** Longest error message returned. */
64
+ const MAX_ERROR_LENGTH = 200;
65
+ /** Shortest secret worth masking; shorter strings would mangle ordinary words. */
66
+ const MIN_SECRET_LENGTH = 4;
67
+
68
+ /**
69
+ * Remove every occurrence of each secret (verbatim, URL-encoded and
70
+ * base64-encoded) from `text`, then cap its length.
71
+ */
72
+ export function scrubSecrets(text: string, secrets: readonly (string | undefined)[]): string {
73
+ let out = text;
74
+ for (const secret of secrets) {
75
+ if (!secret || secret.length < MIN_SECRET_LENGTH) continue;
76
+ const forms = new Set([
77
+ secret,
78
+ encodeURIComponent(secret),
79
+ Buffer.from(secret, "utf8").toString("base64"),
80
+ ]);
81
+ for (const form of forms) out = out.split(form).join("***");
82
+ }
83
+ return out.length > MAX_ERROR_LENGTH ? `${out.slice(0, MAX_ERROR_LENGTH)}…` : out;
84
+ }
85
+
86
+ /**
87
+ * Ask one registry entry's provider for its models.
88
+ *
89
+ * Never throws for a provider-side failure: that is the `error` / `static`
90
+ * result. A host maps `error` to its own status.
91
+ */
92
+ export async function discoverProviderModels(
93
+ providerId: string,
94
+ registry: AIProviderRegistryView,
95
+ options: DiscoverProviderModelsOptions,
96
+ ): Promise<ModelDiscoveryResult> {
97
+ const match = registryEntries(registry).find(([id]) => id === providerId);
98
+ if (!match)
99
+ return { status: "error", providerId, error: `'${providerId}' is not in this registry` };
100
+ const entry = match[1];
101
+ if (entry.kind !== "llm") {
102
+ return { status: "unsupported", providerId, reason: "only llm entries list models" };
103
+ }
104
+ if (!isEntryEnabled(entry)) return { status: "error", providerId, error: "entry is disabled" };
105
+
106
+ // The policy decides before anything is resolved or requested.
107
+ const refusal = options.policy ? entryPolicyRefusal(entry, options.policy) : undefined;
108
+ if (refusal) return { status: "error", providerId, error: refusal };
109
+
110
+ const credential = resolveEntryCredential(entry, {
111
+ env: options.env,
112
+ ...(options.policy ? { policy: options.policy } : {}),
113
+ ...(options.key ? { key: options.key } : {}),
114
+ ...(options.path ? { path: options.path } : {}),
115
+ });
116
+ const secrets = [credential.value, ...(options.redact ?? [])];
117
+ const scrub = (text: string) => scrubSecrets(text, secrets);
118
+
119
+ if (!entry.preset) {
120
+ return {
121
+ status: "unsupported",
122
+ providerId,
123
+ reason:
124
+ "this entry names no catalog preset, so its models cannot be listed; enter a model id",
125
+ };
126
+ }
127
+ if (credential.error && !credential.value) {
128
+ return { status: "error", providerId, error: scrub(credential.error) };
129
+ }
130
+
131
+ const staticList = getStaticModels(entry.preset).map(({ id, name }) => ({ id, name }));
132
+ if (!credential.value) {
133
+ // Without a key there is nothing to ask with.
134
+ return staticList.length > 0
135
+ ? {
136
+ status: "static",
137
+ providerId,
138
+ models: staticList,
139
+ reason: "no credential to query the provider with",
140
+ }
141
+ : {
142
+ status: "unsupported",
143
+ providerId,
144
+ reason: "no credential, and no offline catalog for this preset",
145
+ };
146
+ }
147
+
148
+ const baseUrl = resolveEntryBaseUrl(entry);
149
+ const live = await fetchProviderModels(entry.preset, credential.value, {
150
+ ...(baseUrl ? { baseUrl } : {}),
151
+ env: options.env,
152
+ });
153
+ if (live.ok && live.models.length > 0) {
154
+ return {
155
+ status: "live",
156
+ providerId,
157
+ models: live.models.map(({ id, name }) => ({ id, name })),
158
+ };
159
+ }
160
+ const reason = scrub(live.error ?? "the provider returned no models");
161
+ return staticList.length > 0
162
+ ? { status: "static", providerId, models: staticList, reason }
163
+ : { status: "error", providerId, error: reason };
164
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Reading and writing one provider-registry layer document
3
+ * (`providers.yml` / `providers.yaml`) as written on disk.
4
+ *
5
+ * The upstream loader validates and drops entries it dislikes; an editor must
6
+ * not, or rewriting a file would delete a hand-written entry. These helpers
7
+ * therefore read the raw `providers` map and write it back unchanged apart
8
+ * from the entry being edited.
9
+ */
10
+
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import {
13
+ type AIProviderRegistryPatch,
14
+ PROVIDERS_FILENAME,
15
+ saveProviderRegistryFile,
16
+ } from "@skaile/workspaces/core";
17
+ import { AI_PROVIDER_PRESETS, type AIProviderPreset } from "@skaile/workspaces/sdk/bridge";
18
+ import { parse as parseYaml } from "yaml";
19
+
20
+ /** The `.yaml` spelling of {@link PROVIDERS_FILENAME}. */
21
+ const PROVIDERS_FILENAME_ALIAS = "providers.yaml";
22
+
23
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
24
+ return typeof value === "object" && value !== null && !Array.isArray(value);
25
+ }
26
+
27
+ /**
28
+ * The file a write to the layer at `path` goes to: the `.yaml` spelling when
29
+ * only that one exists, so an edit never shadows a hand-written document.
30
+ */
31
+ export function providerLayerDocument(path: string): string {
32
+ if (existsSync(path)) return path;
33
+ const alias = `${path.slice(0, -PROVIDERS_FILENAME.length)}${PROVIDERS_FILENAME_ALIAS}`;
34
+ return existsSync(alias) ? alias : path;
35
+ }
36
+
37
+ /** The catalog preset `id` names, if the catalog has one. */
38
+ export function catalogPreset(id: unknown): AIProviderPreset | undefined {
39
+ return typeof id === "string" && Object.hasOwn(AI_PROVIDER_PRESETS, id)
40
+ ? AI_PROVIDER_PRESETS[id]
41
+ : undefined;
42
+ }
43
+
44
+ /**
45
+ * A layer document's `providers` map exactly as written — entries the
46
+ * validator would reject included, so rewriting the file never drops a
47
+ * hand-written entry. A missing or empty file is an empty layer.
48
+ *
49
+ * @throws When the file is not YAML or not a registry document.
50
+ */
51
+ export function readProviderLayer(file: string): Record<string, unknown> {
52
+ if (!existsSync(file)) return {};
53
+ let document: unknown;
54
+ try {
55
+ document = parseYaml(readFileSync(file, "utf8"));
56
+ } catch (err) {
57
+ throw new Error(`${file} is not valid YAML (${(err as Error).message})`);
58
+ }
59
+ if (document === null || document === undefined) return {};
60
+ if (!isPlainObject(document)) throw new Error(`${file} does not contain a registry document`);
61
+ const providers = document.providers;
62
+ if (providers === undefined || providers === null) return {};
63
+ if (!isPlainObject(providers)) throw new Error(`${file}: 'providers' is not a map of entries`);
64
+ return providers;
65
+ }
66
+
67
+ /** Write a layer's `providers` map back in the shape the upstream CLI writes. */
68
+ export function writeProviderLayer(file: string, providers: Record<string, unknown>): void {
69
+ saveProviderRegistryFile(file, { version: 1, providers: providers as AIProviderRegistryPatch });
70
+ }