@skaile/forge-settings 1.3.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +52 -9
  3. package/dist/module.json +1 -1
  4. package/dist/runtime/ai-sdk/index.d.ts +33 -26
  5. package/dist/runtime/ai-sdk/index.js +34 -16
  6. package/dist/runtime/components/ForgeProvidersPanel.d.vue.ts +13 -0
  7. package/dist/runtime/components/ForgeProvidersPanel.vue +461 -78
  8. package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +13 -0
  9. package/dist/runtime/editor/index.d.ts +55 -16
  10. package/dist/runtime/editor/index.js +82 -40
  11. package/dist/runtime/model/selector.d.ts +16 -0
  12. package/dist/runtime/model/selector.js +12 -0
  13. package/dist/runtime/server/credential-store.d.ts +16 -4
  14. package/dist/runtime/server/credential-store.js +3 -4
  15. package/dist/runtime/server/env-layers.d.ts +1 -1
  16. package/dist/runtime/server/env-layers.js +3 -3
  17. package/dist/runtime/server/index.d.ts +9 -8
  18. package/dist/runtime/server/index.js +3 -3
  19. package/dist/runtime/server/provider-discovery.d.ts +28 -8
  20. package/dist/runtime/server/provider-discovery.js +57 -33
  21. package/dist/runtime/server/provider-layers.d.ts +1 -1
  22. package/dist/runtime/server/provider-layers.js +3 -5
  23. package/dist/runtime/server/provider-service.d.ts +26 -2
  24. package/dist/runtime/server/provider-service.js +16 -1
  25. package/dist/runtime/server/provider-write.d.ts +26 -10
  26. package/dist/runtime/server/provider-write.js +75 -7
  27. package/dist/runtime/server/voice-provider.d.ts +61 -0
  28. package/dist/runtime/server/voice-provider.js +31 -0
  29. package/dist/runtime/shared/data-zone.d.ts +68 -0
  30. package/dist/runtime/shared/data-zone.js +79 -0
  31. package/dist/runtime/shared/harness-providers.d.ts +42 -3
  32. package/dist/runtime/shared/harness-providers.js +31 -8
  33. package/dist/runtime/shared/harnesses.d.ts +7 -0
  34. package/dist/runtime/shared/harnesses.js +18 -0
  35. package/dist/runtime/shared/provider-accounts.d.ts +215 -0
  36. package/dist/runtime/shared/provider-accounts.js +255 -0
  37. package/dist/runtime/shared/provider-registry.d.ts +19 -182
  38. package/dist/runtime/shared/provider-registry.js +25 -65
  39. package/dist/runtime/vue/index.d.ts +2 -0
  40. package/dist/runtime/vue/index.js +2 -0
  41. package/dist/runtime/vue/providers-panel-model.d.ts +98 -5
  42. package/dist/runtime/vue/providers-panel-model.js +13 -1
  43. package/dist/runtime/vue/useProvidersPanel.d.ts +66 -5
  44. package/dist/runtime/vue/useProvidersPanel.js +417 -29
  45. package/dist/runtime/{server → workspaces}/harness-settings.d.ts +51 -13
  46. package/dist/runtime/{server → workspaces}/harness-settings.js +93 -9
  47. package/dist/runtime/workspaces/index.d.ts +16 -0
  48. package/dist/runtime/workspaces/index.js +5 -0
  49. package/dist/runtime/workspaces/language-model.d.ts +45 -0
  50. package/dist/runtime/workspaces/language-model.js +26 -0
  51. package/dist/runtime/workspaces/model-endpoint.d.ts +38 -0
  52. package/dist/runtime/workspaces/model-endpoint.js +54 -0
  53. package/dist/runtime/workspaces/provider-settings.d.ts +35 -0
  54. package/dist/runtime/{server → workspaces}/provider-settings.js +0 -31
  55. package/dist/runtime/{server → workspaces}/settings-defaults.d.ts +1 -1
  56. package/dist/runtime/{shared → workspaces}/settings-types.d.ts +11 -4
  57. package/package.json +11 -2
  58. package/src/runtime/ai-sdk/index.ts +88 -47
  59. package/src/runtime/components/ForgeProvidersPanel.vue +477 -78
  60. package/src/runtime/editor/index.ts +158 -63
  61. package/src/runtime/model/selector.ts +31 -0
  62. package/src/runtime/server/credential-store.ts +20 -10
  63. package/src/runtime/server/env-layers.ts +4 -4
  64. package/src/runtime/server/index.ts +9 -8
  65. package/src/runtime/server/provider-discovery.ts +114 -48
  66. package/src/runtime/server/provider-layers.ts +6 -6
  67. package/src/runtime/server/provider-service.ts +53 -1
  68. package/src/runtime/server/provider-write.ts +132 -18
  69. package/src/runtime/server/voice-provider.ts +110 -0
  70. package/src/runtime/shared/data-zone.ts +179 -0
  71. package/src/runtime/shared/harness-providers.ts +78 -7
  72. package/src/runtime/shared/harnesses.ts +25 -0
  73. package/src/runtime/shared/provider-accounts.ts +526 -0
  74. package/src/runtime/shared/provider-registry.ts +82 -256
  75. package/src/runtime/vue/index.ts +2 -0
  76. package/src/runtime/vue/providers-panel-model.ts +123 -5
  77. package/src/runtime/vue/useProvidersPanel.ts +589 -37
  78. package/src/runtime/{server → workspaces}/harness-settings.ts +166 -22
  79. package/src/runtime/workspaces/index.ts +17 -0
  80. package/src/runtime/workspaces/language-model.ts +82 -0
  81. package/src/runtime/workspaces/model-endpoint.ts +100 -0
  82. package/src/runtime/workspaces/provider-settings.ts +96 -0
  83. package/src/runtime/{server → workspaces}/settings-defaults.ts +1 -1
  84. package/src/runtime/{shared → workspaces}/settings-types.ts +11 -4
  85. package/dist/runtime/server/provider-settings.d.ts +0 -78
  86. package/src/runtime/server/provider-settings.ts +0 -192
  87. /package/dist/runtime/{server → workspaces}/settings-defaults.js +0 -0
  88. /package/dist/runtime/{shared → workspaces}/settings-types.js +0 -0
