@skaile/forge-settings 1.3.3 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +52 -9
- package/dist/module.json +1 -1
- package/dist/runtime/ai-sdk/index.d.ts +33 -26
- package/dist/runtime/ai-sdk/index.js +34 -16
- package/dist/runtime/components/ForgeProvidersPanel.d.vue.ts +13 -0
- package/dist/runtime/components/ForgeProvidersPanel.vue +461 -78
- package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +13 -0
- package/dist/runtime/editor/index.d.ts +55 -16
- package/dist/runtime/editor/index.js +82 -40
- package/dist/runtime/model/selector.d.ts +16 -0
- package/dist/runtime/model/selector.js +12 -0
- package/dist/runtime/server/credential-store.d.ts +16 -4
- package/dist/runtime/server/credential-store.js +3 -4
- package/dist/runtime/server/env-layers.d.ts +1 -1
- package/dist/runtime/server/env-layers.js +3 -3
- package/dist/runtime/server/index.d.ts +9 -8
- package/dist/runtime/server/index.js +3 -3
- package/dist/runtime/server/provider-discovery.d.ts +28 -8
- package/dist/runtime/server/provider-discovery.js +57 -33
- package/dist/runtime/server/provider-layers.d.ts +1 -1
- package/dist/runtime/server/provider-layers.js +3 -5
- package/dist/runtime/server/provider-service.d.ts +26 -2
- package/dist/runtime/server/provider-service.js +16 -1
- package/dist/runtime/server/provider-write.d.ts +26 -10
- package/dist/runtime/server/provider-write.js +75 -7
- package/dist/runtime/server/voice-provider.d.ts +61 -0
- package/dist/runtime/server/voice-provider.js +31 -0
- package/dist/runtime/shared/data-zone.d.ts +68 -0
- package/dist/runtime/shared/data-zone.js +79 -0
- package/dist/runtime/shared/harness-providers.d.ts +42 -3
- package/dist/runtime/shared/harness-providers.js +31 -8
- package/dist/runtime/shared/harnesses.d.ts +7 -0
- package/dist/runtime/shared/harnesses.js +18 -0
- package/dist/runtime/shared/provider-accounts.d.ts +215 -0
- package/dist/runtime/shared/provider-accounts.js +255 -0
- package/dist/runtime/shared/provider-registry.d.ts +19 -182
- package/dist/runtime/shared/provider-registry.js +25 -65
- package/dist/runtime/vue/index.d.ts +2 -0
- package/dist/runtime/vue/index.js +2 -0
- package/dist/runtime/vue/providers-panel-model.d.ts +98 -5
- package/dist/runtime/vue/providers-panel-model.js +13 -1
- package/dist/runtime/vue/useProvidersPanel.d.ts +66 -5
- package/dist/runtime/vue/useProvidersPanel.js +417 -29
- package/dist/runtime/{server → workspaces}/harness-settings.d.ts +51 -13
- package/dist/runtime/{server → workspaces}/harness-settings.js +93 -9
- package/dist/runtime/workspaces/index.d.ts +16 -0
- package/dist/runtime/workspaces/index.js +5 -0
- package/dist/runtime/workspaces/language-model.d.ts +45 -0
- package/dist/runtime/workspaces/language-model.js +26 -0
- package/dist/runtime/workspaces/model-endpoint.d.ts +38 -0
- package/dist/runtime/workspaces/model-endpoint.js +54 -0
- package/dist/runtime/workspaces/provider-settings.d.ts +35 -0
- package/dist/runtime/{server → workspaces}/provider-settings.js +0 -31
- package/dist/runtime/{server → workspaces}/settings-defaults.d.ts +1 -1
- package/dist/runtime/{shared → workspaces}/settings-types.d.ts +11 -4
- package/package.json +11 -2
- package/src/runtime/ai-sdk/index.ts +88 -47
- package/src/runtime/components/ForgeProvidersPanel.vue +477 -78
- package/src/runtime/editor/index.ts +158 -63
- package/src/runtime/model/selector.ts +31 -0
- package/src/runtime/server/credential-store.ts +20 -10
- package/src/runtime/server/env-layers.ts +4 -4
- package/src/runtime/server/index.ts +9 -8
- package/src/runtime/server/provider-discovery.ts +114 -48
- package/src/runtime/server/provider-layers.ts +6 -6
- package/src/runtime/server/provider-service.ts +53 -1
- package/src/runtime/server/provider-write.ts +132 -18
- package/src/runtime/server/voice-provider.ts +110 -0
- package/src/runtime/shared/data-zone.ts +179 -0
- package/src/runtime/shared/harness-providers.ts +78 -7
- package/src/runtime/shared/harnesses.ts +25 -0
- package/src/runtime/shared/provider-accounts.ts +526 -0
- package/src/runtime/shared/provider-registry.ts +82 -256
- package/src/runtime/vue/index.ts +2 -0
- package/src/runtime/vue/providers-panel-model.ts +123 -5
- package/src/runtime/vue/useProvidersPanel.ts +589 -37
- package/src/runtime/{server → workspaces}/harness-settings.ts +166 -22
- package/src/runtime/workspaces/index.ts +17 -0
- package/src/runtime/workspaces/language-model.ts +82 -0
- package/src/runtime/workspaces/model-endpoint.ts +100 -0
- package/src/runtime/workspaces/provider-settings.ts +96 -0
- package/src/runtime/{server → workspaces}/settings-defaults.ts +1 -1
- package/src/runtime/{shared → workspaces}/settings-types.ts +11 -4
- package/dist/runtime/server/provider-settings.d.ts +0 -78
- package/src/runtime/server/provider-settings.ts +0 -192
- /package/dist/runtime/{server → workspaces}/settings-defaults.js +0 -0
- /package/dist/runtime/{shared → workspaces}/settings-types.js +0 -0
|
@@ -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`, `
|
|
12
|
-
* `lock*`), as a pure function over the request body and
|
|
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
|
|
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
|
*
|
|
@@ -21,9 +22,11 @@
|
|
|
21
22
|
*/
|
|
22
23
|
import type { HarnessDetection } from "@skaile/workspaces/sdk/bridge";
|
|
23
24
|
import type { HarnessInfo } from "@skaile/workspaces/types";
|
|
24
|
-
import type
|
|
25
|
+
import { type HarnessProviderBlock, type HarnessProviderCandidate } from "../shared/harness-providers.js";
|
|
26
|
+
import type { AIProviderDataZoneRule } from "../shared/provider-registry.js";
|
|
27
|
+
import type { SkaileSettings } from "./settings-types.js";
|
|
25
28
|
/** The slice of {@link SkaileSettings} that selects, configures and locks harnesses. */
|
|
26
|
-
export type HarnessSettings = Pick<SkaileSettings, "harness" | "harnessModels" | "harnessProviders" | "enabledHarnesses" | "lockHarness" | "lockProvider" | "lockModel" | "lockAudio">;
|
|
29
|
+
export type HarnessSettings = Pick<SkaileSettings, "harness" | "harnessModels" | "harnessRoles" | "harnessProviders" | "enabledHarnesses" | "lockHarness" | "lockProvider" | "lockModel" | "lockAudio">;
|
|
27
30
|
/** One lock flag and where its value comes from. */
|
|
28
31
|
export type LockFlagStatus = {
|
|
29
32
|
value: boolean;
|
|
@@ -59,16 +62,36 @@ export type HarnessRow = HarnessInfo & {
|
|
|
59
62
|
enabled: boolean;
|
|
60
63
|
/** What `detectHarnesses()` found on this host: installed, version, login. */
|
|
61
64
|
detection: HarnessDetection;
|
|
65
|
+
/** The role ids the harness takes ({@link harnessRoleIds}); empty for a one-model harness. */
|
|
66
|
+
roles: string[];
|
|
67
|
+
/** The role → model selector assignments of `s.harnessRoles` for this harness (pass effective settings). */
|
|
68
|
+
roleAssignments: Record<string, string>;
|
|
69
|
+
/**
|
|
70
|
+
* Registry entries on a surface the harness speaks that the host's
|
|
71
|
+
* data-zone rule keeps out, by entry id (reason `blocked-by-rule`, with the
|
|
72
|
+
* message a table shows on the box it cannot tick). Empty without
|
|
73
|
+
* `providers` or under the rule `any`.
|
|
74
|
+
*/
|
|
75
|
+
blockedProviders: Record<string, HarnessProviderBlock>;
|
|
62
76
|
};
|
|
77
|
+
/** The registry entries {@link buildHarnessRows} checks against the host's data-zone rule. */
|
|
78
|
+
export interface HarnessRowProviders {
|
|
79
|
+
/** `GET /api/providers` rows (or any {@link HarnessProviderCandidate}s), in registry order. */
|
|
80
|
+
candidates: readonly HarnessProviderCandidate[];
|
|
81
|
+
/** The host's data-zone rule. Absent: `any`. */
|
|
82
|
+
dataZoneRule?: AIProviderDataZoneRule;
|
|
83
|
+
}
|
|
63
84
|
/**
|
|
64
85
|
* The rows of `GET /api/agents`: one per harness, in `harnesses` order. Hosts
|
|
65
86
|
* filter to `enabled` unless the request asked for all (`?all=1`).
|
|
66
87
|
* `surfaces` and `providerPlurality` ride along from {@link HarnessInfo} so the
|
|
67
|
-
* Harness tab can offer each harness only the providers it can use
|
|
88
|
+
* Harness tab can offer each harness only the providers it can use; `roles`
|
|
89
|
+
* and `roleAssignments` drive the "Roles" badge and the models-per-task panel.
|
|
68
90
|
* `detections` is the host's `detectHarnesses()` answer; a harness it does not
|
|
69
91
|
* mention (or every harness, when it is omitted) reports `{ status: "unknown" }`.
|
|
92
|
+
* `providers`, when given, fills each row's `blockedProviders`.
|
|
70
93
|
*/
|
|
71
|
-
export declare function buildHarnessRows(harnesses: HarnessInfo[], s: Pick<HarnessSettings, "enabledHarnesses">, detections?: readonly HarnessDetection[]): HarnessRow[];
|
|
94
|
+
export declare function buildHarnessRows(harnesses: HarnessInfo[], s: Pick<HarnessSettings, "enabledHarnesses" | "harnessRoles">, detections?: readonly HarnessDetection[], providers?: HarnessRowProviders): HarnessRow[];
|
|
72
95
|
/** What {@link validateHarnessSettingsPatch} hands back for a valid body. */
|
|
73
96
|
export type HarnessSettingsPatch = {
|
|
74
97
|
/**
|
|
@@ -77,9 +100,9 @@ export type HarnessSettingsPatch = {
|
|
|
77
100
|
*/
|
|
78
101
|
patch: Partial<HarnessSettings>;
|
|
79
102
|
/**
|
|
80
|
-
* Whether the effective `harness`, `harnessModels`, `
|
|
81
|
-
* `enabledHarnesses` changed, i.e. whether running
|
|
82
|
-
* defaults and the host should stop them.
|
|
103
|
+
* Whether the effective `harness`, `harnessModels`, `harnessRoles`,
|
|
104
|
+
* `harnessProviders` or `enabledHarnesses` changed, i.e. whether running
|
|
105
|
+
* sessions now run on stale defaults and the host should stop them.
|
|
83
106
|
*/
|
|
84
107
|
sessionAffecting: boolean;
|
|
85
108
|
};
|
|
@@ -94,6 +117,11 @@ export type HarnessSettingsPatch = {
|
|
|
94
117
|
* keys are present `harness` wins.
|
|
95
118
|
* - `harnessModels`: patched per harness id (catalog ids only); a value is a
|
|
96
119
|
* `providerId/modelId` selector, an empty string removes that harness's entry.
|
|
120
|
+
* - `harnessRoles`: patched per harness id, then per role. A harness must be in
|
|
121
|
+
* the catalog and take roles ({@link harnessRoleIds}); a role must be one of
|
|
122
|
+
* its roles; a value is a `providerId/modelId` selector, and one of
|
|
123
|
+
* `ctx.runnableModels` when that list is given. An empty string or `null`
|
|
124
|
+
* removes the role; `{}` or `null` for a harness removes all its roles.
|
|
97
125
|
* - `harnessProviders`: the registry entry ids picked per harness id. The modal
|
|
98
126
|
* sends the whole map, so it replaces the stored one: each value must be an
|
|
99
127
|
* array of strings (empty strings are dropped, duplicates collapse, order is
|
|
@@ -114,6 +142,8 @@ export type HarnessSettingsPatch = {
|
|
|
114
142
|
* @param ctx.catalog valid harness ids (`listHarnesses().map(d => d.id)`).
|
|
115
143
|
* @param ctx.current the stored settings the patch applies to (the instance file).
|
|
116
144
|
* @param ctx.locks the lock flags in force ({@link resolveLockFlags} of `current`).
|
|
145
|
+
* @param ctx.runnableModels optional: the selectors a session may run; when
|
|
146
|
+
* given, a `harnessRoles` value outside it is refused.
|
|
117
147
|
* @returns the patch, or the error message of the first violated rule (a
|
|
118
148
|
* string, mirroring `validateEnvSettingsUpdate`; hosts answer 400 with it).
|
|
119
149
|
*/
|
|
@@ -121,18 +151,21 @@ export declare function validateHarnessSettingsPatch(body: unknown, ctx: {
|
|
|
121
151
|
catalog: Iterable<string>;
|
|
122
152
|
current: HarnessSettings;
|
|
123
153
|
locks: LockFlags;
|
|
154
|
+
runnableModels?: Iterable<string>;
|
|
124
155
|
}): HarnessSettingsPatch | string;
|
|
125
|
-
/** One settings layer's say in which harness, model and provider pick a session gets. */
|
|
156
|
+
/** One settings layer's say in which harness, model, roles and provider pick a session gets. */
|
|
126
157
|
export type HarnessLayer = {
|
|
127
158
|
/** The harness id (the workspaces settings key `harness`). */
|
|
128
159
|
harness?: string;
|
|
129
160
|
/** An explicit model selector; wins over this layer's `harnessModels` entry. */
|
|
130
161
|
model?: string;
|
|
131
162
|
harnessModels?: Record<string, string>;
|
|
163
|
+
/** Role → model selector per harness id; layered per role. */
|
|
164
|
+
harnessRoles?: Record<string, Record<string, string>>;
|
|
132
165
|
harnessProviders?: Record<string, string[]>;
|
|
133
166
|
};
|
|
134
167
|
/**
|
|
135
|
-
* Resolve the harness, model and provider pick a session starts with.
|
|
168
|
+
* Resolve the harness, model, roles and provider pick a session starts with.
|
|
136
169
|
*
|
|
137
170
|
* `layers[0]` is the instance layer; later layers (workspace file, workspace
|
|
138
171
|
* overrides, chat, …) are more specific and override it per field — the last
|
|
@@ -146,10 +179,15 @@ export type HarnessLayer = {
|
|
|
146
179
|
* `layer.harnessProviders[harness]` — both keyed by the *resolved* harness, so
|
|
147
180
|
* a pick stored for another harness never applies.
|
|
148
181
|
*
|
|
182
|
+
* `roles` is layered per role of the resolved harness: for each role the last
|
|
183
|
+
* layer with a non-empty selector wins; `locks.model` freezes it to layer 0's
|
|
184
|
+
* roles, as it freezes `model`. No role set anywhere → `undefined`.
|
|
185
|
+
*
|
|
149
186
|
* Fields no layer sets come back `undefined`; the host supplies its defaults.
|
|
150
187
|
*/
|
|
151
188
|
export declare function applyHarnessLayers(layers: HarnessLayer[], locks: LockFlags): {
|
|
152
189
|
harness: string | undefined;
|
|
153
190
|
model: string | undefined;
|
|
191
|
+
roles: Record<string, string> | undefined;
|
|
154
192
|
providers: string[] | undefined;
|
|
155
193
|
};
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import {
|
|
2
|
+
harnessProviderBlock
|
|
3
|
+
} from "../shared/harness-providers.js";
|
|
1
4
|
import {
|
|
2
5
|
DEFAULT_ENABLED_HARNESSES,
|
|
3
6
|
HARNESS_LABELS,
|
|
7
|
+
harnessRoleIds,
|
|
4
8
|
isExperimentalHarness
|
|
5
9
|
} from "../shared/harnesses.js";
|
|
6
10
|
const LOCK_FIELDS = [
|
|
@@ -21,15 +25,29 @@ export function resolveLockFlags(stored) {
|
|
|
21
25
|
export function enabledHarnessIds(s) {
|
|
22
26
|
return s.enabledHarnesses?.length ? s.enabledHarnesses : [...DEFAULT_ENABLED_HARNESSES];
|
|
23
27
|
}
|
|
24
|
-
export function buildHarnessRows(harnesses, s, detections = []) {
|
|
28
|
+
export function buildHarnessRows(harnesses, s, detections = [], providers) {
|
|
25
29
|
const enabled = new Set(enabledHarnessIds(s));
|
|
26
|
-
return harnesses.map((info) =>
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
return harnesses.map((info) => {
|
|
31
|
+
const roles = harnessRoleIds(info.id);
|
|
32
|
+
const stored = stringEntries(s.harnessRoles?.[info.id]);
|
|
33
|
+
const roleAssignments = {};
|
|
34
|
+
for (const role of roles) if (stored[role]) roleAssignments[role] = stored[role];
|
|
35
|
+
const blockedProviders = {};
|
|
36
|
+
for (const candidate of providers?.candidates ?? []) {
|
|
37
|
+
const block = harnessProviderBlock(info, candidate, providers?.dataZoneRule);
|
|
38
|
+
if (block) blockedProviders[candidate.id] = block;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
...info,
|
|
42
|
+
label: HARNESS_LABELS[info.id] ?? info.name,
|
|
43
|
+
experimental: isExperimentalHarness(info.id),
|
|
44
|
+
enabled: enabled.has(info.id),
|
|
45
|
+
detection: detections.find((d) => d.id === info.id) ?? { id: info.id, status: "unknown" },
|
|
46
|
+
roles,
|
|
47
|
+
roleAssignments,
|
|
48
|
+
blockedProviders
|
|
49
|
+
};
|
|
50
|
+
});
|
|
33
51
|
}
|
|
34
52
|
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
53
|
const nonEmpty = (value) => typeof value === "string" && value ? value : void 0;
|
|
@@ -66,6 +84,19 @@ function sameIdLists(a, b) {
|
|
|
66
84
|
(key) => b[key] !== void 0 && a[key].length === b[key].length && a[key].every((id, i) => id === b[key][i])
|
|
67
85
|
);
|
|
68
86
|
}
|
|
87
|
+
function roleMaps(value) {
|
|
88
|
+
const maps = {};
|
|
89
|
+
if (!isObject(value)) return maps;
|
|
90
|
+
for (const [harnessId, roles] of Object.entries(value)) {
|
|
91
|
+
const entries = stringEntries(roles);
|
|
92
|
+
if (Object.keys(entries).length) maps[harnessId] = entries;
|
|
93
|
+
}
|
|
94
|
+
return maps;
|
|
95
|
+
}
|
|
96
|
+
function sameRoleMaps(a, b) {
|
|
97
|
+
const keys = Object.keys(a);
|
|
98
|
+
return keys.length === Object.keys(b).length && keys.every((key) => b[key] !== void 0 && sameEntries(a[key], b[key]));
|
|
99
|
+
}
|
|
69
100
|
export function validateHarnessSettingsPatch(body, ctx) {
|
|
70
101
|
if (!isObject(body)) return "body must be an object";
|
|
71
102
|
if ("model" in body) return "model is set per harness - use harnessModels";
|
|
@@ -120,6 +151,51 @@ export function validateHarnessSettingsPatch(body, ctx) {
|
|
|
120
151
|
sessionAffecting = true;
|
|
121
152
|
}
|
|
122
153
|
}
|
|
154
|
+
if ("harnessRoles" in body) {
|
|
155
|
+
const raw = body.harnessRoles;
|
|
156
|
+
if (!isObject(raw)) return "harnessRoles must be an object";
|
|
157
|
+
const runnable = ctx.runnableModels ? new Set(ctx.runnableModels) : void 0;
|
|
158
|
+
const currentRoles = roleMaps(current.harnessRoles);
|
|
159
|
+
const nextRoles = {};
|
|
160
|
+
for (const [id, roles] of Object.entries(currentRoles)) nextRoles[id] = { ...roles };
|
|
161
|
+
for (const [id, roles] of Object.entries(raw)) {
|
|
162
|
+
if (!catalog.has(id)) return `harnessRoles: unknown harness "${id}"`;
|
|
163
|
+
const supported = harnessRoleIds(id);
|
|
164
|
+
if (!supported.length)
|
|
165
|
+
return `harnessRoles: harness "${id}" runs one model and takes no roles`;
|
|
166
|
+
if (roles === null) {
|
|
167
|
+
delete nextRoles[id];
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (!isObject(roles)) return `harnessRoles.${id} must be an object`;
|
|
171
|
+
if (!Object.keys(roles).length) {
|
|
172
|
+
delete nextRoles[id];
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const next = { ...nextRoles[id] };
|
|
176
|
+
for (const [role, entry] of Object.entries(roles)) {
|
|
177
|
+
if (!supported.includes(role)) return `harnessRoles.${id}: unknown role "${role}"`;
|
|
178
|
+
if (entry !== null && typeof entry !== "string")
|
|
179
|
+
return `harnessRoles.${id}.${role} must be a string`;
|
|
180
|
+
const value = typeof entry === "string" ? entry.trim() : "";
|
|
181
|
+
if (!value) {
|
|
182
|
+
delete next[role];
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (!isModelSelector(value))
|
|
186
|
+
return `harnessRoles.${id}.${role} must be a "providerId/modelId" selector`;
|
|
187
|
+
if (runnable && !runnable.has(value))
|
|
188
|
+
return `harnessRoles.${id}.${role}: model "${value}" is not runnable`;
|
|
189
|
+
next[role] = value;
|
|
190
|
+
}
|
|
191
|
+
if (Object.keys(next).length) nextRoles[id] = next;
|
|
192
|
+
else delete nextRoles[id];
|
|
193
|
+
}
|
|
194
|
+
if (!sameRoleMaps(currentRoles, nextRoles)) {
|
|
195
|
+
patch.harnessRoles = Object.keys(nextRoles).length ? nextRoles : void 0;
|
|
196
|
+
sessionAffecting = true;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
123
199
|
if ("enabledHarnesses" in body) {
|
|
124
200
|
const raw = body.enabledHarnesses;
|
|
125
201
|
if (!Array.isArray(raw) || !raw.every((id) => typeof id === "string")) {
|
|
@@ -163,5 +239,13 @@ export function applyHarnessLayers(layers, locks) {
|
|
|
163
239
|
const ids = harness === void 0 ? void 0 : layer.harnessProviders?.[harness];
|
|
164
240
|
return ids?.length ? ids : void 0;
|
|
165
241
|
});
|
|
166
|
-
|
|
242
|
+
let roles;
|
|
243
|
+
if (harness !== void 0) {
|
|
244
|
+
const merged = {};
|
|
245
|
+
const roleLayers = locks.model.value ? layers.slice(0, 1) : layers;
|
|
246
|
+
for (const layer of roleLayers)
|
|
247
|
+
Object.assign(merged, stringEntries(layer.harnessRoles?.[harness]));
|
|
248
|
+
if (Object.keys(merged).length) roles = merged;
|
|
249
|
+
}
|
|
250
|
+
return { harness, model, roles, providers };
|
|
167
251
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
export * from "./harness-settings.js";
|
|
12
|
+
export * from "./language-model.js";
|
|
13
|
+
export * from "./model-endpoint.js";
|
|
14
|
+
export * from "./provider-settings.js";
|
|
15
|
+
export * from "./settings-defaults.js";
|
|
16
|
+
export type { DomainConfig, SkaileSettings } from "./settings-types.js";
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
import type { MergedAIProviderRegistry } from "@skaile/provider-registry";
|
|
8
|
+
import { type ResolvedLanguageModel, type ResolveRegistryLanguageModelOptions } from "../ai-sdk/index.js";
|
|
9
|
+
/**
|
|
10
|
+
* The forge keyring of a Workspaces host: `FORGE_CREDENTIAL_STORE` of `env`
|
|
11
|
+
* when set, else `<skaile home>/forge-credentials.json`, beside the user's
|
|
12
|
+
* `settings.json` (follows `$SKAILE_HOME`). Generic entries never assume this
|
|
13
|
+
* home; a Workspaces host passes it as the store `path` (or pins it into
|
|
14
|
+
* `FORGE_CREDENTIAL_STORE`).
|
|
15
|
+
*/
|
|
16
|
+
export declare function workspaceCredentialStorePath(env?: Readonly<Record<string, string | undefined>>): string;
|
|
17
|
+
/**
|
|
18
|
+
* The merged provider registry of `projectDir` — the user layer, the
|
|
19
|
+
* project's `.skaile/providers.yml` and the catalog presets they name.
|
|
20
|
+
*
|
|
21
|
+
* @throws {ModelResolutionError} `registry_invalid` on any error diagnostic.
|
|
22
|
+
*/
|
|
23
|
+
export declare function loadWorkspaceProviderRegistry(projectDir: string): MergedAIProviderRegistry;
|
|
24
|
+
/** Options for {@link resolveWorkspaceLanguageModel}. */
|
|
25
|
+
export interface ResolveWorkspaceLanguageModelOptions extends Omit<ResolveRegistryLanguageModelOptions, "registry"> {
|
|
26
|
+
/** Workspace whose `.skaile/providers.yml` is the workspace layer. */
|
|
27
|
+
projectDir: string;
|
|
28
|
+
/**
|
|
29
|
+
* An already merged (e.g. transform-proxy-routed) registry. When set,
|
|
30
|
+
* `projectDir` is not read for providers; the selector, the policy check
|
|
31
|
+
* and the model all resolve against this registry.
|
|
32
|
+
*/
|
|
33
|
+
registry?: MergedAIProviderRegistry;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The AI SDK model for `selector`, resolved against the registry of
|
|
37
|
+
* `projectDir` (read once) or the given `registry`.
|
|
38
|
+
*
|
|
39
|
+
* `keyring:` references read the store at `path`, else the environment's
|
|
40
|
+
* `FORGE_CREDENTIAL_STORE`, else {@link workspaceCredentialStorePath}.
|
|
41
|
+
*
|
|
42
|
+
* @throws {ModelResolutionError} `registry_invalid` (only when read from disk),
|
|
43
|
+
* plus every failure of `resolveRegistryLanguageModel`.
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveWorkspaceLanguageModel(selector: string, options: ResolveWorkspaceLanguageModelOptions): Promise<ResolvedLanguageModel>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { loadMergedProviderRegistry } from "@skaile/workspaces/sdk/bridge";
|
|
3
|
+
import { globalSkaileDir } from "@skaile/workspaces/sdk/core";
|
|
4
|
+
import {
|
|
5
|
+
resolveRegistryLanguageModel
|
|
6
|
+
} from "../ai-sdk/index.js";
|
|
7
|
+
import { ModelResolutionError } from "../model/errors.js";
|
|
8
|
+
export function workspaceCredentialStorePath(env) {
|
|
9
|
+
return env?.FORGE_CREDENTIAL_STORE || join(globalSkaileDir(), "forge-credentials.json");
|
|
10
|
+
}
|
|
11
|
+
export function loadWorkspaceProviderRegistry(projectDir) {
|
|
12
|
+
const { merged, diagnostics } = loadMergedProviderRegistry({ projectDir });
|
|
13
|
+
const errors = diagnostics.filter((d) => d.severity === "error").map((d) => d.path ? `${d.path}: ${d.message}` : d.message);
|
|
14
|
+
if (errors.length > 0) {
|
|
15
|
+
throw new ModelResolutionError("registry_invalid", `providers registry: ${errors.join("; ")}`);
|
|
16
|
+
}
|
|
17
|
+
return merged;
|
|
18
|
+
}
|
|
19
|
+
export async function resolveWorkspaceLanguageModel(selector, options) {
|
|
20
|
+
const { projectDir, registry, ...rest } = options;
|
|
21
|
+
return resolveRegistryLanguageModel(selector, {
|
|
22
|
+
...rest,
|
|
23
|
+
registry: registry ?? loadWorkspaceProviderRegistry(projectDir),
|
|
24
|
+
path: options.path ?? workspaceCredentialStorePath(options.env)
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
/** Env var names the runner hands its endpoint URL and token through. */
|
|
10
|
+
export declare const WORKSPACE_MODEL_ENDPOINT_URL_ENV = "SKAILE_MODEL_ENDPOINT_URL";
|
|
11
|
+
export declare const WORKSPACE_MODEL_ENDPOINT_TOKEN_ENV = "SKAILE_MODEL_ENDPOINT_TOKEN";
|
|
12
|
+
/** Address of a Workspaces model endpoint. `token` is a secret. */
|
|
13
|
+
export interface WorkspaceModelEndpoint {
|
|
14
|
+
/** `http(s)://<host>:<port>/v1`. */
|
|
15
|
+
url: string;
|
|
16
|
+
/** Bearer token; never log it. */
|
|
17
|
+
token: string;
|
|
18
|
+
}
|
|
19
|
+
/** Options for {@link readWorkspaceModelEndpoint}. */
|
|
20
|
+
export interface ReadWorkspaceModelEndpointOptions {
|
|
21
|
+
/** Environment snapshot the URL/token pair is read from. */
|
|
22
|
+
env: Record<string, string | undefined>;
|
|
23
|
+
/** Project whose `.skaile/model-endpoint.json` is read when the env pair is absent. */
|
|
24
|
+
projectDir?: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The endpoint address, or `null` when none is configured.
|
|
28
|
+
*
|
|
29
|
+
* 1. `SKAILE_MODEL_ENDPOINT_URL` + `SKAILE_MODEL_ENDPOINT_TOKEN` in `env` win.
|
|
30
|
+
* Both must be set and the URL must be HTTP(S); one without the other is a
|
|
31
|
+
* configuration error, not an absence.
|
|
32
|
+
* 2. Otherwise, only when `projectDir` is given, `<projectDir>/.skaile/model-endpoint.json`
|
|
33
|
+
* (`{ url, token, pid }`). A missing file is `null`.
|
|
34
|
+
*
|
|
35
|
+
* @throws Error a malformed or incomplete configuration; the message names the
|
|
36
|
+
* source and the problem, never the token.
|
|
37
|
+
*/
|
|
38
|
+
export declare function readWorkspaceModelEndpoint(options: ReadWorkspaceModelEndpointOptions): Promise<WorkspaceModelEndpoint | null>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isPlainObject } from "../shared/guards.js";
|
|
4
|
+
export const WORKSPACE_MODEL_ENDPOINT_URL_ENV = "SKAILE_MODEL_ENDPOINT_URL";
|
|
5
|
+
export const WORKSPACE_MODEL_ENDPOINT_TOKEN_ENV = "SKAILE_MODEL_ENDPOINT_TOKEN";
|
|
6
|
+
const PREFIX = "Skaile model endpoint configuration";
|
|
7
|
+
function isHttpUrl(value) {
|
|
8
|
+
try {
|
|
9
|
+
const { protocol } = new URL(value);
|
|
10
|
+
return protocol === "http:" || protocol === "https:";
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function readWorkspaceModelEndpoint(options) {
|
|
16
|
+
const envUrl = options.env[WORKSPACE_MODEL_ENDPOINT_URL_ENV]?.trim() ?? "";
|
|
17
|
+
const envToken = options.env[WORKSPACE_MODEL_ENDPOINT_TOKEN_ENV]?.trim() ?? "";
|
|
18
|
+
if (envUrl || envToken) {
|
|
19
|
+
if (!envUrl || !envToken) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`${PREFIX}: ${WORKSPACE_MODEL_ENDPOINT_URL_ENV} and ${WORKSPACE_MODEL_ENDPOINT_TOKEN_ENV} must be set together`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
if (!isHttpUrl(envUrl)) {
|
|
25
|
+
throw new Error(`${PREFIX}: ${WORKSPACE_MODEL_ENDPOINT_URL_ENV} is not an http(s) URL`);
|
|
26
|
+
}
|
|
27
|
+
return { url: envUrl, token: envToken };
|
|
28
|
+
}
|
|
29
|
+
if (options.projectDir === void 0) return null;
|
|
30
|
+
const file = join(options.projectDir, ".skaile", "model-endpoint.json");
|
|
31
|
+
let text;
|
|
32
|
+
try {
|
|
33
|
+
text = await readFile(file, "utf8");
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error.code === "ENOENT") return null;
|
|
36
|
+
throw new Error(`${PREFIX}: ${file} could not be read`);
|
|
37
|
+
}
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
parsed = JSON.parse(text);
|
|
41
|
+
} catch {
|
|
42
|
+
throw new Error(`${PREFIX}: ${file} is not valid JSON`);
|
|
43
|
+
}
|
|
44
|
+
const record = isPlainObject(parsed) ? parsed : {};
|
|
45
|
+
const url = typeof record.url === "string" ? record.url.trim() : "";
|
|
46
|
+
const token = typeof record.token === "string" ? record.token.trim() : "";
|
|
47
|
+
if (!url || !token) {
|
|
48
|
+
throw new Error(`${PREFIX}: ${file} needs a non-empty "url" and "token"`);
|
|
49
|
+
}
|
|
50
|
+
if (!isHttpUrl(url)) {
|
|
51
|
+
throw new Error(`${PREFIX}: ${file} "url" is not an http(s) URL`);
|
|
52
|
+
}
|
|
53
|
+
return { url, token };
|
|
54
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
/** What {@link migrateLegacyProviderSettings} did to one settings file. */
|
|
14
|
+
export interface LegacySettingsMigration {
|
|
15
|
+
/** The file that was inspected. */
|
|
16
|
+
path: string;
|
|
17
|
+
/** Whether the file was rewritten. */
|
|
18
|
+
changed: boolean;
|
|
19
|
+
/** The selector `provider` + `model` collapsed into, when that happened. */
|
|
20
|
+
model?: string;
|
|
21
|
+
/** Everything dropped or left untouched, and why. Secret-free. */
|
|
22
|
+
warnings: string[];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Migrate one legacy `settings.json` in place. Idempotent: a file with no
|
|
26
|
+
* legacy `provider` and no `apiKeys` is left byte-identical.
|
|
27
|
+
*
|
|
28
|
+
* An `apiKeys` map is removed from the file and never stored anywhere else:
|
|
29
|
+
* the registry holds references only, and a plaintext key has no safe
|
|
30
|
+
* automatic destination. Each non-empty entry is reported through `warnings`
|
|
31
|
+
* (without the key) so the user can move it into `.env`.
|
|
32
|
+
*
|
|
33
|
+
* @param settingsPath - Absolute path to a `settings.json`.
|
|
34
|
+
*/
|
|
35
|
+
export declare function migrateLegacyProviderSettings(settingsPath: string): LegacySettingsMigration;
|
|
@@ -1,9 +1,4 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
3
|
-
entriesOfKind,
|
|
4
|
-
selectVoiceEntryId,
|
|
5
|
-
VOICE_OVERRIDE_PARAM
|
|
6
|
-
} from "../shared/provider-registry.js";
|
|
7
2
|
export function migrateLegacyProviderSettings(settingsPath) {
|
|
8
3
|
const result = {
|
|
9
4
|
path: settingsPath,
|
|
@@ -53,29 +48,3 @@ export function migrateLegacyProviderSettings(settingsPath) {
|
|
|
53
48
|
}
|
|
54
49
|
return result;
|
|
55
50
|
}
|
|
56
|
-
export function resolveVoiceProvider(registry, kind, settings = {}, overrides = {}) {
|
|
57
|
-
const candidates = entriesOfKind(registry, kind);
|
|
58
|
-
if (candidates.length === 0) return void 0;
|
|
59
|
-
const warnings = [];
|
|
60
|
-
const wanted = (kind === "stt" ? settings.sttProvider : settings.ttsProvider)?.trim();
|
|
61
|
-
const ids = candidates.map(([id]) => id);
|
|
62
|
-
const providerId = selectVoiceEntryId(ids, wanted);
|
|
63
|
-
const entry = candidates[ids.indexOf(providerId)][1];
|
|
64
|
-
if (wanted && wanted !== providerId) {
|
|
65
|
-
warnings.push(
|
|
66
|
-
`settings name '${wanted}' for ${kind}, which is not an enabled ${kind} entry \u2014 using '${providerId}' instead`
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
const fallback = wanted !== providerId;
|
|
70
|
-
const settingsOverride = {};
|
|
71
|
-
const override = (kind === "stt" ? settings.sttLanguage : settings.ttsVoice)?.trim();
|
|
72
|
-
if (override) settingsOverride[VOICE_OVERRIDE_PARAM[kind]] = override;
|
|
73
|
-
return {
|
|
74
|
-
providerId,
|
|
75
|
-
entry,
|
|
76
|
-
...entry.base_url ? { baseUrl: entry.base_url } : {},
|
|
77
|
-
params: { ...entry.params, ...settingsOverride, ...overrides },
|
|
78
|
-
fallback,
|
|
79
|
-
warnings
|
|
80
|
-
};
|
|
81
|
-
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Storage stays with the host: this module reads and writes nothing except the
|
|
5
5
|
* one `settings.json` {@link migrateLegacyHarnessSettings} is pointed at.
|
|
6
6
|
*/
|
|
7
|
-
import type { SkaileSettings } from "
|
|
7
|
+
import type { SkaileSettings } from "./settings-types.js";
|
|
8
8
|
/**
|
|
9
9
|
* Merged default settings for forge apps.
|
|
10
10
|
*
|
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
import type { SkaileSettings as CoreSettings } from "@skaile/workspaces/sdk/core";
|
|
9
9
|
/** Skill domain enablement configuration for a forge app installation. */
|
|
@@ -55,6 +55,13 @@ export interface SkaileSettings extends Omit<CoreSettings, "apiKeys"> {
|
|
|
55
55
|
* harness, all of them) is used automatically.
|
|
56
56
|
*/
|
|
57
57
|
harnessProviders?: Record<string, string[]>;
|
|
58
|
+
/**
|
|
59
|
+
* Per-harness model per task ("roles"): role id → model selector
|
|
60
|
+
* (`"<providerId>/<modelId>"`), keyed by harness id. Only harnesses with
|
|
61
|
+
* `HARNESS_ROLES` take roles (omp, Continue); hosts pass the session
|
|
62
|
+
* harness's map to `skaile serve --roles`. Frozen by `lockModel`.
|
|
63
|
+
*/
|
|
64
|
+
harnessRoles?: Record<string, Record<string, string>>;
|
|
58
65
|
/**
|
|
59
66
|
* Instance allow-list of harness ids the harness picker offers. Absent means
|
|
60
67
|
* `DEFAULT_ENABLED_HARNESSES`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skaile/forge-settings",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Provider and settings operations for Skaile forge apps: provider registry layers, credential store, env layers, model discovery, the providers panel, and editor / AI SDK model adapters.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "bun@1.3.9",
|
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
"./editor": {
|
|
27
27
|
"types": "./dist/runtime/editor/index.d.ts",
|
|
28
28
|
"import": "./dist/runtime/editor/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./workspaces": {
|
|
31
|
+
"types": "./dist/runtime/workspaces/index.d.ts",
|
|
32
|
+
"import": "./dist/runtime/workspaces/index.js"
|
|
29
33
|
}
|
|
30
34
|
},
|
|
31
35
|
"files": [
|
|
@@ -55,7 +59,7 @@
|
|
|
55
59
|
},
|
|
56
60
|
"dependencies": {
|
|
57
61
|
"@nuxt/kit": "^4.0.0",
|
|
58
|
-
"@skaile/
|
|
62
|
+
"@skaile/provider-registry": "^0.1.0",
|
|
59
63
|
"ofetch": "^1.4.0",
|
|
60
64
|
"yaml": "^2.9.0"
|
|
61
65
|
},
|
|
@@ -65,6 +69,7 @@
|
|
|
65
69
|
"@ai-sdk/openai-compatible": "^3.0.0",
|
|
66
70
|
"@nuxt/ui": "^4.11.1",
|
|
67
71
|
"@skaile/forge-editor": "^6.0.0",
|
|
72
|
+
"@skaile/workspaces": "^7.8.0 || ^8.0.0 || ^9.0.0 || ^10.0.0",
|
|
68
73
|
"ai": "^7.0.0",
|
|
69
74
|
"nuxt": "^4.0.0",
|
|
70
75
|
"vue": "^3.5.0"
|
|
@@ -85,6 +90,9 @@
|
|
|
85
90
|
"@skaile/forge-editor": {
|
|
86
91
|
"optional": true
|
|
87
92
|
},
|
|
93
|
+
"@skaile/workspaces": {
|
|
94
|
+
"optional": true
|
|
95
|
+
},
|
|
88
96
|
"ai": {
|
|
89
97
|
"optional": true
|
|
90
98
|
},
|
|
@@ -102,6 +110,7 @@
|
|
|
102
110
|
"@nuxt/schema": "^4.0.0",
|
|
103
111
|
"@nuxt/ui": "^4.11.1",
|
|
104
112
|
"@skaile/forge-editor": "^6.4.2",
|
|
113
|
+
"@skaile/workspaces": "^10.0.0",
|
|
105
114
|
"@types/bun": "^1.2.0",
|
|
106
115
|
"@types/node": "^22.0.0",
|
|
107
116
|
"ai": "^7.0.155",
|