@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
@@ -0,0 +1,144 @@
1
+ import { ModelResolutionError } from "../model/errors.js";
2
+ import {
3
+ isEntryEnabled,
4
+ isMergedRegistry
5
+ } from "./provider-registry.js";
6
+ export const MODEL_ROLE_KINDS = ["chat", "decision", "image", "tts", "stt"];
7
+ export const STANDARD_MODEL_ROLES = [
8
+ {
9
+ id: "decision",
10
+ kind: "decision",
11
+ label: "Decision model",
12
+ description: "A small, fast model for classifying and routing: yes/no checks, picking a branch, tagging.",
13
+ enabled: true
14
+ },
15
+ {
16
+ id: "image",
17
+ kind: "image",
18
+ label: "Image model",
19
+ description: "Generates or edits images.",
20
+ enabled: true
21
+ },
22
+ {
23
+ id: "tts",
24
+ kind: "tts",
25
+ label: "Text to speech",
26
+ description: "The voice provider that reads text aloud.",
27
+ enabled: true
28
+ },
29
+ {
30
+ id: "stt",
31
+ kind: "stt",
32
+ label: "Speech to text",
33
+ description: "The voice provider that transcribes spoken input.",
34
+ enabled: true
35
+ }
36
+ ];
37
+ function isVoiceRoleKind(kind) {
38
+ return kind === "tts" || kind === "stt";
39
+ }
40
+ function entryOf(registry, id) {
41
+ const entries = isMergedRegistry(registry) ? registry.providers : registry;
42
+ return Object.hasOwn(entries, id) ? entries[id] : void 0;
43
+ }
44
+ function parseSelector(selector) {
45
+ const slash = selector.indexOf("/");
46
+ if (slash <= 0 || slash === selector.length - 1) return void 0;
47
+ return { entryId: selector.slice(0, slash), modelId: selector.slice(slash + 1) };
48
+ }
49
+ function findRole(roles, id) {
50
+ return roles.find((role) => role.id === id);
51
+ }
52
+ function assignmentIssue(role, roleId, value, registry) {
53
+ const issue = (code, message) => ({
54
+ role: roleId,
55
+ code,
56
+ message
57
+ });
58
+ if (!role) return issue("unknown_role", `Model role '${roleId}' does not exist.`);
59
+ if (!role.enabled) {
60
+ return issue("disabled_role", `Model role '${role.label}' (${roleId}) is disabled.`);
61
+ }
62
+ const voice = isVoiceRoleKind(role.kind);
63
+ let entryId;
64
+ if (voice) {
65
+ if (value.includes("/")) {
66
+ return issue(
67
+ "bad_selector",
68
+ `Model role '${role.label}' (${roleId}) takes a provider id, not '${value}'.`
69
+ );
70
+ }
71
+ entryId = value;
72
+ } else {
73
+ const parsed = parseSelector(value);
74
+ if (!parsed) {
75
+ return issue(
76
+ "bad_selector",
77
+ `Model role '${role.label}' (${roleId}) takes '<provider>/<model>', not '${value}'.`
78
+ );
79
+ }
80
+ entryId = parsed.entryId;
81
+ }
82
+ const entry = entryOf(registry, entryId);
83
+ if (!entry) {
84
+ return issue(
85
+ "entry_missing",
86
+ `Model role '${role.label}' (${roleId}) points at provider '${entryId}', which is not in the registry.`
87
+ );
88
+ }
89
+ if (!isEntryEnabled(entry)) {
90
+ return issue(
91
+ "entry_disabled",
92
+ `Model role '${role.label}' (${roleId}) points at provider '${entryId}', which is disabled.`
93
+ );
94
+ }
95
+ const wanted = voice ? role.kind : "llm";
96
+ if (entry.kind !== wanted) {
97
+ return issue(
98
+ "entry_wrong_kind",
99
+ `Model role '${role.label}' (${roleId}) needs a '${wanted}' provider, but '${entryId}' is '${entry.kind}'.`
100
+ );
101
+ }
102
+ return void 0;
103
+ }
104
+ export function validateModelRoleAssignments(roles, assignments, registry) {
105
+ const issues = [];
106
+ for (const [roleId, value] of Object.entries(assignments)) {
107
+ if (!value) continue;
108
+ const issue = assignmentIssue(findRole(roles, roleId), roleId, value, registry);
109
+ if (issue) issues.push(issue);
110
+ }
111
+ return issues;
112
+ }
113
+ export function resolveModelRole(roleId, context) {
114
+ const role = findRole(context.roles, roleId);
115
+ if (!role) {
116
+ throw new ModelResolutionError("unknown_selector", `Model role '${roleId}' does not exist.`);
117
+ }
118
+ if (!role.enabled) {
119
+ throw new ModelResolutionError(
120
+ "unknown_selector",
121
+ `Model role '${role.label}' (${roleId}) is disabled.`
122
+ );
123
+ }
124
+ const value = Object.hasOwn(context.assignments, roleId) ? context.assignments[roleId] : void 0;
125
+ if (!value) return { kind: "unassigned" };
126
+ const issue = assignmentIssue(role, roleId, value, context.registry);
127
+ if (issue) {
128
+ const providerId = isVoiceRoleKind(role.kind) ? value : parseSelector(value)?.entryId;
129
+ throw new ModelResolutionError("unknown_selector", issue.message, {
130
+ ...providerId !== void 0 && entryOf(context.registry, providerId) ? { providerId } : {}
131
+ });
132
+ }
133
+ if (isVoiceRoleKind(role.kind)) return { kind: "voice", entryId: value };
134
+ return { kind: "model", selector: value, entryId: value.slice(0, value.indexOf("/")) };
135
+ }
136
+ export function modelRoleAliases(roles, assignments) {
137
+ const aliases = {};
138
+ for (const role of roles) {
139
+ if (!role.enabled || isVoiceRoleKind(role.kind)) continue;
140
+ const value = Object.hasOwn(assignments, role.id) ? assignments[role.id] : void 0;
141
+ if (value) aliases[role.id] = value;
142
+ }
143
+ return aliases;
144
+ }
@@ -9,6 +9,9 @@
9
9
  export * from "../shared/env-vars.js";
