@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +31 -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 +18 -5
  10. package/dist/runtime/components/ForgeProvidersPanel.vue +32 -7
  11. package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +18 -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 +24 -4
  23. package/dist/runtime/vue/providers-panel-model.js +5 -0
  24. package/dist/runtime/vue/useProvidersPanel.d.ts +10 -2
  25. package/dist/runtime/vue/useProvidersPanel.js +67 -43
  26. package/package.json +1 -1
  27. package/src/runtime/components/ForgeModelRolesPanel.vue +312 -0
  28. package/src/runtime/components/ForgeProvidersPanel.vue +55 -20
  29. package/src/runtime/server/index.ts +1 -0
  30. package/src/runtime/shared/model-roles.ts +283 -0
  31. package/src/runtime/vue/index.ts +3 -0
  32. package/src/runtime/vue/model-roles-panel.ts +140 -0
  33. package/src/runtime/vue/providers-client.ts +100 -0
  34. package/src/runtime/vue/providers-panel-model.ts +34 -4
  35. package/src/runtime/vue/useProvidersPanel.ts +89 -45
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.1
4
+
5
+ - `<ForgeProvidersPanel>` takes an optional `note` prop: a string replaces the line above the provider list, `false` hides it (Add provider stays, right-aligned). Absent keeps today's text, so existing hosts see no change.
6
+
7
+ ## 1.3.0
8
+
9
+ - 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`.
10
+ - `/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.
11
+ - `/nuxt`: registers `<ForgeModelRolesPanel>` (provider and model select per enabled role, `status` slot, emits `update:assignments`, never writes).
12
+
3
13
  ## 1.2.2
4
14
 
5
15
  - 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.
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,35 @@ 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
+ `note` sets the line above the list: absent keeps the panel's text for `layer`, a string replaces it, `false` hides it (the Add provider button stays, right-aligned).
90
+
91
+ ## Model roles
92
+
93
+ 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.
94
+
95
+ | Kind | Assigns | Registry entry |
96
+ |---|---|---|
97
+ | `chat`, `decision`, `image` | `<entryId>/<modelId>` | enabled `llm` entry (`image` offers every model; discovery carries no modality) |
98
+ | `tts`, `stt` | the voice entry id | enabled entry of that kind |
99
+
100
+ omp names: `decision` = `judge`, `tts` = `speech`, `stt` = `dictation`.
101
+
102
+ ```ts
103
+ import { STANDARD_MODEL_ROLES, validateModelRoleAssignments, resolveModelRole, modelRoleAliases } from '@skaile/forge-settings/server' // or /vue
104
+
105
+ const roles = [
106
+ { id: 'assistant', kind: 'chat', label: 'Assistant', description: 'The in-app chat.', enabled: true },
107
+ ...STANDARD_MODEL_ROLES.map((role) => ({ ...role, enabled: false })), // decision, image, tts, stt: flip what the app uses
108
+ ]
109
+ validateModelRoleAssignments(roles, assignments, registry) // [{ role, code, message }]: one issue per role id, host turns them into 400s
110
+ resolveModelRole('assistant', { roles, assignments, registry }) // { kind: 'model', selector, entryId } | { kind: 'voice', entryId } | { kind: 'unassigned' }
111
+ resolveRegistryLanguageModel('@assistant', { roles: modelRoleAliases(roles, assignments) })
112
+ ```
113
+
114
+ 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.
115
+
87
116
  ## Development
88
117
 
