@skaile/forge-settings 1.2.1 → 1.3.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 (34) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +29 -2
  3. package/dist/module.d.mts +4 -2
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +5 -0
  6. package/dist/runtime/components/ForgeModelRolesPanel.d.vue.ts +34 -0
  7. package/dist/runtime/components/ForgeModelRolesPanel.vue +250 -0
  8. package/dist/runtime/components/ForgeModelRolesPanel.vue.d.ts +34 -0
  9. package/dist/runtime/components/ForgeProvidersPanel.d.vue.ts +15 -5
  10. package/dist/runtime/components/ForgeProvidersPanel.vue +24 -2
  11. package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +15 -5
  12. package/dist/runtime/server/index.d.ts +1 -0
  13. package/dist/runtime/server/index.js +1 -0
  14. package/dist/runtime/shared/model-roles.d.ts +99 -0
  15. package/dist/runtime/shared/model-roles.js +144 -0
  16. package/dist/runtime/vue/index.d.ts +3 -0
  17. package/dist/runtime/vue/index.js +3 -0
  18. package/dist/runtime/vue/model-roles-panel.d.ts +70 -0
  19. package/dist/runtime/vue/model-roles-panel.js +54 -0
  20. package/dist/runtime/vue/providers-client.d.ts +67 -0
  21. package/dist/runtime/vue/providers-client.js +28 -0
  22. package/dist/runtime/vue/providers-panel-model.d.ts +17 -4
  23. package/dist/runtime/vue/useProvidersPanel.d.ts +10 -2
  24. package/dist/runtime/vue/useProvidersPanel.js +67 -43
  25. package/package.json +3 -3
  26. package/src/runtime/components/ForgeModelRolesPanel.vue +312 -0
  27. package/src/runtime/components/ForgeProvidersPanel.vue +46 -14
  28. package/src/runtime/server/index.ts +1 -0
  29. package/src/runtime/shared/model-roles.ts +283 -0
  30. package/src/runtime/vue/index.ts +3 -0
  31. package/src/runtime/vue/model-roles-panel.ts +140 -0
  32. package/src/runtime/vue/providers-client.ts +100 -0
  33. package/src/runtime/vue/providers-panel-model.ts +17 -4
  34. package/src/runtime/vue/useProvidersPanel.ts +89 -45
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0
4
+
5
+ - Model roles (`/server` and `/vue`, browser-safe): `MODEL_ROLE_KINDS` (`chat`, `decision`, `image`, `tts`, `stt`), `ModelRoleDefinition`, `STANDARD_MODEL_ROLES` (`decision`, `image`, `tts`, `stt`), `validateModelRoleAssignments` (issues keyed by role id), `resolveModelRole` and `modelRoleAliases` (feeds `resolveRegistryLanguageModel({ roles })`). A dangling assignment throws `ModelResolutionError` (`unknown_selector`). omp naming: `decision` = `judge`, `tts` = `speech`, `stt` = `dictation`.
6
+ - `/vue`: `ProvidersPanelClient` and `urlProvidersClient`. `<ForgeProvidersPanel>` takes an optional `client` prop (the URL props build the same client as before, so existing hosts need no change; they become optional when `client` is set) and, with a client, a per-provider Test button.
7
+ - `/nuxt`: registers `<ForgeModelRolesPanel>` (provider and model select per enabled role, `status` slot, emits `update:assignments`, never writes).
8
+
9
+ ## 1.2.2
10
+
11
+ - Accepts `@skaile/workspaces` ^8.0.0. The only 8.0.0 break is the renamed managed-codex start, which this package does not touch; the provider registry, catalog and model-discovery APIs it uses are unchanged.
12
+
3
13
  ## 1.2.1
4
14
 
5
15
  - First release published by CI (`publish.yml`, npm trusted publishing). No code change.
package/README.md CHANGED
@@ -5,8 +5,8 @@ Provider and settings operations for Skaile forge apps, without the app's databa
5
5
  | Entry | Contents | Peers it needs |
6
6
  |---|---|---|
7
7
  | `/server` | registry layers and writes, credential store, `.env` layers, provider inventory and discovery, harness settings rules, credential policy | none |