10
10
  export * from "../shared/harness-providers.js";
11
11
  export * from "../shared/harnesses.js";
12
+ export * from "../shared/model-roles.js";
12
13
  export * from "../shared/provider-registry.js";
14
+ export * from "./model-roles-panel.js";
15
+ export * from "./providers-client.js";
13
16
  export * from "./settings-modal-scope.js";
14
17
  export * from "./useProvidersPanel.js";
@@ -1,6 +1,9 @@
1
1
  export * from "../shared/env-vars.js";
2
2
  export * from "../shared/harness-providers.js";
3
3
  export * from "../shared/harnesses.js";
4
+ export * from "../shared/model-roles.js";
4
5
  export * from "../shared/provider-registry.js";
6
+ export * from "./model-roles-panel.js";
7
+ export * from "./providers-client.js";
5
8
  export * from "./settings-modal-scope.js";
6
9
  export * from "./useProvidersPanel.js";
@@ -0,0 +1,70 @@
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
+ import type { ModelRoleAssignments, ModelRoleDefinition, ModelRoleKind } from "../shared/model-roles.js";
7
+ import type { AIProviderKind } from "../shared/provider-registry.js";
8
+ import type { ProvidersPanelEntry } from "./providers-panel-model.js";
9
+ /** What the roles panel needs of a providers client; `ProvidersPanelClient` satisfies it. */
10
+ export interface ModelRolesPanelClient {
11
+ /** Merged registry view including disabled entries. */
12
+ list(): Promise<ProvidersPanelEntry[]>;
13
+ /** The models one entry offers. */
14
+ models(id: string): Promise<ModelRolesModelsResult>;
15
+ }
16
+ /** The result of `ModelRolesPanelClient.models`. */
17
+ export interface ModelRolesModelsResult {
18
+ status: "live" | "static" | "unsupported";
19
+ models: {
20
+ id: string;
21
+ name: string;
22
+ }[];
23
+ message?: string;
24
+ }
25
+ /** One option of a provider or model select. */
26
+ export interface ModelRoleOption {
27
+ label: string;
28
+ value: string;
29
+ }
30
+ /** The registry entry kind a role kind assigns from. */
31
+ export declare function roleEntryKind(kind: ModelRoleKind): AIProviderKind;
32
+ /** Whether the role assigns an entry id (`tts`, `stt`) rather than a model. */
33
+ export declare function isVoiceRole(kind: ModelRoleKind): boolean;
34
+ /** The enabled entries of the kind this role assigns from. */
35
+ export declare function entriesForRole(kind: ModelRoleKind, entries: readonly ProvidersPanelEntry[]): ProvidersPanelEntry[];
36
+ /** `<entryId>/<modelId>`. */
37
+ export declare function buildSelector(entryId: string, modelId: string): string;
38
+ /** Splits on the first slash (model ids may contain slashes); no slash means no model. */
39
+ export declare function splitSelector(selector: string): {
40
+ entryId: string;
41
+ modelId: string;
42
+ };
43
+ /** The assignment of a role; inherited keys such as `constructor` are not assignments. */
44
+ export declare function assignmentOf(assignments: ModelRoleAssignments, roleId: string): string | undefined;
45
+ /** The assignments with one role set; an empty value unassigns it. Never mutates. */
46
+ export declare function withAssignment(assignments: ModelRoleAssignments, roleId: string, value: string): ModelRoleAssignments;
47
+ /** What one row shows. */
48
+ export interface ModelRoleRowState {
49
+ /** The provider the row has: the pending pick, else the assignment's. Empty when none. */
50
+ entryId: string;
51
+ /** The assigned model id (model roles only). */
52
+ modelId: string;
53
+ /** Whether the role has an assignment stored. */
54
+ assigned: boolean;
55
+ /** Enabled entries of the role's kind, plus the current one when it is unavailable. */
56
+ providerOptions: ModelRoleOption[];
57
+ /** Set when `entryId` is not an enabled entry of the role's kind. */
58
+ warning?: string;
59
+ }
60
+ /**
61
+ * The state of one row. `pendingEntryId` is a provider picked on a model role
62
+ * before a model exists: an assignment needs both, so the pick lives in the
63
+ * panel until the model is chosen.
64
+ */
65
+ export declare function modelRoleRowState(role: ModelRoleDefinition, assignment: string | undefined, entries: readonly ProvidersPanelEntry[], pendingEntryId?: string): ModelRoleRowState;
66
+ /** Options of a model select; the current model stays listed when the provider no longer offers it. */
67
+ export declare function modelOptions(models: readonly {
68
+ id: string;
69
+ name: string;
70
+ }[], currentModelId: string): ModelRoleOption[];
@@ -0,0 +1,54 @@
1
+ export function roleEntryKind(kind) {
2
+ if (kind === "tts") return "tts";
3
+ if (kind === "stt") return "stt";
4
+ return "llm";
5
+ }
6
+ export function isVoiceRole(kind) {
7
+ return kind === "tts" || kind === "stt";
8
+ }
9
+ export function entriesForRole(kind, entries) {
10
+ const wanted = roleEntryKind(kind);
11
+ return entries.filter((entry) => entry.enabled && entry.kind === wanted);
12
+ }
13
+ export function buildSelector(entryId, modelId) {
14
+ return `${entryId}/${modelId}`;
15
+ }
16
+ export function splitSelector(selector) {
17
+ const slash = selector.indexOf("/");
18
+ if (slash < 0) return { entryId: selector, modelId: "" };
19
+ return { entryId: selector.slice(0, slash), modelId: selector.slice(slash + 1) };
20
+ }
21
+ export function assignmentOf(assignments, roleId) {
22
+ return Object.hasOwn(assignments, roleId) ? assignments[roleId] : void 0;
23
+ }
24
+ export function withAssignment(assignments, roleId, value) {
25
+ const next = { ...assignments };
26
+ if (value === "") delete next[roleId];
27
+ else next[roleId] = value;
28
+ return next;
29
+ }
30
+ export function modelRoleRowState(role, assignment, entries, pendingEntryId) {
31
+ const value = assignment ?? "";
32
+ const voice = isVoiceRole(role.kind);
33
+ const parsed = voice ? { entryId: value, modelId: "" } : splitSelector(value);
34
+ const entryId = pendingEntryId ?? parsed.entryId;
35
+ const modelId = entryId === parsed.entryId ? parsed.modelId : "";
36
+ const offered = entriesForRole(role.kind, entries);
37
+ const providerOptions = offered.map((entry) => ({ label: entry.name, value: entry.id }));
38
+ let warning;
39
+ if (entryId !== "" && !offered.some((entry) => entry.id === entryId)) {
40
+ providerOptions.push({ label: `${entryId} (unavailable)`, value: entryId });
41
+ warning = `Provider "${entryId}" is not available`;
42
+ }
43
+ return { entryId, modelId, assigned: value !== "", providerOptions, warning };
44
+ }
45
+ export function modelOptions(models, currentModelId) {
46
+ const options = models.map((model) => ({
47
+ label: model.name && model.name !== model.id ? `${model.name} (${model.id})` : model.id,
48
+ value: model.id
49
+ }));
50
+ if (currentModelId !== "" && !models.some((model) => model.id === currentModelId)) {
51
+ options.push({ label: `${currentModelId} (not listed)`, value: currentModelId });
52
+ }
53
+ return options;
54
+ }
@@ -0,0 +1,67 @@
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
+ import type { EnvSettingsDocument, EnvSettingsUpdate } from "../shared/env-vars.js";
9
+ import { type ProvidersPanelEntry, type ProvidersPanelPreset } from "./providers-panel-model.js";
10
+ /** The models of one provider entry, as `ProvidersPanelClient.models` answers. */
11
+ export interface ProviderModelsResult {
12
+ /**
13
+ * `live`: asked the provider's own endpoint; `static`: a fixed list from the
14
+ * catalog; `unsupported`: the entry cannot list models (free-text entry).
15
+ */
16
+ status: "live" | "static" | "unsupported";
17
+ models: {
18
+ id: string;
19
+ name: string;
20
+ }[];
21
+ /** Why the list is empty or not live. Secret-free. */
22
+ message?: string;
23
+ }
24
+ /** The outcome of one provider credential test. */
25
+ export interface ProvidersPanelTestResult {
26
+ ok: boolean;
27
+ /** What the provider answered or why it refused. Secret-free. */
28
+ message: string;
29
+ }
30
+ /** Every call `ForgeProvidersPanel` and `ForgeModelRolesPanel` make. */
31
+ export interface ProvidersPanelClient {
32
+ /** The merged view, disabled entries included. */
33
+ list(): Promise<ProvidersPanelEntry[]>;
34
+ /** Presets whose key is set in the scope's environment. */
35
+ detected(): Promise<ProvidersPanelPreset[]>;
36
+ /** The catalog presets. */
37
+ presets(): Promise<ProvidersPanelPreset[]>;
38
+ /** Create or change an entry; the body is the registry entry's editable fields. */
39
+ upsert(id: string, body: Record<string, unknown>): Promise<void>;
40
+ /** Delete an entry of the scope's own document. */
41
+ remove(id: string): Promise<void>;
42
+ /** The scope's `.env` document. */
43
+ environment(): Promise<EnvSettingsDocument>;
44
+ /** Write variables to the scope's `.env`. */
45
+ updateEnvironment(update: EnvSettingsUpdate): Promise<void>;
46
+ /** The models of one entry. */
47
+ models(id: string): Promise<ProviderModelsResult>;
48
+ /** Check that the entry's credential works. */
49
+ test(id: string): Promise<ProvidersPanelTestResult>;
50
+ }
51
+ /** The URLs {@link urlProvidersClient} calls. */
52
+ export interface UrlProvidersClientUrls {
53
+ /** `GET ${providersUrl}?all=1`: the merged view. */
54
+ providersUrl: string;
55
+ /** `PUT|DELETE ${providersWriteUrl}/:id`; `GET ${providersWriteUrl}/detected`; `GET .../:id/models`; `POST .../:id/test`. */
56
+ providersWriteUrl: string;
57
+ /** Catalog presets. Default `/api/providers/presets`. */
58
+ presetsUrl?: string;
59
+ /** The scope's `.env` route. Omitted, `environment` and `updateEnvironment` reject. */
60
+ environmentUrl?: string;
61
+ }
62
+ /**
63
+ * The HTTP transport: the requests the panel made before it took a client.
64
+ * `models` and `test` call routes skaile-forge does not serve; they are for
65
+ * hosts that do.
66
+ */
67
+ export declare function urlProvidersClient(urls: UrlProvidersClientUrls): ProvidersPanelClient;
@@ -0,0 +1,28 @@
1
+ import { $fetch } from "ofetch";
2
+ import {
3
+ DEFAULT_PRESETS_URL
4
+ } from "./providers-panel-model.js";
5
+ export function urlProvidersClient(urls) {
6
+ const entryUrl = (id) => `${urls.providersWriteUrl}/${encodeURIComponent(id)}`;
7
+ const environmentUrl = () => {
8
+ if (!urls.environmentUrl) throw new Error("This scope has no environment route.");
9
+ return urls.environmentUrl;
10
+ };
11
+ return {
12
+ list: () => $fetch(urls.providersUrl, { query: { all: "1" } }),
13
+ detected: () => $fetch(`${urls.providersWriteUrl}/detected`),
14
+ presets: () => $fetch(urls.presetsUrl ?? DEFAULT_PRESETS_URL),
15
+ upsert: async (id, body) => {
16
+ await $fetch(entryUrl(id), { method: "PUT", body });
17
+ },
18
+ remove: async (id) => {
19
+ await $fetch(entryUrl(id), { method: "DELETE" });
20
+ },
21
+ environment: async () => await $fetch(environmentUrl()),
22
+ updateEnvironment: async (update) => {
23
+ await $fetch(environmentUrl(), { method: "PUT", body: update });
24
+ },
25
+ models: (id) => $fetch(`${entryUrl(id)}/models`),
26
+ test: (id) => $fetch(`${entryUrl(id)}/test`, { method: "POST" })
27
+ };
28
+ }
@@ -6,14 +6,21 @@
6
6
  */