89
118
  ```
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.2",
4
+ "version": "1.3.1",
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,21 +1,34 @@
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. */
14
22
  readOnly?: boolean;
23
+ /** The line above the list. Absent: the default text for `layer`; a string replaces it; `false` hides it. */
24
+ note?: string | false;
15
25
  };
16
26
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
17
27
  changed: () => any;
18
28
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
19
29
  onChanged?: (() => any) | undefined;
20
- }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
30
+ }>, {
31
+ environmentEnabled: boolean;
32
+ note: string | false;
33
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
21
34
  export default _default;
@@ -29,10 +29,9 @@
29
29
  <p v-if="detectedError" class="text-xs text-error-500">{{ detectedError }}</p>
30
30
  </div>
31
31
 
32
- <div class="flex items-center gap-2">
33
- <p class="text-xs text-foreground-muted leading-relaxed flex-1">
34
- {{ props.layer === "workspace" ? "Entries of this workspace's providers.yml can be changed here; instance entries are inherited." : "The instance registry, $FORGE_HOME/.skaile/providers.yml." }}
35
- Keys stay in the environment; an entry only references them.
32
+ <div class="flex items-center gap-2" :class="{ 'justify-end': noteText === null }">
33
+ <p v-if="noteText !== null" class="text-xs text-foreground-muted leading-relaxed flex-1">
34
+ {{ noteText }}
36
35
  </p>
37
36
  <UButton
38
37
  v-if="!props.readOnly"
@@ -168,6 +167,11 @@
168
167
  :data-testid="`provider-override-notice-${row.id}`"
169
168
  class="text-foreground-subtle"
170
169
  >{{ notice }}</p>
170
+ <p
171
+ v-if="testResults[row.id]"
172
+ :data-testid="`provider-test-result-${row.id}`"
173
+ :class="testResults[row.id]?.ok ? 'text-success-500' : 'text-error-500'"
174
+ >{{ testResults[row.id]?.message }}</p>
171
175
  <p
172
176
  v-if="rowError?.id === row.id"
173
177
  :data-testid="`provider-row-error-${row.id}`"
@@ -185,6 +189,17 @@
185
189
  </td>
186
190
  <td class="px-3 py-2">
187
191
  <div class="flex items-center justify-end gap-1">
192
+ <UButton
193
+ v-if="canTest"
194
+ size="sm"
195
+ variant="ghost"
196
+ :loading="testBusy === row.id"
197
+ :disabled="testBusy !== null"
198
+ :data-testid="`provider-test-${row.id}`"
199
+ @click="testProvider(row)"
200
+ >
201
+ <Icon name="lucide:plug-zap" class="mr-1.5 w-3.5 h-3.5" />Test
202
+ </UButton>
188
203
  <UButton
189
204
  v-if="row.canReset"
190
205
  size="sm"
@@ -374,15 +389,21 @@
374
389
  </template>
375
390
 
376
391
  <script setup>
392
+ import { computed } from "vue";
393
+ import { providersPanelNote } from "../vue/providers-panel-model";
377
394
  import { useProvidersPanel } from "../vue/useProvidersPanel";
378
395
  const props = defineProps({
379
- providersUrl: { type: String, required: true },
380
- providersWriteUrl: { type: String, required: true },
396
+ client: { type: Object, required: false },
397
+ providersUrl: { type: String, required: false },
398
+ providersWriteUrl: { type: String, required: false },
381
399
  presetsUrl: { type: String, required: false },
382
400
  environmentUrl: { type: String, required: false },
401
+ environmentEnabled: { type: Boolean, required: false, default: void 0 },
383
402
  layer: { type: String, required: true },
384
- readOnly: { type: Boolean, required: false }
403
+ readOnly: { type: Boolean, required: false },
404
+ note: { type: [String, Boolean], required: false, default: void 0 }
385
405
  });
406
+ const noteText = computed(() => providersPanelNote(props.note, props.layer));
386
407
  const emit = defineEmits(["changed"]);
387
408
  const {
388
409
  rows,
@@ -403,6 +424,10 @@ const {
403
424
  showCredentialFields,
404
425
  rowBusy,
405
426
  rowError,
427
+ canTest,
428
+ testBusy,
429
+ testResults,
430
+ testProvider,
406
431
  onCredentialSelect,
407
432
  setEnabled,
408
433
  resetPatch,
@@ -1,21 +1,34 @@
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. */
14
22
  readOnly?: boolean;
23
+ /** The line above the list. Absent: the default text for `layer`; a string replaces it; `false` hides it. */
24
+ note?: string | false;
15
25
  };
16
26
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
17
27
  changed: () => any;
18
28
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
19
29
  onChanged?: (() => any) | undefined;
20
- }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
30
+ }>, {
31
+ environmentEnabled: boolean;
32
+ note: string | false;
33
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
21
34
  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";