@skaile/forge-settings 1.2.2 → 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 +6 -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 +1 -1
  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
@@ -1,4 +1,3 @@
1
- import { $fetch } from "ofetch";
2
1
  import {
3
2
  computed,
4
3
  onMounted,
@@ -13,11 +12,13 @@ import {
13
12
  AI_PROVIDER_SURFACES,
14
13
  validateCredentialRef
15
14
  } from "../shared/provider-registry.js";
15
+ import {
16
+ urlProvidersClient
17
+ } from "./providers-client.js";
16
18
  import {
17
19
  CREDENTIAL_ENTER_KEY,
18
20
  CREDENTIAL_OTHER_REF,
19
21
  CUSTOM_PRESET,
20
- DEFAULT_PRESETS_URL,
21
22
  EMPTY_FORM,
22
23
  ENV_SOURCE_LABEL,
23
24
  envNameOfRef,
@@ -36,13 +37,30 @@ export function useProvidersPanel(props, emit) {
36
37
  const loading = ref(false);
37
38
  const loadError = ref(null);
38
39
  const envError = ref(null);
40
+ function client() {
41
+ if (props.client) return props.client;
42
+ if (!props.providersUrl || !props.providersWriteUrl) {
43
+ throw new Error(
44
+ "ForgeProvidersPanel needs a `client`, or `providersUrl` and `providersWriteUrl`."
45
+ );
46
+ }
47
+ return urlProvidersClient({
48
+ providersUrl: props.providersUrl,
49
+ providersWriteUrl: props.providersWriteUrl,
50
+ presetsUrl: props.presetsUrl,
51
+ environmentUrl: props.environmentUrl
52
+ });
53
+ }
54
+ const environmentOn = computed(
55
+ () => props.environmentEnabled ?? (props.client ? true : Boolean(props.environmentUrl))
56
+ );
39
57
  async function loadEnvironment() {
40
- if (!props.environmentUrl) {
58
+ if (!environmentOn.value) {
41
59
  envDocument.value = null;
42
60
  return;
43
61
  }
44
62
  try {
45
- envDocument.value = await $fetch(props.environmentUrl);
63
+ envDocument.value = await client().environment();
46
64
  envError.value = null;
47
65
  } catch (err) {
48
66
  envError.value = requestErrorMessage(err, "Could not load the environment variables.");
@@ -52,11 +70,8 @@ export function useProvidersPanel(props, emit) {
52
70
  loading.value = true;
53
71
  loadError.value = null;
54
72
  try {
55
- const [list, found] = await Promise.all([
56
- $fetch(props.providersUrl, { query: { all: "1" } }),
57
- $fetch(`${props.providersWriteUrl}/detected`),
58
- loadEnvironment()
59
- ]);
73
+ const api = client();
74
+ const [list, found] = await Promise.all([api.list(), api.detected(), loadEnvironment()]);
60
75
  providers.value = Array.isArray(list) ? list : [];
61
76
  detected.value = Array.isArray(found) ? found : [];
62
77
  } catch (err) {
@@ -67,15 +82,14 @@ export function useProvidersPanel(props, emit) {
67
82
  }
68
83
  async function loadPresets() {
69
84
  try {
70
- const list = await $fetch(
71
- props.presetsUrl ?? DEFAULT_PRESETS_URL
72
- );
85
+ const list = await client().presets();
73
86
  presets.value = Array.isArray(list) ? list : [];
74
87
  } catch (err) {
75
88
  loadError.value = requestErrorMessage(err, "Could not load the provider presets.");
76
89
  }
77
90
  }
78
91
  async function afterWrite() {
92
+ for (const key of Object.keys(testResults)) delete testResults[key];
79
93
  await refresh();
80
94
  emit("changed");
81
95
  }
@@ -84,7 +98,7 @@ export function useProvidersPanel(props, emit) {
84
98
  void refresh();
85
99
  });
86
100
  watch(
87
- () => [props.providersUrl, props.providersWriteUrl, props.environmentUrl],
101
+ () => [props.providersUrl, props.providersWriteUrl, props.environmentUrl, props.client],
88
102
  () => void refresh()
89
103
  );
90
104
  const envVars = computed(() => {
@@ -93,9 +107,7 @@ export function useProvidersPanel(props, emit) {
93
107
  return byName;
94
108
  });
95
109
  const envEditable = computed(() => envDocument.value?.editable === true);
96
- const canEnterKey = computed(
97
- () => Boolean(props.environmentUrl) && envDocument.value?.editable !== false
98
- );
110
+ const canEnterKey = computed(() => environmentOn.value && envDocument.value?.editable !== false);
99
111
  function stateOf(entry) {
100
112
  if (props.layer !== "workspace" || !entry.layers.includes("instance")) return "own";
101
113
  return entry.layers.includes("workspace") ? "patched" : "inherited";
@@ -206,7 +218,7 @@ export function useProvidersPanel(props, emit) {
206
218
  const baseUrlPlaceholder = computed(
207
219
  () => selectedPreset.value?.base_url ?? "https://api.example.com/v1"
208
220
  );
209
- const canSetValue = computed(() => Boolean(props.environmentUrl));
221
+ const canSetValue = computed(() => environmentOn.value);
210
222
  const showCredentialFields = computed(() => editing.value === null);
211
223
  const idError = computed(() => {
212
224
  const id = form.id.trim();
@@ -296,18 +308,15 @@ export function useProvidersPanel(props, emit) {
296
308
  saving.value = true;
297
309
  dialogError.value = null;
298
310
  try {
299
- if (showCredentialFields.value && form.secret && props.environmentUrl) {
311
+ if (showCredentialFields.value && form.secret && environmentOn.value) {
300
312
  const update = {
301
313
  set: { [form.envName.trim()]: form.secret },
302
314
  unset: []
303
315
  };
304
- await $fetch(props.environmentUrl, { method: "PUT", body: update });
316
+ await client().updateEnvironment(update);
305
317
  form.secret = "";
306
318
  }
307
- await $fetch(`${props.providersWriteUrl}/${encodeURIComponent(id)}`, {
308
- method: "PUT",
309
- body: buildBody()
310
- });
319
+ await client().upsert(id, buildBody());
311
320
  dialogVisible.value = false;
312
321
  editing.value = null;
313
322
  await afterWrite();
@@ -337,9 +346,7 @@ export function useProvidersPanel(props, emit) {
337
346
  deleting.value = true;
338
347
  deleteError.value = null;
339
348
  try {
340
- await $fetch(`${props.providersWriteUrl}/${encodeURIComponent(target.id)}`, {
341
- method: "DELETE"
342
- });
349
+ await client().remove(target.id);
343
350
  deleteTarget.value = null;
344
351
  await afterWrite();
345
352
  } catch (err) {
@@ -362,12 +369,9 @@ export function useProvidersPanel(props, emit) {
362
369
  detectedBusy.value = preset.id;
363
370
  detectedError.value = null;
364
371
  try {
365
- await $fetch(`${props.providersWriteUrl}/${encodeURIComponent(id)}`, {
366
- method: "PUT",
367
- body: {
368
- preset: preset.id,
369
- credential: { kind: preset.credential.kind, ref: `env:${envName}` }
370
- }
372
+ await client().upsert(id, {
373
+ preset: preset.id,
374
+ credential: { kind: preset.credential.kind, ref: `env:${envName}` }
371
375
  });
372
376
  await afterWrite();
373
377
  } catch (err) {
@@ -399,10 +403,7 @@ export function useProvidersPanel(props, emit) {
399
403
  }
400
404
  async function putRowChange(row, change) {
401
405
  const body = row.state === "own" ? { ...ownBody(row), ...change } : change;
402
- await $fetch(`${props.providersWriteUrl}/${encodeURIComponent(row.id)}`, {
403
- method: "PUT",
404
- body
405
- });
406
+ await client().upsert(row.id, body);
406
407
  }
407
408
  function credentialFor(row, ref2) {
408
409
  return { kind: row.credentialKind ?? FALLBACK_CREDENTIAL_KIND, ref: ref2 };
@@ -442,13 +443,32 @@ export function useProvidersPanel(props, emit) {
442
443
  await runRowWrite(
443
444
  row.id,
444
445
  async () => {
445
- await $fetch(`${props.providersWriteUrl}/${encodeURIComponent(row.id)}`, {
446
- method: "DELETE"
447
- });
446
+ await client().remove(row.id);
448
447
  },
449
448
  `Could not reset ${row.id}.`
450
449
  );
451
450
  }
451
+ const canTest = computed(() => Boolean(props.client) && !props.readOnly);
452
+ const testBusy = ref(null);
453
+ const testResults = reactive({});
454
+ async function testProvider(row) {
455
+ if (!props.client || props.readOnly || testBusy.value !== null) return;
456
+ testBusy.value = row.id;
457
+ delete testResults[row.id];
458
+ try {
459
+ testResults[row.id] = await props.client.test(row.id);
460
+ } catch (err) {
461
+ testResults[row.id] = {
462
+ ok: false,
463
+ message: requestErrorMessage(
464
+ err,
465
+ err instanceof Error && err.message ? err.message : `Could not test ${row.id}.`
466
+ )
467
+ };
468
+ } finally {
469
+ testBusy.value = null;
470
+ }
471
+ }
452
472
  const keyEditing = ref(null);
453
473
  const keyForm = reactive({ name: "", value: "" });
454
474
  const keyError = ref(null);
@@ -476,7 +496,7 @@ export function useProvidersPanel(props, emit) {
476
496
  }
477
497
  async function saveKeyEditor() {
478
498
  const row = rows.value.find((r) => r.id === keyEditing.value);
479
- if (!row?.canEditCredential || !props.environmentUrl) return;
499
+ if (!row?.canEditCredential || !environmentOn.value) return;
480
500
  const name = keyForm.name.trim();
481
501
  const refusal = validateEnvVarName(name);
482
502
  if (refusal) {
@@ -492,7 +512,7 @@ export function useProvidersPanel(props, emit) {
492
512
  let wroteEnv = false;
493
513
  try {
494
514
  const update = { set: { [name]: keyForm.value }, unset: [] };
495
- await $fetch(props.environmentUrl, { method: "PUT", body: update });
515
+ await client().updateEnvironment(update);
496
516
  wroteEnv = true;
497
517
  const ref2 = `env:${name}`;
498
518
  if (row.credentialRef !== ref2) {
@@ -542,11 +562,11 @@ export function useProvidersPanel(props, emit) {
542
562
  const envValues = reactive({});
543
563
  const envBusy = ref(false);
544
564
  async function writeEnvironment(update, fallback) {
545
- if (!props.environmentUrl || !envEditable.value) return false;
565
+ if (!environmentOn.value || !envEditable.value) return false;
546
566
  envBusy.value = true;
547
567
  envError.value = null;
548
568
  try {
549
- await $fetch(props.environmentUrl, { method: "PUT", body: update });
569
+ await client().updateEnvironment(update);
550
570
  } catch (err) {
551
571
  envError.value = requestErrorMessage(err, fallback);
552
572
  return false;
@@ -607,6 +627,10 @@ export function useProvidersPanel(props, emit) {
607
627
  resetPatch,
608
628
  rowBusy,
609
629
  rowError,
630
+ canTest,
631
+ testBusy,
632
+ testResults,
633
+ testProvider,
610
634
  // key editor
611
635
  canEnterKey,
612
636
  keyEditing,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skaile/forge-settings",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "Provider and settings operations for Skaile forge apps: provider registry layers, credential store, env layers, model discovery, the providers panel, and editor / AI SDK model adapters.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.9",
@@ -0,0 +1,312 @@
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 || undefined"
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 || undefined"
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 lang="ts">
99
+ import { computed, onMounted, reactive, ref, watch } from "vue";
100
+ import type { ModelRoleAssignments, ModelRoleDefinition } from "../shared/model-roles";
101
+ import {
102
+ assignmentOf,
103
+ buildSelector,
104
+ isVoiceRole,
105
+ type ModelRoleOption,
106
+ type ModelRoleRowState,
107
+ type ModelRolesModelsResult,
108
+ type ModelRolesPanelClient,
109
+ modelOptions,
110
+ modelRoleRowState,
111
+ withAssignment,
112
+ } from "../vue/model-roles-panel";
113
+ import type { ProvidersPanelEntry } from "../vue/providers-panel-model";
114
+
115
+ const props = defineProps<{
116
+ /** Every role the host declares; only `enabled` ones are rendered. */
117
+ roles: ModelRoleDefinition[];
118
+ /** Role id to selector (`<entryId>/<modelId>`, or an entry id for `tts` / `stt`). */
119
+ assignments: ModelRoleAssignments;
120
+ /** Reads the registry and its models; a `ProvidersPanelClient` fits. */
121
+ client: ModelRolesPanelClient;
122
+ /** Disables every change. */
123
+ readOnly?: boolean;
124
+ }>();
125
+
126
+ const emit = defineEmits<{
127
+ /** The full new assignments map after a pick or a clear. The host saves it. */
128
+ (event: "update:assignments", assignments: ModelRoleAssignments): void;
129
+ }>();
130
+
131
+ defineSlots<{
132
+ /** Under a role's row: availability or check text the host knows. */
133
+ status?(props: { role: ModelRoleDefinition }): unknown;
134
+ }>();
135
+
136
+ interface ModelsCache {
137
+ loading: boolean;
138
+ result?: ModelRolesModelsResult;
139
+ /** The request failed; the free-text field is offered. */
140
+ error?: string;
141
+ }
142
+
143
+ const entries = ref<ProvidersPanelEntry[]>([]);
144
+ const loading = ref(false);
145
+ const loaded = ref(false);
146
+ const loadError = ref<string | null>(null);
147
+ const modelsCache = reactive<Record<string, ModelsCache>>({});
148
+ /** Provider picked on a model role before a model exists. */
149
+ const pending = reactive<Record<string, string>>({});
150
+ /** Free-text model input not yet committed. */
151
+ const drafts = reactive<Record<string, string>>({});
152
+
153
+ function errorMessage(error: unknown): string {
154
+ return error instanceof Error && error.message ? error.message : "The request failed.";
155
+ }
156
+
157
+ async function loadEntries(): Promise<void> {
158
+ loading.value = true;
159
+ loadError.value = null;
160
+ try {
161
+ entries.value = await props.client.list();
162
+ loaded.value = true;
163
+ } catch (error) {
164
+ loadError.value = errorMessage(error);
165
+ } finally {
166
+ loading.value = false;
167
+ }
168
+ }
169
+
170
+ /** Latest request number per provider; a response with another number is stale and dropped. */
171
+ const modelsRequest: Record<string, number> = {};
172
+ let requestCounter = 0;
173
+
174
+ async function loadModels(entryId: string): Promise<void> {
175
+ const request = ++requestCounter;
176
+ modelsRequest[entryId] = request;
177
+ modelsCache[entryId] = { loading: true };
178
+ try {
179
+ const result = await props.client.models(entryId);
180
+ if (modelsRequest[entryId] !== request) return;
181
+ modelsCache[entryId] = { loading: false, result };
182
+ } catch (error) {
183
+ if (modelsRequest[entryId] !== request) return;
184
+ modelsCache[entryId] = { loading: false, error: errorMessage(error) };
185
+ }
186
+ }
187
+
188
+ /** Reloads the registry and the models of every shown provider. */
189
+ async function refresh(): Promise<void> {
190
+ // Responses still in flight were asked before the reload; their numbers no longer match.
191
+ for (const key of Object.keys(modelsRequest)) delete modelsRequest[key];
192
+ for (const key of Object.keys(modelsCache)) delete modelsCache[key];
193
+ await loadEntries();
194
+ loadModelsForRows();
195
+ }
196
+
197
+ interface Row {
198
+ role: ModelRoleDefinition;
199
+ voice: boolean;
200
+ state: ModelRoleRowState;
201
+ /** Options for the model select; `null` selects the free-text field. */
202
+ modelChoices: ModelRoleOption[] | null;
203
+ modelsLoading: boolean;
204
+ modelsHint?: string;
205
+ }
206
+
207
+ const rows = computed<Row[]>(() =>
208
+ props.roles
209
+ .filter((role) => role.enabled)
210
+ .map((role) => {
211
+ const voice = isVoiceRole(role.kind);
212
+ const state = modelRoleRowState(role, assignmentOf(props.assignments, role.id), entries.value, pending[role.id]);
213
+ const cache = state.entryId ? modelsCache[state.entryId] : undefined;
214
+ const result = cache?.result;
215
+ const listed = result && result.status !== "unsupported";
216
+ let modelsHint: string | undefined;
217
+ if (!voice && cache && !cache.loading && !listed) {
218
+ modelsHint = cache.error ?? result?.message ?? "This provider does not list models; enter the model id.";
219
+ } else if (!voice && result?.status === "static") {
220
+ modelsHint = result.message;
221
+ }
222
+ return {
223
+ role,
224
+ voice,
225
+ state,
226
+ modelChoices: listed ? modelOptions(result.models, state.modelId) : null,
227
+ modelsLoading: cache?.loading ?? false,
228
+ modelsHint,
229
+ };
230
+ }),
231
+ );
232
+
233
+ /** Fetches the models of every provider a model row shows and has not fetched. */
234
+ function loadModelsForRows(): void {
235
+ for (const row of rows.value) {
236
+ const id = row.state.entryId;
237
+ if (row.voice || !id || row.state.warning || modelsCache[id]) continue;
238
+ void loadModels(id);
239
+ }
240
+ }
241
+
242
+ function emitAssignments(roleId: string, value: string): void {
243
+ emit("update:assignments", withAssignment(props.assignments, roleId, value));
244
+ }
245
+
246
+ function onProvider(role: ModelRoleDefinition, entryId: string): void {
247
+ if (props.readOnly) return;
248
+ delete drafts[role.id];
249
+ if (isVoiceRole(role.kind)) {
250
+ emitAssignments(role.id, entryId);
251
+ return;
252
+ }
253
+ // The model belongs to the old provider: drop it, keep the pick until a model is chosen.
254
+ if (entryId === "") delete pending[role.id];
255
+ else pending[role.id] = entryId;
256
+ if (assignmentOf(props.assignments, role.id)) emitAssignments(role.id, "");
257
+ }
258
+
259
+ function onModel(role: ModelRoleDefinition, modelId: string): void {
260
+ if (props.readOnly) return;
261
+ const entryId = rows.value.find((row) => row.role.id === role.id)?.state.entryId ?? "";
262
+ delete drafts[role.id];
263
+ if (!entryId || modelId === "") return;
264
+ delete pending[role.id];
265
+ emitAssignments(role.id, buildSelector(entryId, modelId));
266
+ }
267
+
268
+ function commitDraft(role: ModelRoleDefinition): void {
269
+ const draft = drafts[role.id];
270
+ if (draft === undefined) return;
271
+ const modelId = draft.trim();
272
+ const row = rows.value.find((candidate) => candidate.role.id === role.id);
273
+ if (!row || modelId === row.state.modelId) {
274
+ delete drafts[role.id];
275
+ return;
276
+ }
277
+ if (modelId === "") {
278
+ delete drafts[role.id];
279
+ if (assignmentOf(props.assignments, role.id)) {
280
+ pending[role.id] = row.state.entryId;
281
+ emitAssignments(role.id, "");
282
+ }
283
+ return;
284
+ }
285
+ onModel(role, modelId);
286
+ }
287
+
288
+ function clear(role: ModelRoleDefinition): void {
289
+ if (props.readOnly) return;
290
+ delete pending[role.id];
291
+ delete drafts[role.id];
292
+ if (assignmentOf(props.assignments, role.id)) emitAssignments(role.id, "");
293
+ }
294
+
295
+ // A stored assignment supersedes a pick that was waiting for its model.
296
+ watch(
297
+ () => props.assignments,
298
+ (assignments) => {
299
+ for (const roleId of Object.keys(pending)) {
300
+ if (assignmentOf(assignments, roleId)) delete pending[roleId];
301
+ }
302
+ },
303
+ );
304
+ watch(rows, loadModelsForRows);
305
+
306
+ onMounted(async () => {
307
+ await loadEntries();
308
+ loadModelsForRows();
309
+ });
310
+
311
+ defineExpose({ refresh });
312
+ </script>
@@ -170,6 +170,11 @@
170
170
  :data-testid="`provider-override-notice-${row.id}`"
171
171
  class="text-foreground-subtle"
172
172
  >{{ notice }}</p>
173
+ <p
174
+ v-if="testResults[row.id]"
175
+ :data-testid="`provider-test-result-${row.id}`"
176
+ :class="testResults[row.id]?.ok ? 'text-success-500' : 'text-error-500'"
177
+ >{{ testResults[row.id]?.message }}</p>
173
178
  <p
174
179
  v-if="rowError?.id === row.id"
175
180
  :data-testid="`provider-row-error-${row.id}`"
@@ -187,6 +192,17 @@
187
192
  </td>
188
193
  <td class="px-3 py-2">
189
194
  <div class="flex items-center justify-end gap-1">
195
+ <UButton
196
+ v-if="canTest"
197
+ size="sm"
198
+ variant="ghost"
199
+ :loading="testBusy === row.id"
200
+ :disabled="testBusy !== null"
201
+ :data-testid="`provider-test-${row.id}`"
202
+ @click="testProvider(row)"
203
+ >
204
+ <Icon name="lucide:plug-zap" class="mr-1.5 w-3.5 h-3.5" />Test
205
+ </UButton>
190
206
  <UButton
191
207
  v-if="row.canReset"
192
208
  size="sm"
@@ -376,22 +392,34 @@
376
392
  </template>
377
393
 
378
394
  <script setup lang="ts">
395
+ import type { ProvidersPanelClient } from "../vue/providers-client";
379
396
  import { type ProvidersPanelLayer, useProvidersPanel } from "../vue/useProvidersPanel";
380
397
 
381
- const props = defineProps<{
382
- /** `GET` merged view for this scope (`/api/providers` | `/api/workspaces/:id/providers`). */
383
- providersUrl: string;
384
- /** `PUT|DELETE ${providersWriteUrl}/:id`; `GET ${providersWriteUrl}/detected`. */
385
- providersWriteUrl: string;
386
- /** Catalog preset summaries. Default `/api/providers/presets`. */
387
- presetsUrl?: string;
388
- /** The scope's `.env` route: hand-entered keys. Omitted, key entry is hidden. */
389
- environmentUrl?: string;
390
- /** Edit/Delete are offered only for entries this layer defines. */
391
- layer: ProvidersPanelLayer;
392
- /** Disables add, edit and delete. */
393
- readOnly?: boolean;
394
- }>();
398
+ const props = withDefaults(
399
+ defineProps<{
400
+ /**
401
+ * The panel's transport. Without it the panel calls the URL props below
402
+ * (`urlProvidersClient`); with it they are ignored and every row offers a Test button.
403
+ */
404
+ client?: ProvidersPanelClient;
405
+ /** `GET` merged view for this scope (`/api/providers` | `/api/workspaces/:id/providers`). Required without `client`. */
406
+ providersUrl?: string;
407
+ /** `PUT|DELETE ${providersWriteUrl}/:id`; `GET ${providersWriteUrl}/detected`. Required without `client`. */
408
+ providersWriteUrl?: string;
409
+ /** Catalog preset summaries. Default `/api/providers/presets`. */
410
+ presetsUrl?: string;
411
+ /** The scope's `.env` route: hand-entered keys. Omitted, key entry is hidden. */
412
+ environmentUrl?: string;
413
+ /** Key entry and the environment disclosure. Default: `Boolean(environmentUrl)` without `client`, `true` with one. */
414
+ environmentEnabled?: boolean;
415
+ /** Edit/Delete are offered only for entries this layer defines. */
416
+ layer: ProvidersPanelLayer;
417
+ /** Disables add, edit and delete. */
418
+ readOnly?: boolean;
419
+ }>(),
420
+ // Without a default Vue casts an absent boolean to `false`; the panel tells absent from `false`.
421
+ { environmentEnabled: undefined },
422
+ );
395
423
 
396
424
  const emit = defineEmits<{
397
425
  /** After every successful `PUT`/`DELETE`. */
@@ -417,6 +445,10 @@ const {
417
445
  showCredentialFields,
418
446
  rowBusy,
419
447
  rowError,
448
+ canTest,
449
+ testBusy,
450
+ testResults,
451
+ testProvider,
420
452
  onCredentialSelect,
421
453
  setEnabled,
422
454
  resetPatch,
@@ -9,6 +9,7 @@
9
9
 
10
10
  export * from "../shared/harness-providers";
11
11
  export * from "../shared/harnesses";
12
+ export * from "../shared/model-roles";
12
13
  export * from "../shared/provider-registry";
13
14
  export type { DomainConfig, SkaileSettings } from "../shared/settings-types";
14
15
  export * from "./credential-policy";