7
7
  import type { EnvLayerId } from "../shared/env-vars.js";
8
8
  import type { AIProviderCredentialKind, AIProviderKind, AIProviderSurface } from "../shared/provider-registry.js";
9
+ import type { ProvidersPanelClient } from "./providers-client.js";
9
10
  /** Registry document a provider entry is defined in. */
10
11
  export type ProvidersPanelLayer = "instance" | "workspace";
11
12
  /** Props both `ForgeProvidersPanel` variants declare. */
12
13
  export interface ProvidersPanelProps {
13
- /** `GET` merged view for this scope (`/api/providers` | `/api/workspaces/:id/providers`). */
14
- providersUrl: string;
15
- /** `PUT|DELETE ${providersWriteUrl}/:id`; `GET ${providersWriteUrl}/detected`. */
16
- providersWriteUrl: string;
14
+ /**
15
+ * The panel's transport. Without it the panel talks to the URL props below
16
+ * through {@link urlProvidersClient}; with it the URL props are ignored. An
17
+ * explicit client also turns on the per-row Test button.
18
+ */
19
+ client?: ProvidersPanelClient;
20
+ /** `GET` merged view for this scope (`/api/providers` | `/api/workspaces/:id/providers`). Required without `client`. */
21
+ providersUrl?: string;
22
+ /** `PUT|DELETE ${providersWriteUrl}/:id`; `GET ${providersWriteUrl}/detected`. Required without `client`. */
23
+ providersWriteUrl?: string;
17
24
  /** Catalog preset summaries. Default `/api/providers/presets`. */
18
25
  presetsUrl?: string;
19
26
  /**
@@ -21,6 +28,12 @@ export interface ProvidersPanelProps {
21
28
  * into the panel write through it. Omitted, no key can be entered.
22
29
  */
23
30
  environmentUrl?: string;
31
+ /**
32
+ * Whether the panel reads and writes the scope's environment (key entry,
33
+ * the environment document). Default: `Boolean(environmentUrl)` without
34
+ * `client`, `true` with one.
35
+ */
36
+ environmentEnabled?: boolean;
24
37
  /** The scope's own registry document. */
25
38
  layer: ProvidersPanelLayer;
26
39
  /** Disables every write (non-admin at instance scope; provider lock). */
@@ -16,9 +16,10 @@
16
16
  import { type ComputedRef, type Ref, type WritableComputedRef } from "vue";
17
17
  import { type EnvSettingsDocument } from "../shared/env-vars.js";
18
18
  import { type AIProviderSurface } from "../shared/provider-registry.js";
19
- import { type ProvidersPanelEntry, type ProvidersPanelEmit, type ProvidersPanelForm, type ProvidersPanelPreset, type ProvidersPanelProps, type ProvidersPanelRow } from "./providers-panel-model.js";
20
- export { CREDENTIAL_ENTER_KEY, CREDENTIAL_OTHER_REF, CUSTOM_PRESET } from "./providers-panel-model.js";
19
+ import { type ProvidersPanelTestResult } from "./providers-client.js";
20
+ import { type ProvidersPanelEmit, type ProvidersPanelEntry, type ProvidersPanelForm, type ProvidersPanelPreset, type ProvidersPanelProps, type ProvidersPanelRow } from "./providers-panel-model.js";
21
21
  export type { ProvidersPanelCredentialOption, ProvidersPanelEmit, ProvidersPanelEntry, ProvidersPanelForm, ProvidersPanelLayer, ProvidersPanelPreset, ProvidersPanelProps, ProvidersPanelRow, ProvidersPanelRowState, } from "./providers-panel-model.js";
22
+ export { CREDENTIAL_ENTER_KEY, CREDENTIAL_OTHER_REF, CUSTOM_PRESET } from "./providers-panel-model.js";
22
23
  /** What both `ForgeProvidersPanel` variants bind to. */
23
24
  export interface ProvidersPanelState {
24
25
  /** The scope's entries, disabled ones included, in registry order. */
@@ -82,6 +83,13 @@ export interface ProvidersPanelState {
82
83
  id: string;
83
84
  message: string;
84
85
  } | null>;
86
+ /** The panel was given an explicit client: rows offer a connection test. */
87
+ canTest: ComputedRef<boolean>;
88
+ /** Row id with a test in flight. */
89
+ testBusy: Ref<string | null>;
90
+ /** The last test result per row id. */
91
+ testResults: Record<string, ProvidersPanelTestResult>;
92
+ testProvider: (row: ProvidersPanelEntry) => Promise<void>;
85
93
  /** "Enter a key…" is offered: the scope has an editable `.env` route. */
86
94
  canEnterKey: ComputedRef<boolean>;
87
95
  /** Row id whose key editor is open. */