@skaile/forge-settings 1.2.2 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +31 -2
- package/dist/module.d.mts +4 -2
- package/dist/module.json +1 -1
- package/dist/module.mjs +5 -0
- package/dist/runtime/components/ForgeModelRolesPanel.d.vue.ts +34 -0
- package/dist/runtime/components/ForgeModelRolesPanel.vue +250 -0
- package/dist/runtime/components/ForgeModelRolesPanel.vue.d.ts +34 -0
- package/dist/runtime/components/ForgeProvidersPanel.d.vue.ts +18 -5
- package/dist/runtime/components/ForgeProvidersPanel.vue +32 -7
- package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +18 -5
- package/dist/runtime/server/index.d.ts +1 -0
- package/dist/runtime/server/index.js +1 -0
- package/dist/runtime/shared/model-roles.d.ts +99 -0
- package/dist/runtime/shared/model-roles.js +144 -0
- package/dist/runtime/vue/index.d.ts +3 -0
- package/dist/runtime/vue/index.js +3 -0
- package/dist/runtime/vue/model-roles-panel.d.ts +70 -0
- package/dist/runtime/vue/model-roles-panel.js +54 -0
- package/dist/runtime/vue/providers-client.d.ts +67 -0
- package/dist/runtime/vue/providers-client.js +28 -0
- package/dist/runtime/vue/providers-panel-model.d.ts +24 -4
- package/dist/runtime/vue/providers-panel-model.js +5 -0
- package/dist/runtime/vue/useProvidersPanel.d.ts +10 -2
- package/dist/runtime/vue/useProvidersPanel.js +67 -43
- package/package.json +1 -1
- package/src/runtime/components/ForgeModelRolesPanel.vue +312 -0
- package/src/runtime/components/ForgeProvidersPanel.vue +55 -20
- package/src/runtime/server/index.ts +1 -0
- package/src/runtime/shared/model-roles.ts +283 -0
- package/src/runtime/vue/index.ts +3 -0
- package/src/runtime/vue/model-roles-panel.ts +140 -0
- package/src/runtime/vue/providers-client.ts +100 -0
- package/src/runtime/vue/providers-panel-model.ts +34 -4
- package/src/runtime/vue/useProvidersPanel.ts +89 -45
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model roles: the named purposes a host app picks a model (or voice provider)
|
|
3
|
+
* for: "the assistant", "the decision model", "speech to text".
|
|
4
|
+
*
|
|
5
|
+
* A role is a host-declared slot; the assignment (role id to selector) is host
|
|
6
|
+
* state, stored wherever the host keeps settings. This module is the catalogue,
|
|
7
|
+
* the validation of assignments against a provider registry and the resolution
|
|
8
|
+
* of one role. Browser-safe: no `node:` imports, so a settings page and the
|
|
9
|
+
* server validate with the same code.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { ModelResolutionError } from "../model/errors";
|
|
13
|
+
import {
|
|
14
|
+
type AIProviderEntry,
|
|
15
|
+
type AIProviderRegistryView,
|
|
16
|
+
isEntryEnabled,
|
|
17
|
+
isMergedRegistry,
|
|
18
|
+
} from "./provider-registry";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What a role assigns, by kind. The registry kinds are `llm | stt | tts |
|
|
22
|
+
* embed` (see `AIProviderKind`); a role kind maps onto them:
|
|
23
|
+
*
|
|
24
|
+
* - `chat`, `decision` and `image` assign a selector `<entryId>/<modelId>` on
|
|
25
|
+
* an enabled `llm` registry entry. `image` offers every model of the `llm`
|
|
26
|
+
* entries, because model discovery carries no modality.
|
|
27
|
+
* - `tts` and `stt` assign the *id* of an enabled voice entry of that kind
|
|
28
|
+
* (the same ids `selectVoiceEntryId` chooses between).
|
|
29
|
+
*
|
|
30
|
+
* Naming against omp's model roles: `decision` is omp's `judge`, `tts` its
|
|
31
|
+
* `speech`, `stt` its `dictation`.
|
|
32
|
+
*/
|
|
33
|
+
export const MODEL_ROLE_KINDS = ["chat", "decision", "image", "tts", "stt"] as const;
|
|
34
|
+
|
|
35
|
+
export type ModelRoleKind = (typeof MODEL_ROLE_KINDS)[number];
|
|
36
|
+
|
|
37
|
+
/** One role a host declares. `enabled: false` hides it and refuses assignments. */
|
|
38
|
+
export interface ModelRoleDefinition {
|
|
39
|
+
/** Stable key, used in assignments and as the `@role` alias. */
|
|
40
|
+
id: string;
|
|
41
|
+
kind: ModelRoleKind;
|
|
42
|
+
/** Short name a person reads. */
|
|
43
|
+
label: string;
|
|
44
|
+
/** What the role is used for. */
|
|
45
|
+
description: string;
|
|
46
|
+
enabled: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Role id to selector: `<entryId>/<modelId>` for `chat`, `decision` and
|
|
51
|
+
* `image` roles, an entry id for `tts` and `stt` roles. An empty string or an
|
|
52
|
+
* absent key means unassigned.
|
|
53
|
+
*/
|
|
54
|
+
export type ModelRoleAssignments = Record<string, string>;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Roles most hosts could want beyond their own chat consumers. Spread them into
|
|
58
|
+
* the host's role list and flip `enabled` on the ones the app uses:
|
|
59
|
+
*
|
|
60
|
+
* ```ts
|
|
61
|
+
* const roles = STANDARD_MODEL_ROLES.map((role) => ({ ...role, enabled: role.id === "stt" }));
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export const STANDARD_MODEL_ROLES: readonly ModelRoleDefinition[] = [
|
|
65
|
+
{
|
|
66
|
+
id: "decision",
|
|
67
|
+
kind: "decision",
|
|
68
|
+
label: "Decision model",
|
|
69
|
+
description:
|
|
70
|
+
"A small, fast model for classifying and routing: yes/no checks, picking a branch, tagging.",
|
|
71
|
+
enabled: true,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: "image",
|
|
75
|
+
kind: "image",
|
|
76
|
+
label: "Image model",
|
|
77
|
+
description: "Generates or edits images.",
|
|
78
|
+
enabled: true,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: "tts",
|
|
82
|
+
kind: "tts",
|
|
83
|
+
label: "Text to speech",
|
|
84
|
+
description: "The voice provider that reads text aloud.",
|
|
85
|
+
enabled: true,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "stt",
|
|
89
|
+
kind: "stt",
|
|
90
|
+
label: "Speech to text",
|
|
91
|
+
description: "The voice provider that transcribes spoken input.",
|
|
92
|
+
enabled: true,
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
/** Why an assignment is refused. */
|
|
97
|
+
export type ModelRoleIssueCode =
|
|
98
|
+
| "unknown_role"
|
|
99
|
+
| "disabled_role"
|
|
100
|
+
| "bad_selector"
|
|
101
|
+
| "entry_missing"
|
|
102
|
+
| "entry_disabled"
|
|
103
|
+
| "entry_wrong_kind";
|
|
104
|
+
|
|
105
|
+
/** One refused assignment, worded for a person; `role` is the assignment key. */
|
|
106
|
+
export interface ModelRoleIssue {
|
|
107
|
+
role: string;
|
|
108
|
+
code: ModelRoleIssueCode;
|
|
109
|
+
message: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Whether a role kind assigns a voice entry rather than a model. */
|
|
113
|
+
function isVoiceRoleKind(kind: ModelRoleKind): kind is "tts" | "stt" {
|
|
114
|
+
return kind === "tts" || kind === "stt";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function entryOf(registry: AIProviderRegistryView, id: string): AIProviderEntry | undefined {
|
|
118
|
+
const entries = isMergedRegistry(registry) ? registry.providers : registry;
|
|
119
|
+
return Object.hasOwn(entries, id) ? entries[id] : undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `<entryId>/<modelId>` split on the first slash; both parts non-empty. */
|
|
123
|
+
function parseSelector(selector: string): { entryId: string; modelId: string } | undefined {
|
|
124
|
+
const slash = selector.indexOf("/");
|
|
125
|
+
if (slash <= 0 || slash === selector.length - 1) return undefined;
|
|
126
|
+
return { entryId: selector.slice(0, slash), modelId: selector.slice(slash + 1) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The declared role with this id, enabled or not. */
|
|
130
|
+
function findRole(
|
|
131
|
+
roles: readonly ModelRoleDefinition[],
|
|
132
|
+
id: string,
|
|
133
|
+
): ModelRoleDefinition | undefined {
|
|
134
|
+
return roles.find((role) => role.id === id);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The issue with one non-empty assignment, if any. */
|
|
138
|
+
function assignmentIssue(
|
|
139
|
+
role: ModelRoleDefinition | undefined,
|
|
140
|
+
roleId: string,
|
|
141
|
+
value: string,
|
|
142
|
+
registry: AIProviderRegistryView,
|
|
143
|
+
): ModelRoleIssue | undefined {
|
|
144
|
+
const issue = (code: ModelRoleIssueCode, message: string): ModelRoleIssue => ({
|
|
145
|
+
role: roleId,
|
|
146
|
+
code,
|
|
147
|
+
message,
|
|
148
|
+
});
|
|
149
|
+
if (!role) return issue("unknown_role", `Model role '${roleId}' does not exist.`);
|
|
150
|
+
if (!role.enabled) {
|
|
151
|
+
return issue("disabled_role", `Model role '${role.label}' (${roleId}) is disabled.`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const voice = isVoiceRoleKind(role.kind);
|
|
155
|
+
let entryId: string;
|
|
156
|
+
if (voice) {
|
|
157
|
+
if (value.includes("/")) {
|
|
158
|
+
return issue(
|
|
159
|
+
"bad_selector",
|
|
160
|
+
`Model role '${role.label}' (${roleId}) takes a provider id, not '${value}'.`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
entryId = value;
|
|
164
|
+
} else {
|
|
165
|
+
const parsed = parseSelector(value);
|
|
166
|
+
if (!parsed) {
|
|
167
|
+
return issue(
|
|
168
|
+
"bad_selector",
|
|
169
|
+
`Model role '${role.label}' (${roleId}) takes '<provider>/<model>', not '${value}'.`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
entryId = parsed.entryId;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const entry = entryOf(registry, entryId);
|
|
176
|
+
if (!entry) {
|
|
177
|
+
return issue(
|
|
178
|
+
"entry_missing",
|
|
179
|
+
`Model role '${role.label}' (${roleId}) points at provider '${entryId}', which is not in the registry.`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (!isEntryEnabled(entry)) {
|
|
183
|
+
return issue(
|
|
184
|
+
"entry_disabled",
|
|
185
|
+
`Model role '${role.label}' (${roleId}) points at provider '${entryId}', which is disabled.`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
const wanted = voice ? role.kind : "llm";
|
|
189
|
+
if (entry.kind !== wanted) {
|
|
190
|
+
return issue(
|
|
191
|
+
"entry_wrong_kind",
|
|
192
|
+
`Model role '${role.label}' (${roleId}) needs a '${wanted}' provider, but '${entryId}' is '${entry.kind}'.`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Check assignments against the declared roles and a registry. Unassigned
|
|
200
|
+
* values (absent or empty) are skipped. At most one issue per role id; the
|
|
201
|
+
* host turns them into 400s keyed by role.
|
|
202
|
+
*/
|
|
203
|
+
export function validateModelRoleAssignments(
|
|
204
|
+
roles: readonly ModelRoleDefinition[],
|
|
205
|
+
assignments: ModelRoleAssignments,
|
|
206
|
+
registry: AIProviderRegistryView,
|
|
207
|
+
): ModelRoleIssue[] {
|
|
208
|
+
const issues: ModelRoleIssue[] = [];
|
|
209
|
+
for (const [roleId, value] of Object.entries(assignments)) {
|
|
210
|
+
if (!value) continue;
|
|
211
|
+
const issue = assignmentIssue(findRole(roles, roleId), roleId, value, registry);
|
|
212
|
+
if (issue) issues.push(issue);
|
|
213
|
+
}
|
|
214
|
+
return issues;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** What a role points at. */
|
|
218
|
+
export type ResolvedModelRole =
|
|
219
|
+
| { kind: "model"; selector: string; entryId: string }
|
|
220
|
+
| { kind: "voice"; entryId: string }
|
|
221
|
+
| { kind: "unassigned" };
|
|
222
|
+
|
|
223
|
+
/** Inputs of {@link resolveModelRole}. */
|
|
224
|
+
export interface ResolveModelRoleContext {
|
|
225
|
+
roles: readonly ModelRoleDefinition[];
|
|
226
|
+
assignments: ModelRoleAssignments;
|
|
227
|
+
registry: AIProviderRegistryView;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Resolve one role to the registry entry it points at.
|
|
232
|
+
*
|
|
233
|
+
* @throws {ModelResolutionError} `unknown_selector` when the role is not
|
|
234
|
+
* declared or is disabled, or when its assignment dangles: the entry is
|
|
235
|
+
* gone, disabled, of the wrong kind, or the value is malformed.
|
|
236
|
+
*/
|
|
237
|
+
export function resolveModelRole(
|
|
238
|
+
roleId: string,
|
|
239
|
+
context: ResolveModelRoleContext,
|
|
240
|
+
): ResolvedModelRole {
|
|
241
|
+
const role = findRole(context.roles, roleId);
|
|
242
|
+
if (!role) {
|
|
243
|
+
throw new ModelResolutionError("unknown_selector", `Model role '${roleId}' does not exist.`);
|
|
244
|
+
}
|
|
245
|
+
if (!role.enabled) {
|
|
246
|
+
throw new ModelResolutionError(
|
|
247
|
+
"unknown_selector",
|
|
248
|
+
`Model role '${role.label}' (${roleId}) is disabled.`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
const value = Object.hasOwn(context.assignments, roleId) ? context.assignments[roleId] : undefined;
|
|
252
|
+
if (!value) return { kind: "unassigned" };
|
|
253
|
+
|
|
254
|
+
const issue = assignmentIssue(role, roleId, value, context.registry);
|
|
255
|
+
if (issue) {
|
|
256
|
+
const providerId = isVoiceRoleKind(role.kind) ? value : parseSelector(value)?.entryId;
|
|
257
|
+
throw new ModelResolutionError("unknown_selector", issue.message, {
|
|
258
|
+
...(providerId !== undefined && entryOf(context.registry, providerId) ? { providerId } : {}),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
if (isVoiceRoleKind(role.kind)) return { kind: "voice", entryId: value };
|
|
262
|
+
return { kind: "model", selector: value, entryId: value.slice(0, value.indexOf("/")) };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The `roles` map `resolveRegistryLanguageModel(selector, { roles })` accepts,
|
|
267
|
+
* so a `@role` selector reaches the assigned model: role id to selector for
|
|
268
|
+
* every enabled, assigned `chat`, `decision` and `image` role. Voice roles,
|
|
269
|
+
* unassigned and disabled roles, and assignments to undeclared roles are left
|
|
270
|
+
* out. Assignments are not validated here; see {@link validateModelRoleAssignments}.
|
|
271
|
+
*/
|
|
272
|
+
export function modelRoleAliases(
|
|
273
|
+
roles: readonly ModelRoleDefinition[],
|
|
274
|
+
assignments: ModelRoleAssignments,
|
|
275
|
+
): Record<string, string> {
|
|
276
|
+
const aliases: Record<string, string> = {};
|
|
277
|
+
for (const role of roles) {
|
|
278
|
+
if (!role.enabled || isVoiceRoleKind(role.kind)) continue;
|
|
279
|
+
const value = Object.hasOwn(assignments, role.id) ? assignments[role.id] : undefined;
|
|
280
|
+
if (value) aliases[role.id] = value;
|
|
281
|
+
}
|
|
282
|
+
return aliases;
|
|
283
|
+
}
|
package/src/runtime/vue/index.ts
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
export * from "../shared/env-vars";
|
|
11
11
|
export * from "../shared/harness-providers";
|
|
12
12
|
export * from "../shared/harnesses";
|
|
13
|
+
export * from "../shared/model-roles";
|
|
13
14
|
export * from "../shared/provider-registry";
|
|
15
|
+
export * from "./model-roles-panel";
|
|
16
|
+
export * from "./providers-client";
|
|
14
17
|
export * from "./settings-modal-scope";
|
|
15
18
|
export * from "./useProvidersPanel";
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-roles panel's logic: which registry entries a role kind offers,
|
|
3
|
+
* how an assignment splits into provider and model, and what a row shows.
|
|
4
|
+
* No Vue, no requests, no `node:` imports; `ForgeModelRolesPanel` renders it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
ModelRoleAssignments,
|
|
9
|
+
ModelRoleDefinition,
|
|
10
|
+
ModelRoleKind,
|
|
11
|
+
} from "../shared/model-roles";
|
|
12
|
+
import type { AIProviderKind } from "../shared/provider-registry";
|
|
13
|
+
import type { ProvidersPanelEntry } from "./providers-panel-model";
|
|
14
|
+
|
|
15
|
+
/** What the roles panel needs of a providers client; `ProvidersPanelClient` satisfies it. */
|
|
16
|
+
export interface ModelRolesPanelClient {
|
|
17
|
+
/** Merged registry view including disabled entries. */
|
|
18
|
+
list(): Promise<ProvidersPanelEntry[]>;
|
|
19
|
+
/** The models one entry offers. */
|
|
20
|
+
models(id: string): Promise<ModelRolesModelsResult>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The result of `ModelRolesPanelClient.models`. */
|
|
24
|
+
export interface ModelRolesModelsResult {
|
|
25
|
+
status: "live" | "static" | "unsupported";
|
|
26
|
+
models: { id: string; name: string }[];
|
|
27
|
+
message?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One option of a provider or model select. */
|
|
31
|
+
export interface ModelRoleOption {
|
|
32
|
+
label: string;
|
|
33
|
+
value: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The registry entry kind a role kind assigns from. */
|
|
37
|
+
export function roleEntryKind(kind: ModelRoleKind): AIProviderKind {
|
|
38
|
+
if (kind === "tts") return "tts";
|
|
39
|
+
if (kind === "stt") return "stt";
|
|
40
|
+
return "llm";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether the role assigns an entry id (`tts`, `stt`) rather than a model. */
|
|
44
|
+
export function isVoiceRole(kind: ModelRoleKind): boolean {
|
|
45
|
+
return kind === "tts" || kind === "stt";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The enabled entries of the kind this role assigns from. */
|
|
49
|
+
export function entriesForRole(
|
|
50
|
+
kind: ModelRoleKind,
|
|
51
|
+
entries: readonly ProvidersPanelEntry[],
|
|
52
|
+
): ProvidersPanelEntry[] {
|
|
53
|
+
const wanted = roleEntryKind(kind);
|
|
54
|
+
return entries.filter((entry) => entry.enabled && entry.kind === wanted);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `<entryId>/<modelId>`. */
|
|
58
|
+
export function buildSelector(entryId: string, modelId: string): string {
|
|
59
|
+
return `${entryId}/${modelId}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Splits on the first slash (model ids may contain slashes); no slash means no model. */
|
|
63
|
+
export function splitSelector(selector: string): { entryId: string; modelId: string } {
|
|
64
|
+
const slash = selector.indexOf("/");
|
|
65
|
+
if (slash < 0) return { entryId: selector, modelId: "" };
|
|
66
|
+
return { entryId: selector.slice(0, slash), modelId: selector.slice(slash + 1) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The assignment of a role; inherited keys such as `constructor` are not assignments. */
|
|
70
|
+
export function assignmentOf(assignments: ModelRoleAssignments, roleId: string): string | undefined {
|
|
71
|
+
return Object.hasOwn(assignments, roleId) ? assignments[roleId] : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The assignments with one role set; an empty value unassigns it. Never mutates. */
|
|
75
|
+
export function withAssignment(
|
|
76
|
+
assignments: ModelRoleAssignments,
|
|
77
|
+
roleId: string,
|
|
78
|
+
value: string,
|
|
79
|
+
): ModelRoleAssignments {
|
|
80
|
+
const next: ModelRoleAssignments = { ...assignments };
|
|
81
|
+
if (value === "") delete next[roleId];
|
|
82
|
+
else next[roleId] = value;
|
|
83
|
+
return next;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What one row shows. */
|
|
87
|
+
export interface ModelRoleRowState {
|
|
88
|
+
/** The provider the row has: the pending pick, else the assignment's. Empty when none. */
|
|
89
|
+
entryId: string;
|
|
90
|
+
/** The assigned model id (model roles only). */
|
|
91
|
+
modelId: string;
|
|
92
|
+
/** Whether the role has an assignment stored. */
|
|
93
|
+
assigned: boolean;
|
|
94
|
+
/** Enabled entries of the role's kind, plus the current one when it is unavailable. */
|
|
95
|
+
providerOptions: ModelRoleOption[];
|
|
96
|
+
/** Set when `entryId` is not an enabled entry of the role's kind. */
|
|
97
|
+
warning?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The state of one row. `pendingEntryId` is a provider picked on a model role
|
|
102
|
+
* before a model exists: an assignment needs both, so the pick lives in the
|
|
103
|
+
* panel until the model is chosen.
|
|
104
|
+
*/
|
|
105
|
+
export function modelRoleRowState(
|
|
106
|
+
role: ModelRoleDefinition,
|
|
107
|
+
assignment: string | undefined,
|
|
108
|
+
entries: readonly ProvidersPanelEntry[],
|
|
109
|
+
pendingEntryId?: string,
|
|
110
|
+
): ModelRoleRowState {
|
|
111
|
+
const value = assignment ?? "";
|
|
112
|
+
const voice = isVoiceRole(role.kind);
|
|
113
|
+
const parsed = voice ? { entryId: value, modelId: "" } : splitSelector(value);
|
|
114
|
+
const entryId = pendingEntryId ?? parsed.entryId;
|
|
115
|
+
// A pick on another provider drops the old provider's model.
|
|
116
|
+
const modelId = entryId === parsed.entryId ? parsed.modelId : "";
|
|
117
|
+
const offered = entriesForRole(role.kind, entries);
|
|
118
|
+
const providerOptions = offered.map((entry) => ({ label: entry.name, value: entry.id }));
|
|
119
|
+
let warning: string | undefined;
|
|
120
|
+
if (entryId !== "" && !offered.some((entry) => entry.id === entryId)) {
|
|
121
|
+
providerOptions.push({ label: `${entryId} (unavailable)`, value: entryId });
|
|
122
|
+
warning = `Provider "${entryId}" is not available`;
|
|
123
|
+
}
|
|
124
|
+
return { entryId, modelId, assigned: value !== "", providerOptions, warning };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Options of a model select; the current model stays listed when the provider no longer offers it. */
|
|
128
|
+
export function modelOptions(
|
|
129
|
+
models: readonly { id: string; name: string }[],
|
|
130
|
+
currentModelId: string,
|
|
131
|
+
): ModelRoleOption[] {
|
|
132
|
+
const options = models.map((model) => ({
|
|
133
|
+
label: model.name && model.name !== model.id ? `${model.name} (${model.id})` : model.id,
|
|
134
|
+
value: model.id,
|
|
135
|
+
}));
|
|
136
|
+
if (currentModelId !== "" && !models.some((model) => model.id === currentModelId)) {
|
|
137
|
+
options.push({ label: `${currentModelId} (not listed)`, value: currentModelId });
|
|
138
|
+
}
|
|
139
|
+
return options;
|
|
140
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transport of `ForgeProvidersPanel` (and the models of
|
|
3
|
+
* `ForgeModelRolesPanel`): one method per call the panels make. The default is
|
|
4
|
+
* {@link urlProvidersClient}, plain `ofetch` against the routes the panel
|
|
5
|
+
* always used; a host that backs the same screens with something other than
|
|
6
|
+
* HTTP routes (server actions, RPC) passes its own client. No Vue, no `node:`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { $fetch } from "ofetch";
|
|
10
|
+
import type { EnvSettingsDocument, EnvSettingsUpdate } from "../shared/env-vars";
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_PRESETS_URL,
|
|
13
|
+
type ProvidersPanelEntry,
|
|
14
|
+
type ProvidersPanelPreset,
|
|
15
|
+
} from "./providers-panel-model";
|
|
16
|
+
|
|
17
|
+
/** The models of one provider entry, as `ProvidersPanelClient.models` answers. */
|
|
18
|
+
export interface ProviderModelsResult {
|
|
19
|
+
/**
|
|
20
|
+
* `live`: asked the provider's own endpoint; `static`: a fixed list from the
|
|
21
|
+
* catalog; `unsupported`: the entry cannot list models (free-text entry).
|
|
22
|
+
*/
|
|
23
|
+
status: "live" | "static" | "unsupported";
|
|
24
|
+
models: { id: string; name: string }[];
|
|
25
|
+
/** Why the list is empty or not live. Secret-free. */
|
|
26
|
+
message?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The outcome of one provider credential test. */
|
|
30
|
+
export interface ProvidersPanelTestResult {
|
|
31
|
+
ok: boolean;
|
|
32
|
+
/** What the provider answered or why it refused. Secret-free. */
|
|
33
|
+
message: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Every call `ForgeProvidersPanel` and `ForgeModelRolesPanel` make. */
|
|
37
|
+
export interface ProvidersPanelClient {
|
|
38
|
+
/** The merged view, disabled entries included. */
|
|
39
|
+
list(): Promise<ProvidersPanelEntry[]>;
|
|
40
|
+
/** Presets whose key is set in the scope's environment. */
|
|
41
|
+
detected(): Promise<ProvidersPanelPreset[]>;
|
|
42
|
+
/** The catalog presets. */
|
|
43
|
+
presets(): Promise<ProvidersPanelPreset[]>;
|
|
44
|
+
/** Create or change an entry; the body is the registry entry's editable fields. */
|
|
45
|
+
upsert(id: string, body: Record<string, unknown>): Promise<void>;
|
|
46
|
+
/** Delete an entry of the scope's own document. */
|
|
47
|
+
remove(id: string): Promise<void>;
|
|
48
|
+
/** The scope's `.env` document. */
|
|
49
|
+
environment(): Promise<EnvSettingsDocument>;
|
|
50
|
+
/** Write variables to the scope's `.env`. */
|
|
51
|
+
updateEnvironment(update: EnvSettingsUpdate): Promise<void>;
|
|
52
|
+
/** The models of one entry. */
|
|
53
|
+
models(id: string): Promise<ProviderModelsResult>;
|
|
54
|
+
/** Check that the entry's credential works. */
|
|
55
|
+
test(id: string): Promise<ProvidersPanelTestResult>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The URLs {@link urlProvidersClient} calls. */
|
|
59
|
+
export interface UrlProvidersClientUrls {
|
|
60
|
+
/** `GET ${providersUrl}?all=1`: the merged view. */
|
|
61
|
+
providersUrl: string;
|
|
62
|
+
/** `PUT|DELETE ${providersWriteUrl}/:id`; `GET ${providersWriteUrl}/detected`; `GET .../:id/models`; `POST .../:id/test`. */
|
|
63
|
+
providersWriteUrl: string;
|
|
64
|
+
/** Catalog presets. Default `/api/providers/presets`. */
|
|
65
|
+
presetsUrl?: string;
|
|
66
|
+
/** The scope's `.env` route. Omitted, `environment` and `updateEnvironment` reject. */
|
|
67
|
+
environmentUrl?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The HTTP transport: the requests the panel made before it took a client.
|
|
72
|
+
* `models` and `test` call routes skaile-forge does not serve; they are for
|
|
73
|
+
* hosts that do.
|
|
74
|
+
*/
|
|
75
|
+
export function urlProvidersClient(urls: UrlProvidersClientUrls): ProvidersPanelClient {
|
|
76
|
+
const entryUrl = (id: string) => `${urls.providersWriteUrl}/${encodeURIComponent(id)}`;
|
|
77
|
+
const environmentUrl = (): string => {
|
|
78
|
+
if (!urls.environmentUrl) throw new Error("This scope has no environment route.");
|
|
79
|
+
return urls.environmentUrl;
|
|
80
|
+
};
|
|
81
|
+
return {
|
|
82
|
+
list: () =>
|
|
83
|
+
$fetch(urls.providersUrl, { query: { all: "1" } }) as Promise<ProvidersPanelEntry[]>,
|
|
84
|
+
detected: () => $fetch(`${urls.providersWriteUrl}/detected`) as Promise<ProvidersPanelPreset[]>,
|
|
85
|
+
presets: () =>
|
|
86
|
+
$fetch(urls.presetsUrl ?? DEFAULT_PRESETS_URL) as Promise<ProvidersPanelPreset[]>,
|
|
87
|
+
upsert: async (id, body) => {
|
|
88
|
+
await $fetch(entryUrl(id), { method: "PUT", body });
|
|
89
|
+
},
|
|
90
|
+
remove: async (id) => {
|
|
91
|
+
await $fetch(entryUrl(id), { method: "DELETE" });
|
|
92
|
+
},
|
|
93
|
+
environment: async () => (await $fetch(environmentUrl())) as EnvSettingsDocument,
|
|
94
|
+
updateEnvironment: async (update) => {
|
|
95
|
+
await $fetch(environmentUrl(), { method: "PUT", body: update });
|
|
96
|
+
},
|
|
97
|
+
models: (id) => $fetch(`${entryUrl(id)}/models`) as Promise<ProviderModelsResult>,
|
|
98
|
+
test: (id) => $fetch(`${entryUrl(id)}/test`, { method: "POST" }) as Promise<ProvidersPanelTestResult>,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -11,16 +11,23 @@ import type {
|
|
|
11
11
|
AIProviderKind,
|
|
12
12
|
AIProviderSurface,
|
|
13
13
|
} from "../shared/provider-registry";
|
|
14
|
+
import type { ProvidersPanelClient } from "./providers-client";
|
|
14
15
|
|
|
15
16
|
/** Registry document a provider entry is defined in. */
|
|
16
17
|
export type ProvidersPanelLayer = "instance" | "workspace";
|
|
17
18
|
|
|
18
19
|
/** Props both `ForgeProvidersPanel` variants declare. */
|
|
19
20
|
export interface ProvidersPanelProps {
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
/**
|
|
22
|
+
* The panel's transport. Without it the panel talks to the URL props below
|
|
23
|
+
* through {@link urlProvidersClient}; with it the URL props are ignored. An
|
|
24
|
+
* explicit client also turns on the per-row Test button.
|
|
25
|
+
*/
|
|
26
|
+
client?: ProvidersPanelClient;
|
|
27
|
+
/** `GET` merged view for this scope (`/api/providers` | `/api/workspaces/:id/providers`). Required without `client`. */
|
|
28
|
+
providersUrl?: string;
|
|
29
|
+
/** `PUT|DELETE ${providersWriteUrl}/:id`; `GET ${providersWriteUrl}/detected`. Required without `client`. */
|
|
30
|
+
providersWriteUrl?: string;
|
|
24
31
|
/** Catalog preset summaries. Default `/api/providers/presets`. */
|
|
25
32
|
presetsUrl?: string;
|
|
26
33
|
/**
|
|
@@ -28,10 +35,21 @@ export interface ProvidersPanelProps {
|
|
|
28
35
|
* into the panel write through it. Omitted, no key can be entered.
|
|
29
36
|
*/
|
|
30
37
|
environmentUrl?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Whether the panel reads and writes the scope's environment (key entry,
|
|
40
|
+
* the environment document). Default: `Boolean(environmentUrl)` without
|
|
41
|
+
* `client`, `true` with one.
|
|
42
|
+
*/
|
|
43
|
+
environmentEnabled?: boolean;
|
|
31
44
|
/** The scope's own registry document. */
|
|
32
45
|
layer: ProvidersPanelLayer;
|
|
33
46
|
/** Disables every write (non-admin at instance scope; provider lock). */
|
|
34
47
|
readOnly?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* The line above the provider list. Absent: the panel's own text for
|
|
50
|
+
* `layer`; a string replaces it; `false` hides it (Add provider stays).
|
|
51
|
+
*/
|
|
52
|
+
note?: string | false;
|
|
35
53
|
}
|
|
36
54
|
|
|
37
55
|
/** Emits both variants declare. */
|
|
@@ -148,6 +166,18 @@ export const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
|
|
|
148
166
|
|
|
149
167
|
export const DEFAULT_PRESETS_URL = "/api/providers/presets";
|
|
150
168
|
|
|
169
|
+
/** The line `ForgeProvidersPanel` shows above its list; `null` = none. */
|
|
170
|
+
export function providersPanelNote(
|
|
171
|
+
note: string | false | undefined,
|
|
172
|
+
layer: ProvidersPanelLayer,
|
|
173
|
+
): string | null {
|
|
174
|
+
if (note === false) return null;
|
|
175
|
+
if (note !== undefined) return note;
|
|
176
|
+
return layer === "workspace"
|
|
177
|
+
? "Entries of this workspace's providers.yml can be changed here; instance entries are inherited. Keys stay in the environment; an entry only references them."
|
|
178
|
+
: "The instance registry, $FORGE_HOME/.skaile/providers.yml. Keys stay in the environment; an entry only references them.";
|
|
179
|
+
}
|
|
180
|
+
|
|
151
181
|
/** An `env:NAME` credential reference; group 1 is the variable name. */
|
|
152
182
|
export const ENV_REF_PATTERN = /^env:(.+)$/;
|
|
153
183
|
|