8
- | `/vue` | `useProvidersPanel`, `SettingsModalScope`, browser-safe registry/env/harness vocabulary | `vue` |
9
- | `/nuxt` | Nuxt module registering `<ForgeProvidersPanel>` | `nuxt`, `@nuxt/ui` |
8
+ | `/vue` | `useProvidersPanel`, `urlProvidersClient`, `SettingsModalScope`, model-role helpers, browser-safe registry/env/harness vocabulary | `vue` |
9
+ | `/nuxt` | Nuxt module registering `<ForgeProvidersPanel>` and `<ForgeModelRolesPanel>` | `nuxt`, `@nuxt/ui` |
10
10
  | `/ai-sdk` | AI SDK `LanguageModel` of a registry selector | `ai` + the `@ai-sdk/*` provider the surface needs |
11
11
  | `/editor` | the editor's `ModelProvider` over a registry | `@skaile/forge-editor`, `@ai-sdk/openai-compatible` |
12
12
 
@@ -84,6 +84,33 @@ modules: ['@nuxt/ui', '@skaile/forge-settings/nuxt']
84
84
 
85
85
  The panel (`PUT|DELETE <writeUrl>/:id`, `GET <writeUrl>/detected`, `GET <providersUrl>`, `GET|PUT <environmentUrl>`) reads and writes only the routes you give it. The host serves them with the `/server` operations and decides who may call them. `SettingsModalScope` documents the same URL fields for hosts that compose a larger modal.
86
86
 
87
+ Without URLs, pass `:client` (a `ProvidersPanelClient`: `list`, `detected`, `presets`, `upsert`, `remove`, `environment`, `updateEnvironment`, `models`, `test`) and the panel talks through it; the URL props then become optional. A passed client also turns on the per-provider Test button. `urlProvidersClient({ providersUrl, providersWriteUrl, … })` is the client the URL props build; its `models(id)` and `test(id)` call `GET|POST <writeUrl>/:id/models|test`, routes only a host that wants those features serves.
88
+
89
+ ## Model roles
90
+
91
+ A *model role* is a named purpose a host picks a model for. The host declares roles and stores the assignments (role id to selector); this package supplies the vocabulary, validation, resolution and a picker.
92
+
93
+ | Kind | Assigns | Registry entry |
94
+ |---|---|---|
95
+ | `chat`, `decision`, `image` | `<entryId>/<modelId>` | enabled `llm` entry (`image` offers every model; discovery carries no modality) |
96
+ | `tts`, `stt` | the voice entry id | enabled entry of that kind |
97
+
98
+ omp names: `decision` = `judge`, `tts` = `speech`, `stt` = `dictation`.
99
+
100
+ ```ts
101
+ import { STANDARD_MODEL_ROLES, validateModelRoleAssignments, resolveModelRole, modelRoleAliases } from '@skaile/forge-settings/server' // or /vue
102
+
103
+ const roles = [
104
+ { id: 'assistant', kind: 'chat', label: 'Assistant', description: 'The in-app chat.', enabled: true },
105
+ ...STANDARD_MODEL_ROLES.map((role) => ({ ...role, enabled: false })), // decision, image, tts, stt: flip what the app uses
106
+ ]
107
+ validateModelRoleAssignments(roles, assignments, registry) // [{ role, code, message }]: one issue per role id, host turns them into 400s
108
+ resolveModelRole('assistant', { roles, assignments, registry }) // { kind: 'model', selector, entryId } | { kind: 'voice', entryId } | { kind: 'unassigned' }
109
+ resolveRegistryLanguageModel('@assistant', { roles: modelRoleAliases(roles, assignments) })
110
+ ```
111
+
112
+ A dangling assignment throws `ModelResolutionError` (`unknown_selector`). `<ForgeModelRolesPanel :roles :assignments :client :read-only @update:assignments>` renders one row per enabled role (provider select, model select from `client.models`, free text when the provider cannot list models, Clear, a `status` slot per row). It never writes; the host saves the emitted assignments.
113
+
87
114
  ## Development
88
115
 
