@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
@@ -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>;
@@ -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,10 +28,21 @@ 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). */
27
40
  readOnly?: boolean;
41
+ /**
42
+ * The line above the provider list. Absent: the panel's own text for
43
+ * `layer`; a string replaces it; `false` hides it (Add provider stays).
44
+ */
45
+ note?: string | false;
28
46
  }
29
47
  /** Emits both variants declare. */
30
48
  export type ProvidersPanelEmit = (event: "changed") => void;
@@ -129,6 +147,8 @@ export declare const CREDENTIAL_OTHER_REF = "__other_ref__";
129
147
  /** Registry ids the write routes accept (`isSafeProviderId` upstream). */
130
148
  export declare const PROVIDER_ID_PATTERN: RegExp;
131
149
  export declare const DEFAULT_PRESETS_URL = "/api/providers/presets";
150
+ /** The line `ForgeProvidersPanel` shows above its list; `null` = none. */
151
+ export declare function providersPanelNote(note: string | false | undefined, layer: ProvidersPanelLayer): string | null;
132
152
  /** An `env:NAME` credential reference; group 1 is the variable name. */
133
153
  export declare const ENV_REF_PATTERN: RegExp;
134
154
  /** The credential kind a written reference gets when the row states none. */
@@ -3,6 +3,11 @@ export const CREDENTIAL_ENTER_KEY = "__enter_key__";
3
3
  export const CREDENTIAL_OTHER_REF = "__other_ref__";
4
4
  export const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
5
5
  export const DEFAULT_PRESETS_URL = "/api/providers/presets";
6
+ export function providersPanelNote(note, layer) {
7
+ if (note === false) return null;
8
+ if (note !== void 0) return note;
9
+ return layer === "workspace" ? "Entries of this workspace's providers.yml can be changed here; instance entries are inherited. Keys stay in the environment; an entry only references them." : "The instance registry, $FORGE_HOME/.skaile/providers.yml. Keys stay in the environment; an entry only references them.";
10
+ }
6
11
  export const ENV_REF_PATTERN = /^env:(.+)$/;
7
12
  export const FALLBACK_CREDENTIAL_KIND = "api_key_header";
8
13
  export const ENV_SOURCE_LABEL = {
@@ -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. */