@@ -8,12 +8,13 @@
8
8
  * What lives here, so hosts only read and write their own storage:
9
9
  *
10
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.
11
+ * write (`harness`, `harnessModels`, `harnessRoles`, `harnessProviders`,
12
+ * `enabledHarnesses`, `lock*`), as a pure function over the request body and
13
+ * the stored state.
13
14
  * - {@link resolveLockFlags} — the admin lock flags in force. Locks are
14
15
  * `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.
16
+ * - {@link applyHarnessLayers} — which harness, model, roles and provider pick
17
+ * a session starts with, given the settings layers the host read.
17
18
  * - {@link enabledHarnessIds} / {@link buildHarnessRows} — the harness catalog
18
19
  * `GET /api/agents` serves.
19
20
  *
@@ -22,18 +23,26 @@
22
23
 
23
24
  import type { HarnessDetection } from "@skaile/workspaces/sdk/bridge";
24
25
  import type { HarnessInfo } from "@skaile/workspaces/types";
26
+ import {
27
+ type HarnessProviderBlock,
28
+ type HarnessProviderCandidate,
29
+ harnessProviderBlock,
30
+ } from "../shared/harness-providers";
25
31
  import {
26
32
  DEFAULT_ENABLED_HARNESSES,
27
33
  HARNESS_LABELS,
34
+ harnessRoleIds,
28
35
  isExperimentalHarness,
29
36
  } from "../shared/harnesses";
30
- import type { SkaileSettings } from "../shared/settings-types";
37
+ import type { AIProviderDataZoneRule } from "../shared/provider-registry";
38
+ import type { SkaileSettings } from "./settings-types";
31
39
 
32
40
  /** The slice of {@link SkaileSettings} that selects, configures and locks harnesses. */
33
41
  export type HarnessSettings = Pick<
34
42
  SkaileSettings,
35
43
  | "harness"
36
44
  | "harnessModels"
45
+ | "harnessRoles"
37
46
  | "harnessProviders"
38
47
  | "enabledHarnesses"
39
48
  | "lockHarness"
@@ -99,29 +108,65 @@ export type HarnessRow = HarnessInfo & {
99
108
  enabled: boolean;
100
109
  /** What `detectHarnesses()` found on this host: installed, version, login. */
101
110
  detection: HarnessDetection;
111
+ /** The role ids the harness takes ({@link harnessRoleIds}); empty for a one-model harness. */
112
+ roles: string[];
113
+ /** The role → model selector assignments of `s.harnessRoles` for this harness (pass effective settings). */
114
+ roleAssignments: Record<string, string>;
115
+ /**
116
+ * Registry entries on a surface the harness speaks that the host's
117
+ * data-zone rule keeps out, by entry id (reason `blocked-by-rule`, with the
118
+ * message a table shows on the box it cannot tick). Empty without
119
+ * `providers` or under the rule `any`.
120
+ */
121
+ blockedProviders: Record<string, HarnessProviderBlock>;
102
122
  };
103
123
 
124
+ /** The registry entries {@link buildHarnessRows} checks against the host's data-zone rule. */
125
+ export interface HarnessRowProviders {
126
+ /** `GET /api/providers` rows (or any {@link HarnessProviderCandidate}s), in registry order. */
127
+ candidates: readonly HarnessProviderCandidate[];
128
+ /** The host's data-zone rule. Absent: `any`. */
129
+ dataZoneRule?: AIProviderDataZoneRule;
130
+ }
131
+
104
132
  /**
105
133
  * The rows of `GET /api/agents`: one per harness, in `harnesses` order. Hosts
106
134
  * filter to `enabled` unless the request asked for all (`?all=1`).
107
135
  * `surfaces` and `providerPlurality` ride along from {@link HarnessInfo} so the
108
- * Harness tab can offer each harness only the providers it can use.
136
+ * Harness tab can offer each harness only the providers it can use; `roles`
137
+ * and `roleAssignments` drive the "Roles" badge and the models-per-task panel.
109
138
  * `detections` is the host's `detectHarnesses()` answer; a harness it does not
110
139
  * mention (or every harness, when it is omitted) reports `{ status: "unknown" }`.
140
+ * `providers`, when given, fills each row's `blockedProviders`.
111
141
  */
112
142
  export function buildHarnessRows(
113
143
  harnesses: HarnessInfo[],
114
- s: Pick<HarnessSettings, "enabledHarnesses">,
144
+ s: Pick<HarnessSettings, "enabledHarnesses" | "harnessRoles">,
115
145
  detections: readonly HarnessDetection[] = [],
146
+ providers?: HarnessRowProviders,
116
147
  ): HarnessRow[] {
117
148
  const enabled = new Set(enabledHarnessIds(s));
118
- return harnesses.map((info) => ({
119
- ...info,
120
- label: HARNESS_LABELS[info.id] ?? info.name,
121
- experimental: isExperimentalHarness(info.id),
122
- enabled: enabled.has(info.id),
123
- detection: detections.find((d) => d.id === info.id) ?? { id: info.id, status: "unknown" },
124
- }));
149
+ return harnesses.map((info) => {
150
+ const roles = harnessRoleIds(info.id);
151
+ const stored = stringEntries(s.harnessRoles?.[info.id]);
152
+ const roleAssignments: Record<string, string> = {};
153
+ for (const role of roles) if (stored[role]) roleAssignments[role] = stored[role];
154
+ const blockedProviders: Record<string, HarnessProviderBlock> = {};
155
+ for (const candidate of providers?.candidates ?? []) {
156
+ const block = harnessProviderBlock(info, candidate, providers?.dataZoneRule);
157
+ if (block) blockedProviders[candidate.id] = block;
158
+ }
159
+ return {
160
+ ...info,
161
+ label: HARNESS_LABELS[info.id] ?? info.name,
162
+ experimental: isExperimentalHarness(info.id),
163
+ enabled: enabled.has(info.id),
164
+ detection: detections.find((d) => d.id === info.id) ?? { id: info.id, status: "unknown" },
165
+ roles,
166
+ roleAssignments,
167
+ blockedProviders,
168
+ };
169
+ });
125
170
  }
126
171
 
127
172
  // ── validateHarnessSettingsPatch ────────────────────────────────────────────
@@ -180,6 +225,28 @@ function sameIdLists(a: Record<string, string[]>, b: Record<string, string[]>):
180
225
  );
181
226
  }
182
227
 
228
+ /** The non-empty role maps of a stored `harnessRoles` value. */
229
+ function roleMaps(value: unknown): Record<string, Record<string, string>> {
230
+ const maps: Record<string, Record<string, string>> = {};
231
+ if (!isObject(value)) return maps;
232
+ for (const [harnessId, roles] of Object.entries(value)) {
233
+ const entries = stringEntries(roles);
234
+ if (Object.keys(entries).length) maps[harnessId] = entries;
235
+ }
236
+ return maps;
237
+ }
238
+
239
+ function sameRoleMaps(
240
+ a: Record<string, Record<string, string>>,
241
+ b: Record<string, Record<string, string>>,
242
+ ): boolean {
243
+ const keys = Object.keys(a);
244
+ return (
245
+ keys.length === Object.keys(b).length &&
246
+ keys.every((key) => b[key] !== undefined && sameEntries(a[key]!, b[key]))
247
+ );
248
+ }
249
+
183
250
  /** What {@link validateHarnessSettingsPatch} hands back for a valid body. */
184
251
  export type HarnessSettingsPatch = {
185
252
  /**
@@ -188,9 +255,9 @@ export type HarnessSettingsPatch = {
188
255
  */
189
256
  patch: Partial<HarnessSettings>;
190
257
  /**
191
- * Whether the effective `harness`, `harnessModels`, `harnessProviders` or
192
- * `enabledHarnesses` changed, i.e. whether running sessions now run on stale
193
- * defaults and the host should stop them.
258
+ * Whether the effective `harness`, `harnessModels`, `harnessRoles`,
259
+ * `harnessProviders` or `enabledHarnesses` changed, i.e. whether running
260
+ * sessions now run on stale defaults and the host should stop them.
194
261
  */
195
262
  sessionAffecting: boolean;
196
263
  };
@@ -206,6 +273,11 @@ export type HarnessSettingsPatch = {
206
273
  * keys are present `harness` wins.
207
274
  * - `harnessModels`: patched per harness id (catalog ids only); a value is a
208
275
  * `providerId/modelId` selector, an empty string removes that harness's entry.
276
+ * - `harnessRoles`: patched per harness id, then per role. A harness must be in
277
+ * the catalog and take roles ({@link harnessRoleIds}); a role must be one of
278
+ * its roles; a value is a `providerId/modelId` selector, and one of
279
+ * `ctx.runnableModels` when that list is given. An empty string or `null`
280
+ * removes the role; `{}` or `null` for a harness removes all its roles.
209
281
  * - `harnessProviders`: the registry entry ids picked per harness id. The modal
210
282
  * sends the whole map, so it replaces the stored one: each value must be an
211
283
  * array of strings (empty strings are dropped, duplicates collapse, order is
@@ -226,12 +298,19 @@ export type HarnessSettingsPatch = {
226
298
  * @param ctx.catalog valid harness ids (`listHarnesses().map(d => d.id)`).
227
299
  * @param ctx.current the stored settings the patch applies to (the instance file).
228
300
  * @param ctx.locks the lock flags in force ({@link resolveLockFlags} of `current`).
301
+ * @param ctx.runnableModels optional: the selectors a session may run; when
302
+ * given, a `harnessRoles` value outside it is refused.
229
303
  * @returns the patch, or the error message of the first violated rule (a
230
304
  * string, mirroring `validateEnvSettingsUpdate`; hosts answer 400 with it).
231
305
  */
232
306
  export function validateHarnessSettingsPatch(
233
307
  body: unknown,
234
- ctx: { catalog: Iterable<string>; current: HarnessSettings; locks: LockFlags },
308
+ ctx: {
309
+ catalog: Iterable<string>;
310
+ current: HarnessSettings;
311
+ locks: LockFlags;
312
+ runnableModels?: Iterable<string>;
313
+ },
235
314
  ): HarnessSettingsPatch | string {
236
315
  if (!isObject(body)) return "body must be an object";
237
316
  if ("model" in body) return "model is set per harness - use harnessModels";
@@ -291,6 +370,52 @@ export function validateHarnessSettingsPatch(
291
370
  }
292
371
  }
293
372
 
373
+ if ("harnessRoles" in body) {
374
+ const raw = body.harnessRoles;
375
+ if (!isObject(raw)) return "harnessRoles must be an object";
376
+ const runnable = ctx.runnableModels ? new Set(ctx.runnableModels) : undefined;
377
+ const currentRoles = roleMaps(current.harnessRoles);
378
+ const nextRoles: Record<string, Record<string, string>> = {};
379
+ for (const [id, roles] of Object.entries(currentRoles)) nextRoles[id] = { ...roles };
380
+ for (const [id, roles] of Object.entries(raw)) {
381
+ if (!catalog.has(id)) return `harnessRoles: unknown harness "${id}"`;
382
+ const supported = harnessRoleIds(id);
383
+ if (!supported.length)
384
+ return `harnessRoles: harness "${id}" runs one model and takes no roles`;
385
+ if (roles === null) {
386
+ delete nextRoles[id];
387
+ continue;
388
+ }
389
+ if (!isObject(roles)) return `harnessRoles.${id} must be an object`;
390
+ if (!Object.keys(roles).length) {
391
+ delete nextRoles[id];
392
+ continue;
393
+ }
394
+ const next = { ...nextRoles[id] };
395
+ for (const [role, entry] of Object.entries(roles)) {
396
+ if (!supported.includes(role)) return `harnessRoles.${id}: unknown role "${role}"`;
397
+ if (entry !== null && typeof entry !== "string")
398
+ return `harnessRoles.${id}.${role} must be a string`;
399
+ const value = typeof entry === "string" ? entry.trim() : "";
400
+ if (!value) {
401
+ delete next[role];
402
+ continue;
403
+ }
404
+ if (!isModelSelector(value))
405
+ return `harnessRoles.${id}.${role} must be a "providerId/modelId" selector`;
406
+ if (runnable && !runnable.has(value))
407
+ return `harnessRoles.${id}.${role}: model "${value}" is not runnable`;
408
+ next[role] = value;
409
+ }
410
+ if (Object.keys(next).length) nextRoles[id] = next;
411
+ else delete nextRoles[id];
412
+ }
413
+ if (!sameRoleMaps(currentRoles, nextRoles)) {
414
+ patch.harnessRoles = Object.keys(nextRoles).length ? nextRoles : undefined;
415
+ sessionAffecting = true;
416
+ }
417
+ }
418
+
294
419
  if ("enabledHarnesses" in body) {
295
420
  const raw = body.enabledHarnesses;
296
421
  if (!Array.isArray(raw) || !raw.every((id) => typeof id === "string")) {
@@ -321,18 +446,20 @@ export function validateHarnessSettingsPatch(
321
446
 
322
447
  // ── applyHarnessLayers ──────────────────────────────────────────────────────
323
448
 
324
- /** One settings layer's say in which harness, model and provider pick a session gets. */
449
+ /** One settings layer's say in which harness, model, roles and provider pick a session gets. */
325
450
  export type HarnessLayer = {
326
451
  /** The harness id (the workspaces settings key `harness`). */
327
452
  harness?: string;
328
453
  /** An explicit model selector; wins over this layer's `harnessModels` entry. */
329
454
  model?: string;
330
455
  harnessModels?: Record<string, string>;
456
+ /** Role → model selector per harness id; layered per role. */
457
+ harnessRoles?: Record<string, Record<string, string>>;
331
458
  harnessProviders?: Record<string, string[]>;
332
459
  };
333
460
 
334
461
  /**
335
- * Resolve the harness, model and provider pick a session starts with.
462
+ * Resolve the harness, model, roles and provider pick a session starts with.
336
463
  *
337
464
  * `layers[0]` is the instance layer; later layers (workspace file, workspace
338
465
  * overrides, chat, …) are more specific and override it per field — the last
@@ -346,12 +473,21 @@ export type HarnessLayer = {
346
473
  * `layer.harnessProviders[harness]` — both keyed by the *resolved* harness, so
347
474
  * a pick stored for another harness never applies.
348
475
  *
476
+ * `roles` is layered per role of the resolved harness: for each role the last
477
+ * layer with a non-empty selector wins; `locks.model` freezes it to layer 0's
478
+ * roles, as it freezes `model`. No role set anywhere → `undefined`.
479
+ *
349
480
  * Fields no layer sets come back `undefined`; the host supplies its defaults.
350
481
  */
351
482
  export function applyHarnessLayers(
352
483
  layers: HarnessLayer[],
353
484
  locks: LockFlags,
354
- ): { harness: string | undefined; model: string | undefined; providers: string[] | undefined } {
485
+ ): {
486
+ harness: string | undefined;
487
+ model: string | undefined;
488
+ roles: Record<string, string> | undefined;
489
+ providers: string[] | undefined;
490
+ } {
355
491
  /** The layer-0 value when `locked`, else the last layer's non-undefined value. */
356
492
  const pick = <T>(
357
493
  locked: boolean,
@@ -376,5 +512,13 @@ export function applyHarnessLayers(
376
512
  const ids = harness === undefined ? undefined : layer.harnessProviders?.[harness];
377
513
  return ids?.length ? ids : undefined;
378
514
  });
379
- return { harness, model, providers };
515
+ let roles: Record<string, string> | undefined;
516
+ if (harness !== undefined) {
517
+ const merged: Record<string, string> = {};
518
+ const roleLayers = locks.model.value ? layers.slice(0, 1) : layers;
519
+ for (const layer of roleLayers)
520
+ Object.assign(merged, stringEntries(layer.harnessRoles?.[harness]));
521
+ if (Object.keys(merged).length) roles = merged;
522
+ }
523
+ return { harness, model, roles, providers };
380
524
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `@skaile/forge-settings/workspaces` — the Skaile Workspaces integration:
3
+ * the Workspaces `settings.json` document (`SkaileSettings`, defaults, legacy
4
+ * migrations), harness settings, project-registry model resolution with the
5
+ * Workspaces keyring location, and the address of a running Workspaces model
6
+ * endpoint.
7
+ *
8
+ * The only entry that needs the optional peer `@skaile/workspaces`; the generic
9
+ * entries (`/server`, `/vue`, `/nuxt`, `/ai-sdk`, `/editor`) never import it.
10
+ */
11
+
12
+ export * from "./harness-settings";
13
+ export * from "./language-model";
14
+ export * from "./model-endpoint";
15
+ export * from "./provider-settings";
16
+ export * from "./settings-defaults";
17
+ export type { DomainConfig, SkaileSettings } from "./settings-types";
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Project-oriented model resolution for a Skaile Workspaces host: load the
3
+ * project's merged provider registry once (user + workspace layers + catalog),
4
+ * keep the forge keyring where Workspaces hosts always kept it, and delegate to
5
+ * the generic `resolveRegistryLanguageModel` (`@skaile/forge-settings/ai-sdk`).
6
+ */
7
+
8
+ import { join } from "node:path";
9
+ import type { MergedAIProviderRegistry } from "@skaile/provider-registry";
10
+ import { loadMergedProviderRegistry } from "@skaile/workspaces/sdk/bridge";
11
+ import { globalSkaileDir } from "@skaile/workspaces/sdk/core";
12
+ import {
13
+ type ResolvedLanguageModel,
14
+ type ResolveRegistryLanguageModelOptions,
15
+ resolveRegistryLanguageModel,
16
+ } from "../ai-sdk";
17
+ import { ModelResolutionError } from "../model/errors";
18
+
19
+ /**
20
+ * The forge keyring of a Workspaces host: `FORGE_CREDENTIAL_STORE` of `env`
21
+ * when set, else `<skaile home>/forge-credentials.json`, beside the user's
22
+ * `settings.json` (follows `$SKAILE_HOME`). Generic entries never assume this
23
+ * home; a Workspaces host passes it as the store `path` (or pins it into
24
+ * `FORGE_CREDENTIAL_STORE`).
25
+ */
26
+ export function workspaceCredentialStorePath(
27
+ env?: Readonly<Record<string, string | undefined>>,
28
+ ): string {
29
+ return env?.FORGE_CREDENTIAL_STORE || join(globalSkaileDir(), "forge-credentials.json");
30
+ }
31
+
32
+ /**
33
+ * The merged provider registry of `projectDir` — the user layer, the
34
+ * project's `.skaile/providers.yml` and the catalog presets they name.
35
+ *
36
+ * @throws {ModelResolutionError} `registry_invalid` on any error diagnostic.
37
+ */
38
+ export function loadWorkspaceProviderRegistry(projectDir: string): MergedAIProviderRegistry {
39
+ const { merged, diagnostics } = loadMergedProviderRegistry({ projectDir });
40
+ const errors = diagnostics
41
+ .filter((d) => d.severity === "error")
42
+ .map((d) => (d.path ? `${d.path}: ${d.message}` : d.message));
43
+ if (errors.length > 0) {
44
+ throw new ModelResolutionError("registry_invalid", `providers registry: ${errors.join("; ")}`);
45
+ }
46
+ return merged;
47
+ }
48
+
49
+ /** Options for {@link resolveWorkspaceLanguageModel}. */
50
+ export interface ResolveWorkspaceLanguageModelOptions
51
+ extends Omit<ResolveRegistryLanguageModelOptions, "registry"> {
52
+ /** Workspace whose `.skaile/providers.yml` is the workspace layer. */
53
+ projectDir: string;
54
+ /**
55
+ * An already merged (e.g. transform-proxy-routed) registry. When set,
56
+ * `projectDir` is not read for providers; the selector, the policy check
57
+ * and the model all resolve against this registry.
58
+ */
59
+ registry?: MergedAIProviderRegistry;
60
+ }
61
+
62
+ /**
63
+ * The AI SDK model for `selector`, resolved against the registry of
64
+ * `projectDir` (read once) or the given `registry`.
65
+ *
66
+ * `keyring:` references read the store at `path`, else the environment's
67
+ * `FORGE_CREDENTIAL_STORE`, else {@link workspaceCredentialStorePath}.
68
+ *
69
+ * @throws {ModelResolutionError} `registry_invalid` (only when read from disk),
70
+ * plus every failure of `resolveRegistryLanguageModel`.
71
+ */
72
+ export async function resolveWorkspaceLanguageModel(
73
+ selector: string,
74
+ options: ResolveWorkspaceLanguageModelOptions,
75
+ ): Promise<ResolvedLanguageModel> {
76
+ const { projectDir, registry, ...rest } = options;
77
+ return resolveRegistryLanguageModel(selector, {
78
+ ...rest,
79
+ registry: registry ?? loadWorkspaceProviderRegistry(projectDir),
80
+ path: options.path ?? workspaceCredentialStorePath(options.env),
81
+ });
82
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Where a running `skaile serve --model-endpoint` can be reached: the
3
+ * OpenAI-compatible URL and its bearer token.
4
+ *
5
+ * Read-only: the runner writes, owns and removes `.skaile/model-endpoint.json`;
6
+ * this module only reads what the host points it at. No directory climbing, no
7
+ * working-directory lookup, and the token never appears in an error.
8
+ */
9
+
10
+ import { readFile } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+ import { isPlainObject } from "../shared/guards";
13
+
14
+ /** Env var names the runner hands its endpoint URL and token through. */
15
+ export const WORKSPACE_MODEL_ENDPOINT_URL_ENV = "SKAILE_MODEL_ENDPOINT_URL";
16
+ export const WORKSPACE_MODEL_ENDPOINT_TOKEN_ENV = "SKAILE_MODEL_ENDPOINT_TOKEN";
17
+
18
+ /** Address of a Workspaces model endpoint. `token` is a secret. */
19
+ export interface WorkspaceModelEndpoint {
20
+ /** `http(s)://<host>:<port>/v1`. */
21
+ url: string;
22
+ /** Bearer token; never log it. */
23
+ token: string;
24
+ }
25
+
26
+ /** Options for {@link readWorkspaceModelEndpoint}. */
27
+ export interface ReadWorkspaceModelEndpointOptions {
28
+ /** Environment snapshot the URL/token pair is read from. */
29
+ env: Record<string, string | undefined>;
30
+ /** Project whose `.skaile/model-endpoint.json` is read when the env pair is absent. */
31
+ projectDir?: string;
32
+ }
33
+
34
+ const PREFIX = "Skaile model endpoint configuration";
35
+
36
+ function isHttpUrl(value: string): boolean {
37
+ try {
38
+ const { protocol } = new URL(value);
39
+ return protocol === "http:" || protocol === "https:";
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * The endpoint address, or `null` when none is configured.
47
+ *
48
+ * 1. `SKAILE_MODEL_ENDPOINT_URL` + `SKAILE_MODEL_ENDPOINT_TOKEN` in `env` win.
49
+ * Both must be set and the URL must be HTTP(S); one without the other is a
50
+ * configuration error, not an absence.
51
+ * 2. Otherwise, only when `projectDir` is given, `<projectDir>/.skaile/model-endpoint.json`
52
+ * (`{ url, token, pid }`). A missing file is `null`.
53
+ *
54
+ * @throws Error a malformed or incomplete configuration; the message names the
55
+ * source and the problem, never the token.
56
+ */
57
+ export async function readWorkspaceModelEndpoint(
58
+ options: ReadWorkspaceModelEndpointOptions,
59
+ ): Promise<WorkspaceModelEndpoint | null> {
60
+ const envUrl = options.env[WORKSPACE_MODEL_ENDPOINT_URL_ENV]?.trim() ?? "";
61
+ const envToken = options.env[WORKSPACE_MODEL_ENDPOINT_TOKEN_ENV]?.trim() ?? "";
62
+ if (envUrl || envToken) {
63
+ if (!envUrl || !envToken) {
64
+ throw new Error(
65
+ `${PREFIX}: ${WORKSPACE_MODEL_ENDPOINT_URL_ENV} and ${WORKSPACE_MODEL_ENDPOINT_TOKEN_ENV} must be set together`,
66
+ );
67
+ }
68
+ if (!isHttpUrl(envUrl)) {
69
+ throw new Error(`${PREFIX}: ${WORKSPACE_MODEL_ENDPOINT_URL_ENV} is not an http(s) URL`);
70
+ }
71
+ return { url: envUrl, token: envToken };
72
+ }
73
+
74
+ if (options.projectDir === undefined) return null;
75
+ const file = join(options.projectDir, ".skaile", "model-endpoint.json");
76
+ let text: string;
77
+ try {
78
+ text = await readFile(file, "utf8");
79
+ } catch (error) {
80
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
81
+ throw new Error(`${PREFIX}: ${file} could not be read`);
82
+ }
83
+ let parsed: unknown;
84
+ try {
85
+ parsed = JSON.parse(text);
86
+ } catch {
87
+ // The parser's message can quote the file's content, i.e. the token.
88
+ throw new Error(`${PREFIX}: ${file} is not valid JSON`);
89
+ }
90
+ const record = isPlainObject(parsed) ? parsed : {};
91
+ const url = typeof record.url === "string" ? record.url.trim() : "";
92
+ const token = typeof record.token === "string" ? record.token.trim() : "";
93
+ if (!url || !token) {
94
+ throw new Error(`${PREFIX}: ${file} needs a non-empty "url" and "token"`);
95
+ }
96
+ if (!isHttpUrl(url)) {
97
+ throw new Error(`${PREFIX}: ${file} "url" is not an http(s) URL`);
98
+ }
99
+ return { url, token };
100
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The legacy `settings.json` migration of a Workspaces host.
3
+ *
4
+ * A `settings.json` written before the 5.0.0 registry migration holds
5
+ * `provider` + a free-text `model`, and an `apiKeys` map of secrets. On first
6
+ * load the pair collapses into one `"<providerId>/<modelId>"` selector, and the
7
+ * `apiKeys` map is dropped with a warning — a plaintext key is never
8
+ * auto-written anywhere; the user moves it into `.env`.
9
+ *
10
+ * Voice selection over the same settings fields is generic:
11
+ * `resolveVoiceProvider` in `@skaile/forge-settings/server`.
12
+ */
13
+
14
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
15
+
16
+ /** What {@link migrateLegacyProviderSettings} did to one settings file. */
17
+ export interface LegacySettingsMigration {
18
+ /** The file that was inspected. */
19
+ path: string;
20
+ /** Whether the file was rewritten. */
21
+ changed: boolean;
22
+ /** The selector `provider` + `model` collapsed into, when that happened. */
23
+ model?: string;
24
+ /** Everything dropped or left untouched, and why. Secret-free. */
25
+ warnings: string[];
26
+ }
27
+
28
+ /**
29
+ * Migrate one legacy `settings.json` in place. Idempotent: a file with no
30
+ * legacy `provider` and no `apiKeys` is left byte-identical.
31
+ *
32
+ * An `apiKeys` map is removed from the file and never stored anywhere else:
33
+ * the registry holds references only, and a plaintext key has no safe
34
+ * automatic destination. Each non-empty entry is reported through `warnings`
35
+ * (without the key) so the user can move it into `.env`.
36
+ *
37
+ * @param settingsPath - Absolute path to a `settings.json`.
38
+ */
39
+ export function migrateLegacyProviderSettings(settingsPath: string): LegacySettingsMigration {
40
+ const result: LegacySettingsMigration = {
41
+ path: settingsPath,
42
+ changed: false,
43
+ warnings: [],
44
+ };
45
+ if (!existsSync(settingsPath)) return result;
46
+
47
+ let settings: Record<string, unknown>;
48
+ try {
49
+ settings = JSON.parse(readFileSync(settingsPath, "utf8")) as Record<string, unknown>;
50
+ } catch (err) {
51
+ result.warnings.push(`${settingsPath} is not valid JSON (${(err as Error).message})`);
52
+ return result;
53
+ }
54
+ if (typeof settings !== "object" || settings === null || Array.isArray(settings)) {
55
+ result.warnings.push(`${settingsPath} does not contain a settings object`);
56
+ return result;
57
+ }
58
+
59
+ // ── provider + free-text model → one selector ───────────────────────────
60
+ const legacyProvider = typeof settings.provider === "string" ? settings.provider.trim() : "";
61
+ if (legacyProvider) {
62
+ const model = typeof settings.model === "string" ? settings.model.trim() : "";
63
+ if (model && !model.includes("/")) {
64
+ settings.model = `${legacyProvider}/${model}`;
65
+ result.model = settings.model as string;
66
+ } else if (!model) {
67
+ result.warnings.push(
68
+ `provider '${legacyProvider}' had no model to namespace — the session now resolves ` +
69
+ "its model through the registry's own order",
70
+ );
71
+ }
72
+ delete settings.provider;
73
+ result.changed = true;
74
+ }
75
+
76
+ // ── apiKeys map → dropped, never stored ─────────────────────────────────
77
+ if ("apiKeys" in settings) {
78
+ const apiKeys = settings.apiKeys;
79
+ const hasSecret =
80
+ typeof apiKeys === "object" &&
81
+ apiKeys !== null &&
82
+ Object.values(apiKeys).some((value) => typeof value === "string" && value !== "");
83
+ if (hasSecret) {
84
+ result.warnings.push(
85
+ "apiKeys found in settings: move each key into .env and reference it from providers.yml (skaile providers add)",
86
+ );
87
+ }
88
+ delete settings.apiKeys;
89
+ result.changed = true;
90
+ }
91
+
92
+ if (result.changed) {
93
+ writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
94
+ }
95
+ return result;
96
+ }
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { SETTINGS_DEFAULTS as CORE_DEFAULTS } from "@skaile/workspaces/sdk/core";
10
- import type { SkaileSettings } from "../shared/settings-types";
10
+ import type { SkaileSettings } from "./settings-types";
11
11
 
12
12
  /**
13
13
  * App-level defaults merged on top of core defaults.
@@ -1,9 +1,9 @@
1
1
  /**
2
- * The settings document type forge apps share.
2
+ * The settings document type forge apps on Skaile Workspaces share.
3
3
  *
4
- * A leaf module: it imports types only, so the harness and provider settings
5
- * modules read `SkaileSettings` from here instead of from the broad server
6
- * entry. Browser-safe.
4
+ * It extends the Workspaces `settings.json` type, so it lives in the explicit
5
+ * `@skaile/forge-settings/workspaces` entry: the generic entries never see it.
6
+ * A leaf module: it imports types only.
7
7
  */
8
8
 
9
9
  import type { SkaileSettings as CoreSettings } from "@skaile/workspaces/sdk/core";
@@ -58,6 +58,13 @@ export interface SkaileSettings extends Omit<CoreSettings, "apiKeys"> {
58
58
  * harness, all of them) is used automatically.
59
59
  */
60
60
  harnessProviders?: Record<string, string[]>;
61
+ /**
62
+ * Per-harness model per task ("roles"): role id → model selector
63
+ * (`"<providerId>/<modelId>"`), keyed by harness id. Only harnesses with
64
+ * `HARNESS_ROLES` take roles (omp, Continue); hosts pass the session
65
+ * harness's map to `skaile serve --roles`. Frozen by `lockModel`.
66
+ */
67
+ harnessRoles?: Record<string, Record<string, string>>;
61
68
  /**
62
69
  * Instance allow-list of harness ids the harness picker offers. Absent means
63
70
  * `DEFAULT_ENABLED_HARNESSES`.