89
116
  ```
package/dist/module.d.mts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { NuxtModule } from '@nuxt/schema';
2
2
 
3
- /** `@skaile/forge-settings/nuxt` has no options: it registers one component. */
3
+ /** `@skaile/forge-settings/nuxt` has no options: it registers two components. */
4
4
  type ForgeSettingsOptions = Record<string, never>;
5
5
  /**
6
6
  * Nuxt module registering `<ForgeProvidersPanel>`, the narrow provider
7
- * settings surface (registry editor, detected-in-environment adds, key entry).
7
+ * settings surface (registry editor, detected-in-environment adds, key entry),
8
+ * and `<ForgeModelRolesPanel>`, the per-role provider and model picker. The
9
+ * roles panel never writes: the host saves the assignments it emits.
8
10
  *
9
11
  * It installs no server route, no auto-import and no authorization: the host
10
12
  * serves the panel's routes with `@skaile/forge-settings/server` and decides who
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@skaile/forge-settings",
3
3
  "configKey": "forgeSettings",
4
- "version": "1.2.1",
4
+ "version": "1.3.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -17,6 +17,11 @@ const forgeSettingsModule = defineNuxtModule({
17
17
  filePath: resolve("./runtime/components/ForgeProvidersPanel.vue"),
18
18
  global: false
19
19
  });
20
+ addComponent({
21
+ name: "ForgeModelRolesPanel",
22
+ filePath: resolve("./runtime/components/ForgeModelRolesPanel.vue"),
23
+ global: false
24
+ });
20
25
  }
21
26
  });
22
27
 
@@ -0,0 +1,34 @@
1
+ import type { ModelRoleAssignments, ModelRoleDefinition } from "../shared/model-roles.js";
2
+ import { type ModelRolesPanelClient } from "../vue/model-roles-panel.js";
3
+ type __VLS_Props = {
4
+ /** Every role the host declares; only `enabled` ones are rendered. */
5
+ roles: ModelRoleDefinition[];
6
+ /** Role id to selector (`<entryId>/<modelId>`, or an entry id for `tts` / `stt`). */
7
+ assignments: ModelRoleAssignments;
8
+ /** Reads the registry and its models; a `ProvidersPanelClient` fits. */
9
+ client: ModelRolesPanelClient;
10
+ /** Disables every change. */
11
+ readOnly?: boolean;
12
+ };
13
+ type __VLS_Slots = {
14
+ /** Under a role's row: availability or check text the host knows. */
15
+ status?(props: {
16
+ role: ModelRoleDefinition;
17
+ }): unknown;
18
+ };
19
+ /** Reloads the registry and the models of every shown provider. */
20
+ declare function refresh(): Promise<void>;
21
+ declare const __VLS_component: import("vue").DefineComponent<__VLS_Props, {
22
+ refresh: typeof refresh;
23
+ }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
24
+ "update:assignments": (assignments: ModelRoleAssignments) => any;
25
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
26
+ "onUpdate:assignments"?: ((assignments: ModelRoleAssignments) => any) | undefined;
27
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
28
+ declare const _default: __VLS_WithSlots<typeof __VLS_component, __VLS_Slots>;
29
+ export default _default;
30
+ type __VLS_WithSlots<T, S> = T & {
31
+ new (): {
32
+ $slots: S;
33
+ };
34
+ };
@@ -0,0 +1,250 @@
1
+ <template>
2
+ <div data-testid="model-roles-panel" class="space-y-3">
3
+ <p v-if="loadError" data-testid="model-roles-error" class="text-sm text-error-500">{{ loadError }}</p>
4
+ <div v-else-if="loading && !loaded" class="text-xs text-foreground-subtle py-2 text-center">
5
+ Loading providers…
6
+ </div>
7
+ <div v-else-if="rows.length === 0" class="text-xs text-foreground-subtle py-2 text-center">
8
+ No model roles are enabled.
9
+ </div>
10
+
11
+ <div
12
+ v-for="row in rows"
13
+ :key="row.role.id"
14
+ :data-testid="`model-role-${row.role.id}`"
15
+ class="p-3 rounded-lg border border-surface-border space-y-2"
16
+ >
17
+ <div class="flex items-start gap-2">
18
+ <div class="flex-1 min-w-0">
19
+ <p class="text-sm font-medium text-foreground">{{ row.role.label }}</p>
20
+ <p class="text-xs text-foreground-muted leading-relaxed">{{ row.role.description }}</p>
21
+ </div>
22
+ <UBadge
23
+ v-if="!row.state.assigned"
24
+ size="sm"
25
+ variant="subtle"
26
+ color="neutral"
27
+ :data-testid="`model-role-unassigned-${row.role.id}`"
28
+ >Not assigned</UBadge>
29
+ </div>
30
+
31
+ <div class="flex items-start gap-2 flex-wrap">
32
+ <USelect
33
+ :model-value="row.state.entryId || void 0"
34
+ :items="row.state.providerOptions"
35
+ placeholder="Choose a provider"
36
+ :disabled="props.readOnly"
37
+ :data-testid="`model-role-provider-${row.role.id}`"
38
+ class="min-w-[12rem] bg-surface-ground border-surface-border text-foreground text-xs"
39
+ @update:model-value="onProvider(row.role, String($event ?? ''))"
40
+ />
41
+
42
+ <template v-if="!row.voice">
43
+ <USelect
44
+ v-if="row.modelChoices"
45
+ :model-value="row.state.modelId || void 0"
46
+ :items="row.modelChoices"
47
+ placeholder="Choose a model"
48
+ :disabled="props.readOnly"
49
+ :data-testid="`model-role-model-${row.role.id}`"
50
+ class="min-w-[16rem] bg-surface-ground border-surface-border text-foreground text-xs font-mono"
51
+ @update:model-value="onModel(row.role, String($event ?? ''))"
52
+ />
53
+ <UInput
54
+ v-else
55
+ :model-value="drafts[row.role.id] ?? row.state.modelId"
56
+ placeholder="Model id"
57
+ :disabled="props.readOnly || !row.state.entryId || row.modelsLoading"
58
+ :data-testid="`model-role-model-${row.role.id}`"
59
+ class="min-w-[16rem] bg-surface-ground border-surface-border text-foreground text-xs font-mono"
60
+ @update:model-value="drafts[row.role.id] = String($event ?? '')"
61
+ @blur="commitDraft(row.role)"
62
+ @keydown.enter="commitDraft(row.role)"
63
+ />
64
+ </template>
65
+
66
+ <UButton
67
+ v-if="!props.readOnly && (row.state.assigned || row.state.entryId)"
68
+ size="sm"
69
+ variant="ghost"
70
+ :aria-label="`Clear ${row.role.label}`"
71
+ :data-testid="`model-role-clear-${row.role.id}`"
72
+ @click="clear(row.role)"
73
+ >
74
+ <Icon name="lucide:x" class="mr-1.5 w-3.5 h-3.5" />Clear
75
+ </UButton>
76
+ </div>
77
+
78
+ <p
79
+ v-if="row.state.warning"
80
+ :data-testid="`model-role-warning-${row.role.id}`"
81
+ class="text-xs text-amber-600 dark:text-amber-400"
82
+ >{{ row.state.warning }}</p>
83
+ <p
84
+ v-if="row.modelsLoading"
85
+ class="text-xs text-foreground-subtle italic"
86
+ >Loading models…</p>
87
+ <p
88
+ v-else-if="row.modelsHint"
89
+ :data-testid="`model-role-hint-${row.role.id}`"
90
+ class="text-xs text-foreground-subtle italic"
91
+ >{{ row.modelsHint }}</p>
92
+
93
+ <slot name="status" :role="row.role" />
94
+ </div>
95
+ </div>
96
+ </template>
97
+
98
+ <script setup>
99
+ import { computed, onMounted, reactive, ref, watch } from "vue";
100
+ import {
101
+ assignmentOf,
102
+ buildSelector,
103
+ isVoiceRole,
104
+ modelOptions,
105
+ modelRoleRowState,
106
+ withAssignment
107
+ } from "../vue/model-roles-panel";
108
+ const props = defineProps({
109
+ roles: { type: Array, required: true },
110
+ assignments: { type: Object, required: true },
111
+ client: { type: Object, required: true },
112
+ readOnly: { type: Boolean, required: false }
113
+ });
114
+ const emit = defineEmits(["update:assignments"]);
115
+ defineSlots();
116
+ const entries = ref([]);
117
+ const loading = ref(false);
118
+ const loaded = ref(false);
119
+ const loadError = ref(null);
120
+ const modelsCache = reactive({});
121
+ const pending = reactive({});
122
+ const drafts = reactive({});
123
+ function errorMessage(error) {
124
+ return error instanceof Error && error.message ? error.message : "The request failed.";
125
+ }
126
+ async function loadEntries() {
127
+ loading.value = true;
128
+ loadError.value = null;
129
+ try {
130
+ entries.value = await props.client.list();
131
+ loaded.value = true;
132
+ } catch (error) {
133
+ loadError.value = errorMessage(error);
134
+ } finally {
135
+ loading.value = false;
136
+ }
137
+ }
138
+ const modelsRequest = {};
139
+ let requestCounter = 0;
140
+ async function loadModels(entryId) {
141
+ const request = ++requestCounter;
142
+ modelsRequest[entryId] = request;
143
+ modelsCache[entryId] = { loading: true };
144
+ try {
145
+ const result = await props.client.models(entryId);
146
+ if (modelsRequest[entryId] !== request) return;
147
+ modelsCache[entryId] = { loading: false, result };
148
+ } catch (error) {
149
+ if (modelsRequest[entryId] !== request) return;
150
+ modelsCache[entryId] = { loading: false, error: errorMessage(error) };
151
+ }
152
+ }
153
+ async function refresh() {
154
+ for (const key of Object.keys(modelsRequest)) delete modelsRequest[key];
155
+ for (const key of Object.keys(modelsCache)) delete modelsCache[key];
156
+ await loadEntries();
157
+ loadModelsForRows();
158
+ }
159
+ const rows = computed(
160
+ () => props.roles.filter((role) => role.enabled).map((role) => {
161
+ const voice = isVoiceRole(role.kind);
162
+ const state = modelRoleRowState(role, assignmentOf(props.assignments, role.id), entries.value, pending[role.id]);
163
+ const cache = state.entryId ? modelsCache[state.entryId] : void 0;
164
+ const result = cache?.result;
165
+ const listed = result && result.status !== "unsupported";
166
+ let modelsHint;
167
+ if (!voice && cache && !cache.loading && !listed) {
168
+ modelsHint = cache.error ?? result?.message ?? "This provider does not list models; enter the model id.";
169
+ } else if (!voice && result?.status === "static") {
170
+ modelsHint = result.message;
171
+ }
172
+ return {
173
+ role,
174
+ voice,
175
+ state,
176
+ modelChoices: listed ? modelOptions(result.models, state.modelId) : null,
177
+ modelsLoading: cache?.loading ?? false,
178
+ modelsHint
179
+ };
180
+ })
181
+ );
182
+ function loadModelsForRows() {
183
+ for (const row of rows.value) {
184
+ const id = row.state.entryId;
185
+ if (row.voice || !id || row.state.warning || modelsCache[id]) continue;
186
+ void loadModels(id);
187
+ }
188
+ }
189
+ function emitAssignments(roleId, value) {
190
+ emit("update:assignments", withAssignment(props.assignments, roleId, value));
191
+ }
192
+ function onProvider(role, entryId) {
193
+ if (props.readOnly) return;
194
+ delete drafts[role.id];
195
+ if (isVoiceRole(role.kind)) {
196
+ emitAssignments(role.id, entryId);
197
+ return;
198
+ }
199
+ if (entryId === "") delete pending[role.id];
200
+ else pending[role.id] = entryId;
201
+ if (assignmentOf(props.assignments, role.id)) emitAssignments(role.id, "");
202
+ }
203
+ function onModel(role, modelId) {
204
+ if (props.readOnly) return;
205
+ const entryId = rows.value.find((row) => row.role.id === role.id)?.state.entryId ?? "";
206
+ delete drafts[role.id];
207
+ if (!entryId || modelId === "") return;
208
+ delete pending[role.id];
209
+ emitAssignments(role.id, buildSelector(entryId, modelId));
210
+ }
211
+ function commitDraft(role) {
212
+ const draft = drafts[role.id];
213
+ if (draft === void 0) return;
214
+ const modelId = draft.trim();
215
+ const row = rows.value.find((candidate) => candidate.role.id === role.id);
216
+ if (!row || modelId === row.state.modelId) {
217
+ delete drafts[role.id];
218
+ return;
219
+ }
220
+ if (modelId === "") {
221
+ delete drafts[role.id];
222
+ if (assignmentOf(props.assignments, role.id)) {
223
+ pending[role.id] = row.state.entryId;
224
+ emitAssignments(role.id, "");
225
+ }
226
+ return;
227
+ }
228
+ onModel(role, modelId);
229
+ }
230
+ function clear(role) {
231
+ if (props.readOnly) return;
232
+ delete pending[role.id];
233
+ delete drafts[role.id];
234
+ if (assignmentOf(props.assignments, role.id)) emitAssignments(role.id, "");
235
+ }
236
+ watch(
237
+ () => props.assignments,
238
+ (assignments) => {
239
+ for (const roleId of Object.keys(pending)) {
240
+ if (assignmentOf(assignments, roleId)) delete pending[roleId];
241
+ }
242
+ }
243
+ );
244
+ watch(rows, loadModelsForRows);
245
+ onMounted(async () => {
246
+ await loadEntries();
247
+ loadModelsForRows();
248
+ });
249
+ defineExpose({ refresh });
250
+ </script>
@@ -0,0 +1,34 @@
1
+ import type { ModelRoleAssignments, ModelRoleDefinition } from "../shared/model-roles.js";
2
+ import { type ModelRolesPanelClient } from "../vue/model-roles-panel.js";
3
+ type __VLS_Props = {
4
+ /** Every role the host declares; only `enabled` ones are rendered. */
5
+ roles: ModelRoleDefinition[];
6
+ /** Role id to selector (`<entryId>/<modelId>`, or an entry id for `tts` / `stt`). */
7
+ assignments: ModelRoleAssignments;
8
+ /** Reads the registry and its models; a `ProvidersPanelClient` fits. */
9
+ client: ModelRolesPanelClient;
10
+ /** Disables every change. */
11
+ readOnly?: boolean;
12
+ };
13
+ type __VLS_Slots = {
14
+ /** Under a role's row: availability or check text the host knows. */
15
+ status?(props: {
16
+ role: ModelRoleDefinition;
17
+ }): unknown;
18
+ };
19
+ /** Reloads the registry and the models of every shown provider. */
20
+ declare function refresh(): Promise<void>;
21
+ declare const __VLS_component: import("vue").DefineComponent<__VLS_Props, {
22
+ refresh: typeof refresh;
23
+ }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
24
+ "update:assignments": (assignments: ModelRoleAssignments) => any;
25
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
26
+ "onUpdate:assignments"?: ((assignments: ModelRoleAssignments) => any) | undefined;
27
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
28
+ declare const _default: __VLS_WithSlots<typeof __VLS_component, __VLS_Slots>;
29
+ export default _default;
30
+ type __VLS_WithSlots<T, S> = T & {
31
+ new (): {
32
+ $slots: S;
33
+ };
34
+ };
@@ -1,13 +1,21 @@
1
+ import type { ProvidersPanelClient } from "../vue/providers-client.js";
1
2
  import { type ProvidersPanelLayer } from "../vue/useProvidersPanel.js";
2
3
  type __VLS_Props = {
3
- /** `GET` merged view for this scope (`/api/providers` | `/api/workspaces/:id/providers`). */
4
- providersUrl: string;
5
- /** `PUT|DELETE ${providersWriteUrl}/:id`; `GET ${providersWriteUrl}/detected`. */
6
- providersWriteUrl: string;
4
+ /**
5
+ * The panel's transport. Without it the panel calls the URL props below
6
+ * (`urlProvidersClient`); with it they are ignored and every row offers a Test button.
7
+ */
8
+ client?: ProvidersPanelClient;
9
+ /** `GET` merged view for this scope (`/api/providers` | `/api/workspaces/:id/providers`). Required without `client`. */
10
+ providersUrl?: string;
11
+ /** `PUT|DELETE ${providersWriteUrl}/:id`; `GET ${providersWriteUrl}/detected`. Required without `client`. */
12
+ providersWriteUrl?: string;
7
13
  /** Catalog preset summaries. Default `/api/providers/presets`. */
8
14
  presetsUrl?: string;
9
15
  /** The scope's `.env` route: hand-entered keys. Omitted, key entry is hidden. */
10
16
  environmentUrl?: string;
17
+ /** Key entry and the environment disclosure. Default: `Boolean(environmentUrl)` without `client`, `true` with one. */
18
+ environmentEnabled?: boolean;
11
19
  /** Edit/Delete are offered only for entries this layer defines. */
12
20
  layer: ProvidersPanelLayer;
13
21
  /** Disables add, edit and delete. */
@@ -17,5 +25,7 @@ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {
17
25
  changed: () => any;
18
26
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
19
27
  onChanged?: (() => any) | undefined;
20
- }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
28
+ }>, {
29
+ environmentEnabled: boolean;
30
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
21
31
  export default _default;
@@ -168,6 +168,11 @@
168
168
  :data-testid="`provider-override-notice-${row.id}`"
169
169
  class="text-foreground-subtle"
170
170
  >{{ notice }}</p>
171
+ <p
172
+ v-if="testResults[row.id]"
173
+ :data-testid="`provider-test-result-${row.id}`"
174
+ :class="testResults[row.id]?.ok ? 'text-success-500' : 'text-error-500'"
175
+ >{{ testResults[row.id]?.message }}</p>
171
176
  <p
172
177
  v-if="rowError?.id === row.id"
173
178
  :data-testid="`provider-row-error-${row.id}`"
@@ -185,6 +190,17 @@
185
190
  </td>
186
191
  <td class="px-3 py-2">
187
192
  <div class="flex items-center justify-end gap-1">
193
+ <UButton
194
+ v-if="canTest"
195
+ size="sm"
196
+ variant="ghost"
197
+ :loading="testBusy === row.id"
198
+ :disabled="testBusy !== null"
199
+ :data-testid="`provider-test-${row.id}`"
200
+ @click="testProvider(row)"
201
+ >
202
+ <Icon name="lucide:plug-zap" class="mr-1.5 w-3.5 h-3.5" />Test
203
+ </UButton>
188
204
  <UButton
189
205
  v-if="row.canReset"
190
206
  size="sm"
@@ -376,10 +392,12 @@
376
392
  <script setup>
377
393
  import { useProvidersPanel } from "../vue/useProvidersPanel";
378
394
  const props = defineProps({
379
- providersUrl: { type: String, required: true },
380
- providersWriteUrl: { type: String, required: true },
395
+ client: { type: Object, required: false },
396
+ providersUrl: { type: String, required: false },
397
+ providersWriteUrl: { type: String, required: false },
381
398
  presetsUrl: { type: String, required: false },
382
399
  environmentUrl: { type: String, required: false },
400
+ environmentEnabled: { type: Boolean, required: false, default: void 0 },
383
401
  layer: { type: String, required: true },
384
402
  readOnly: { type: Boolean, required: false }
385
403
  });
@@ -403,6 +421,10 @@ const {
403
421
  showCredentialFields,
404
422
  rowBusy,
405
423
  rowError,
424
+ canTest,
425
+ testBusy,
426
+ testResults,
427
+ testProvider,
406
428
  onCredentialSelect,
407
429
  setEnabled,
408
430
  resetPatch,
@@ -1,13 +1,21 @@
1
+ import type { ProvidersPanelClient } from "../vue/providers-client.js";
1
2
  import { type ProvidersPanelLayer } from "../vue/useProvidersPanel.js";
2
3
  type __VLS_Props = {
3
- /** `GET` merged view for this scope (`/api/providers` | `/api/workspaces/:id/providers`). */
4
- providersUrl: string;
5
- /** `PUT|DELETE ${providersWriteUrl}/:id`; `GET ${providersWriteUrl}/detected`. */
6
- providersWriteUrl: string;
4
+ /**
5
+ * The panel's transport. Without it the panel calls the URL props below
6
+ * (`urlProvidersClient`); with it they are ignored and every row offers a Test button.
7
+ */
8
+ client?: ProvidersPanelClient;
9
+ /** `GET` merged view for this scope (`/api/providers` | `/api/workspaces/:id/providers`). Required without `client`. */
10
+ providersUrl?: string;
11
+ /** `PUT|DELETE ${providersWriteUrl}/:id`; `GET ${providersWriteUrl}/detected`. Required without `client`. */
12
+ providersWriteUrl?: string;
7
13
  /** Catalog preset summaries. Default `/api/providers/presets`. */
8
14
  presetsUrl?: string;
9
15
  /** The scope's `.env` route: hand-entered keys. Omitted, key entry is hidden. */
10
16
  environmentUrl?: string;
17
+ /** Key entry and the environment disclosure. Default: `Boolean(environmentUrl)` without `client`, `true` with one. */
18
+ environmentEnabled?: boolean;
11
19
  /** Edit/Delete are offered only for entries this layer defines. */
12
20
  layer: ProvidersPanelLayer;
13
21
  /** Disables add, edit and delete. */
@@ -17,5 +25,7 @@ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {
17
25
  changed: () => any;
18
26
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
19
27
  onChanged?: (() => any) | undefined;
20
- }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
28
+ }>, {
29
+ environmentEnabled: boolean;
30
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
21
31
  export default _default;
@@ -8,6 +8,7 @@
8
8
  */
9
9
  export * from "../shared/harness-providers.js";
10
10
  export * from "../shared/harnesses.js";
11
+ export * from "../shared/model-roles.js";
11
12
  export * from "../shared/provider-registry.js";
12
13
  export type { DomainConfig, SkaileSettings } from "../shared/settings-types.js";
13
14
  export * from "./credential-policy.js";
@@ -1,5 +1,6 @@
1
1
  export * from "../shared/harness-providers.js";
2
2
  export * from "../shared/harnesses.js";
3
+ export * from "../shared/model-roles.js";
3
4
  export * from "../shared/provider-registry.js";
4
5
  export * from "./credential-policy.js";
5
6
  export * from "./credential-store.js";
@@ -0,0 +1,99 @@
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
+ import { type AIProviderRegistryView } from "./provider-registry.js";
12
+ /**
13
+ * What a role assigns, by kind. The registry kinds are `llm | stt | tts |
14
+ * embed` (see `AIProviderKind`); a role kind maps onto them:
15
+ *
16
+ * - `chat`, `decision` and `image` assign a selector `<entryId>/<modelId>` on
17
+ * an enabled `llm` registry entry. `image` offers every model of the `llm`
18
+ * entries, because model discovery carries no modality.
19
+ * - `tts` and `stt` assign the *id* of an enabled voice entry of that kind
20
+ * (the same ids `selectVoiceEntryId` chooses between).
21
+ *
22
+ * Naming against omp's model roles: `decision` is omp's `judge`, `tts` its
23
+ * `speech`, `stt` its `dictation`.
24
+ */
25
+ export declare const MODEL_ROLE_KINDS: readonly ["chat", "decision", "image", "tts", "stt"];
26
+ export type ModelRoleKind = (typeof MODEL_ROLE_KINDS)[number];
27
+ /** One role a host declares. `enabled: false` hides it and refuses assignments. */
28
+ export interface ModelRoleDefinition {
29
+ /** Stable key, used in assignments and as the `@role` alias. */
30
+ id: string;
31
+ kind: ModelRoleKind;
32
+ /** Short name a person reads. */
33
+ label: string;
34
+ /** What the role is used for. */
35
+ description: string;
36
+ enabled: boolean;
37
+ }
38
+ /**
39
+ * Role id to selector: `<entryId>/<modelId>` for `chat`, `decision` and
40
+ * `image` roles, an entry id for `tts` and `stt` roles. An empty string or an
41
+ * absent key means unassigned.
42
+ */
43
+ export type ModelRoleAssignments = Record<string, string>;
44
+ /**
45
+ * Roles most hosts could want beyond their own chat consumers. Spread them into
46
+ * the host's role list and flip `enabled` on the ones the app uses:
47
+ *
48
+ * ```ts
49
+ * const roles = STANDARD_MODEL_ROLES.map((role) => ({ ...role, enabled: role.id === "stt" }));
50
+ * ```
51
+ */
52
+ export declare const STANDARD_MODEL_ROLES: readonly ModelRoleDefinition[];
53
+ /** Why an assignment is refused. */
54
+ export type ModelRoleIssueCode = "unknown_role" | "disabled_role" | "bad_selector" | "entry_missing" | "entry_disabled" | "entry_wrong_kind";
55
+ /** One refused assignment, worded for a person; `role` is the assignment key. */
56
+ export interface ModelRoleIssue {
57
+ role: string;
58
+ code: ModelRoleIssueCode;
59
+ message: string;
60
+ }
61
+ /**
62
+ * Check assignments against the declared roles and a registry. Unassigned
63
+ * values (absent or empty) are skipped. At most one issue per role id; the
64
+ * host turns them into 400s keyed by role.
65
+ */
66
+ export declare function validateModelRoleAssignments(roles: readonly ModelRoleDefinition[], assignments: ModelRoleAssignments, registry: AIProviderRegistryView): ModelRoleIssue[];
67
+ /** What a role points at. */
68
+ export type ResolvedModelRole = {
69
+ kind: "model";
70
+ selector: string;
71
+ entryId: string;
72
+ } | {
73
+ kind: "voice";
74
+ entryId: string;
75
+ } | {
76
+ kind: "unassigned";
77
+ };
78
+ /** Inputs of {@link resolveModelRole}. */
79
+ export interface ResolveModelRoleContext {
80
+ roles: readonly ModelRoleDefinition[];
81
+ assignments: ModelRoleAssignments;
82
+ registry: AIProviderRegistryView;
83
+ }
84
+ /**
85
+ * Resolve one role to the registry entry it points at.
86
+ *
87
+ * @throws {ModelResolutionError} `unknown_selector` when the role is not
88
+ * declared or is disabled, or when its assignment dangles: the entry is
89
+ * gone, disabled, of the wrong kind, or the value is malformed.
90
+ */
91
+ export declare function resolveModelRole(roleId: string, context: ResolveModelRoleContext): ResolvedModelRole;
92
+ /**
93
+ * The `roles` map `resolveRegistryLanguageModel(selector, { roles })` accepts,
94
+ * so a `@role` selector reaches the assigned model: role id to selector for
95
+ * every enabled, assigned `chat`, `decision` and `image` role. Voice roles,
96
+ * unassigned and disabled roles, and assignments to undeclared roles are left
97
+ * out. Assignments are not validated here; see {@link validateModelRoleAssignments}.
98
+ */
99
+ export declare function modelRoleAliases(roles: readonly ModelRoleDefinition[], assignments: ModelRoleAssignments): Record<string, string>;