@skaile/forge-settings 0.0.0-stage → 1.0.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 (89) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +94 -2
  4. package/dist/module.d.mts +17 -0
  5. package/dist/module.json +9 -0
  6. package/dist/module.mjs +23 -0
  7. package/dist/runtime/ai-sdk/index.d.ts +46 -0
  8. package/dist/runtime/ai-sdk/index.js +31 -0
  9. package/dist/runtime/components/ForgeProvidersPanel.d.vue.ts +21 -0
  10. package/dist/runtime/components/ForgeProvidersPanel.vue +435 -0
  11. package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +21 -0
  12. package/dist/runtime/editor/index.d.ts +58 -0
  13. package/dist/runtime/editor/index.js +72 -0
  14. package/dist/runtime/model/errors.d.ts +32 -0
  15. package/dist/runtime/model/errors.js +19 -0
  16. package/dist/runtime/server/credential-policy.d.ts +61 -0
  17. package/dist/runtime/server/credential-policy.js +65 -0
  18. package/dist/runtime/server/credential-store.d.ts +184 -0
  19. package/dist/runtime/server/credential-store.js +234 -0
  20. package/dist/runtime/server/env-layers.d.ts +62 -0
  21. package/dist/runtime/server/env-layers.js +183 -0
  22. package/dist/runtime/server/env.d.ts +30 -0
  23. package/dist/runtime/server/env.js +15 -0
  24. package/dist/runtime/server/harness-settings.d.ts +150 -0
  25. package/dist/runtime/server/harness-settings.js +166 -0
  26. package/dist/runtime/server/index.d.ts +23 -0
  27. package/dist/runtime/server/index.js +14 -0
  28. package/dist/runtime/server/provider-discovery.d.ts +73 -0
  29. package/dist/runtime/server/provider-discovery.js +77 -0
  30. package/dist/runtime/server/provider-layers.d.ts +27 -0
  31. package/dist/runtime/server/provider-layers.js +35 -0
  32. package/dist/runtime/server/provider-service.d.ts +126 -0
  33. package/dist/runtime/server/provider-service.js +87 -0
  34. package/dist/runtime/server/provider-settings.d.ts +78 -0
  35. package/dist/runtime/server/provider-settings.js +81 -0
  36. package/dist/runtime/server/provider-write.d.ts +154 -0
  37. package/dist/runtime/server/provider-write.js +221 -0
  38. package/dist/runtime/server/settings-defaults.d.ts +26 -0
  39. package/dist/runtime/server/settings-defaults.js +46 -0
  40. package/dist/runtime/shared/env-vars.d.ts +55 -0
  41. package/dist/runtime/shared/env-vars.js +22 -0
  42. package/dist/runtime/shared/guards.d.ts +2 -0
  43. package/dist/runtime/shared/guards.js +3 -0
  44. package/dist/runtime/shared/harness-providers.d.ts +62 -0
  45. package/dist/runtime/shared/harness-providers.js +38 -0
  46. package/dist/runtime/shared/harnesses.d.ts +19 -0
  47. package/dist/runtime/shared/harnesses.js +15 -0
  48. package/dist/runtime/shared/provider-registry.d.ts +255 -0
  49. package/dist/runtime/shared/provider-registry.js +112 -0
  50. package/dist/runtime/shared/settings-types.d.ts +84 -0
  51. package/dist/runtime/shared/settings-types.js +0 -0
  52. package/dist/runtime/shims-vue.d.ts +8 -0
  53. package/dist/runtime/vue/index.d.ts +14 -0
  54. package/dist/runtime/vue/index.js +6 -0
  55. package/dist/runtime/vue/providers-panel-model.d.ts +147 -0
  56. package/dist/runtime/vue/providers-panel-model.js +37 -0
  57. package/dist/runtime/vue/settings-modal-scope.d.ts +62 -0
  58. package/dist/runtime/vue/settings-modal-scope.js +2 -0
  59. package/dist/runtime/vue/useProvidersPanel.d.ts +115 -0
  60. package/dist/runtime/vue/useProvidersPanel.js +633 -0
  61. package/dist/types.d.mts +9 -0
  62. package/package.json +117 -4
  63. package/src/runtime/ai-sdk/index.ts +88 -0
  64. package/src/runtime/components/ForgeProvidersPanel.vue +449 -0
  65. package/src/runtime/editor/index.ts +146 -0
  66. package/src/runtime/model/errors.ts +52 -0
  67. package/src/runtime/server/credential-policy.ts +139 -0
  68. package/src/runtime/server/credential-store.ts +473 -0
  69. package/src/runtime/server/env-layers.ts +287 -0
  70. package/src/runtime/server/env.ts +46 -0
  71. package/src/runtime/server/harness-settings.ts +373 -0
  72. package/src/runtime/server/index.ts +24 -0
  73. package/src/runtime/server/provider-discovery.ts +164 -0
  74. package/src/runtime/server/provider-layers.ts +67 -0
  75. package/src/runtime/server/provider-service.ts +244 -0
  76. package/src/runtime/server/provider-settings.ts +192 -0
  77. package/src/runtime/server/provider-write.ts +433 -0
  78. package/src/runtime/server/settings-defaults.ts +87 -0
  79. package/src/runtime/shared/env-vars.ts +82 -0
  80. package/src/runtime/shared/guards.ts +4 -0
  81. package/src/runtime/shared/harness-providers.ts +118 -0
  82. package/src/runtime/shared/harnesses.ts +34 -0
  83. package/src/runtime/shared/provider-registry.ts +391 -0
  84. package/src/runtime/shared/settings-types.ts +87 -0
  85. package/src/runtime/shims-vue.d.ts +8 -0
  86. package/src/runtime/vue/index.ts +15 -0
  87. package/src/runtime/vue/providers-panel-model.ts +203 -0
  88. package/src/runtime/vue/settings-modal-scope.ts +66 -0
  89. package/src/runtime/vue/useProvidersPanel.ts +930 -0
@@ -0,0 +1,930 @@
1
+ /**
2
+ * Shared brain for `components-nuxt-ui/ForgeProvidersPanel.vue` — the settings
3
+ * modal's Providers tab.
4
+ *
5
+ * It drives the registry editor: loading, the add/edit form, the delete confirmation,
6
+ * the "detected in environment" one-click adds, the per-row credential picker,
7
+ * the enabled toggle and every request live here.
8
+ *
9
+ * The panel writes one registry document — the scope's own `layer` — through
10
+ * `PUT|DELETE ${providersWriteUrl}/:id`. An entry a lower layer defines can be
11
+ * patched at workspace scope (`credential` / `enabled` only) and reset by
12
+ * deleting the patch. A key typed into the panel goes to the scope's `.env`
13
+ * route only; the provider route receives an `env:NAME` reference, never a
14
+ * value.
15
+ */
16
+
17
+ import { $fetch } from "ofetch";
18
+ import {
19
+ type ComputedRef,
20
+ computed,
21
+ onMounted,
22
+ type Ref,
23
+ reactive,
24
+ ref,
25
+ type WritableComputedRef,
26
+ watch,
27
+ } from "vue";
28
+ import {
29
+ type EnvLayerId,
30
+ type EnvSettingsDocument,
31
+ type EnvSettingsUpdate,
32
+ type EnvVarView,
33
+ validateEnvVarName,
34
+ } from "../shared/env-vars";
35
+ import {
36
+ AI_PROVIDER_SURFACES,
37
+ type AIProviderCredentialKind,
38
+ type AIProviderKind,
39
+ type AIProviderSurface,
40
+ validateCredentialRef,
41
+ } from "../shared/provider-registry";
42
+ import {
43
+ CREDENTIAL_ENTER_KEY,
44
+ CREDENTIAL_OTHER_REF,
45
+ CUSTOM_PRESET,
46
+ DEFAULT_PRESETS_URL,
47
+ EMPTY_FORM,
48
+ ENV_SOURCE_LABEL,
49
+ envNameOfRef,
50
+ FALLBACK_CREDENTIAL_KIND,
51
+ nonEnvSourceLabel,
52
+ OVERRIDES_INSTANCE_NOTICE,
53
+ PROVIDER_ID_PATTERN,
54
+ type ProvidersPanelCredentialOption,
55
+ type ProvidersPanelEntry,
56
+ type ProvidersPanelEmit,
57
+ type ProvidersPanelForm,
58
+ type ProvidersPanelPreset,
59
+ type ProvidersPanelProps,
60
+ type ProvidersPanelRow,
61
+ type ProvidersPanelRowState,
62
+ requestErrorMessage,
63
+ } from "./providers-panel-model";
64
+
65
+ export { CREDENTIAL_ENTER_KEY, CREDENTIAL_OTHER_REF, CUSTOM_PRESET } from "./providers-panel-model";
66
+ export type {
67
+ ProvidersPanelCredentialOption,
68
+ ProvidersPanelEmit,
69
+ ProvidersPanelEntry,
70
+ ProvidersPanelForm,
71
+ ProvidersPanelLayer,
72
+ ProvidersPanelPreset,
73
+ ProvidersPanelProps,
74
+ ProvidersPanelRow,
75
+ ProvidersPanelRowState,
76
+ } from "./providers-panel-model";
77
+
78
+ /** What both `ForgeProvidersPanel` variants bind to. */
79
+ export interface ProvidersPanelState {
80
+ /** The scope's entries, disabled ones included, in registry order. */
81
+ rows: ComputedRef<ProvidersPanelRow[]>;
82
+ loading: Ref<boolean>;
83
+ loadError: Ref<string | null>;
84
+ /** Re-read the entries, the detected presets and the environment document. */
85
+ refresh: () => Promise<void>;
86
+
87
+ dialogVisible: Ref<boolean>;
88
+ dialogTitle: ComputedRef<string>;
89
+ /** The row being edited; `null` while adding. */
90
+ editing: Ref<ProvidersPanelEntry | null>;
91
+ form: ProvidersPanelForm;
92
+ /** A refusal from the form check or either request, shown in the dialog. */
93
+ dialogError: Ref<string | null>;
94
+ saving: Ref<boolean>;
95
+ /** Catalog presets plus "Custom" ({@link CUSTOM_PRESET}). */
96
+ presetOptions: ComputedRef<Array<{ label: string; value: string }>>;
97
+ surfaceOptions: Array<{ label: string; value: AIProviderSurface }>;
98
+ isCustom: ComputedRef<boolean>;
99
+ /** The surface select is shown: a custom `llm`/`embed` entry. */
100
+ showSurface: ComputedRef<boolean>;
101
+ baseUrlPlaceholder: ComputedRef<string>;
102
+ /** "Set value now" is offered: the scope has an `.env` route. */
103
+ canSetValue: ComputedRef<boolean>;
104
+ /** The dialog shows the env name and secret fields: adding, not editing. */
105
+ showCredentialFields: ComputedRef<boolean>;
106
+ idError: ComputedRef<string | null>;
107
+ openAdd: (presetId?: string) => void;
108
+ openEdit: (row: ProvidersPanelEntry) => void;
109
+ selectPreset: (value: string) => void;
110
+ saveProvider: () => Promise<void>;
111
+
112
+ deleteTarget: Ref<ProvidersPanelEntry | null>;
113
+ /** The confirmation dialog's `v-model`; closing it cancels. */
114
+ deleteOpen: WritableComputedRef<boolean>;
115
+ deleteError: Ref<string | null>;
116
+ deleting: Ref<boolean>;
117
+ requestDelete: (row: ProvidersPanelEntry) => void;
118
+ confirmDelete: () => Promise<void>;
119
+
120
+ /** Presets whose key is set in the scope's env and that no entry names. */
121
+ detected: Ref<ProvidersPanelPreset[]>;
122
+ /** Preset id whose one-click add is in flight. */
123
+ detectedBusy: Ref<string | null>;
124
+ detectedError: Ref<string | null>;
125
+ addDetected: (preset: ProvidersPanelPreset) => Promise<void>;
126
+
127
+ // Row actions
128
+ /** The credential select's `@update`: a NAME writes, the two actions open an editor. */
129
+ onCredentialSelect: (row: ProvidersPanelRow, value: string) => void;
130
+ setCredentialEnv: (row: ProvidersPanelRow, name: string) => Promise<void>;
131
+ setEnabled: (row: ProvidersPanelRow, enabled: boolean) => Promise<void>;
132
+ /** Delete the workspace patch of an inherited entry. */
133
+ resetPatch: (row: ProvidersPanelRow) => Promise<void>;
134
+ /** Row id with a write in flight. */
135
+ rowBusy: Ref<string | null>;
136
+ rowError: Ref<{ id: string; message: string } | null>;
137
+
138
+ // Inline key editor
139
+ /** "Enter a key…" is offered: the scope has an editable `.env` route. */
140
+ canEnterKey: ComputedRef<boolean>;
141
+ /** Row id whose key editor is open. */
142
+ keyEditing: Ref<string | null>;
143
+ keyForm: { name: string; value: string };
144
+ keyError: Ref<string | null>;
145
+ openKeyEditor: (row: ProvidersPanelRow) => void;
146
+ cancelKeyEditor: () => void;
147
+ saveKeyEditor: () => Promise<void>;
148
+
149
+ // Inline reference editor
150
+ /** Row id whose reference editor is open. */
151
+ refEditing: Ref<string | null>;
152
+ refForm: { ref: string };
153
+ refError: Ref<string | null>;
154
+ openRefEditor: (row: ProvidersPanelRow) => void;
155
+ cancelRefEditor: () => void;
156
+ saveRefEditor: () => Promise<void>;
157
+
158
+ // Environment
159
+ envDocument: Ref<EnvSettingsDocument | null>;
160
+ /** The environment document says the caller may write its layer. */
161
+ envEditable: ComputedRef<boolean>;
162
+ /** Draft new values per variable name. */
163
+ envValues: Record<string, string>;
164
+ envError: Ref<string | null>;
165
+ envBusy: Ref<boolean>;
166
+ setEnvValue: (name: string) => Promise<void>;
167
+ }
168
+
169
+ // ── Composable ───────────────────────────────────────────────────────────────
170
+
171
+ export function useProvidersPanel(
172
+ props: ProvidersPanelProps,
173
+ emit: ProvidersPanelEmit,
174
+ ): ProvidersPanelState {
175
+ const providers = ref<ProvidersPanelEntry[]>([]);
176
+ const presets = ref<ProvidersPanelPreset[]>([]);
177
+ const detected = ref<ProvidersPanelPreset[]>([]);
178
+ const envDocument = ref<EnvSettingsDocument | null>(null);
179
+ const loading = ref(false);
180
+ const loadError = ref<string | null>(null);
181
+ const envError = ref<string | null>(null);
182
+
183
+ // ── Loading ────────────────────────────────────────────────────────────────
184
+
185
+ /** Read the scope's `.env` document. A failure shows in the disclosure and blocks nothing else. */
186
+ async function loadEnvironment() {
187
+ if (!props.environmentUrl) {
188
+ envDocument.value = null;
189
+ return;
190
+ }
191
+ try {
192
+ envDocument.value = (await $fetch(props.environmentUrl)) as EnvSettingsDocument;
193
+ envError.value = null;
194
+ } catch (err) {
195
+ envError.value = requestErrorMessage(err, "Could not load the environment variables.");
196
+ }
197
+ }
198
+
199
+ /** Re-read the entries (disabled ones included), the detected presets and the environment. */
200
+ async function refresh() {
201
+ loading.value = true;
202
+ loadError.value = null;
203
+ try {
204
+ const [list, found] = await Promise.all([
205
+ $fetch(props.providersUrl, { query: { all: "1" } }) as Promise<ProvidersPanelEntry[]>,
206
+ $fetch(`${props.providersWriteUrl}/detected`) as Promise<ProvidersPanelPreset[]>,
207
+ loadEnvironment(),
208
+ ]);
209
+ providers.value = Array.isArray(list) ? list : [];
210
+ detected.value = Array.isArray(found) ? found : [];
211
+ } catch (err) {
212
+ loadError.value = requestErrorMessage(err, "Could not load the providers.");
213
+ } finally {
214
+ loading.value = false;
215
+ }
216
+ }
217
+
218
+ async function loadPresets() {
219
+ try {
220
+ const list = (await $fetch(
221
+ props.presetsUrl ?? DEFAULT_PRESETS_URL,
222
+ )) as ProvidersPanelPreset[];
223
+ presets.value = Array.isArray(list) ? list : [];
224
+ } catch (err) {
225
+ loadError.value = requestErrorMessage(err, "Could not load the provider presets.");
226
+ }
227
+ }
228
+
229
+ /** After every successful write: reload list, banner and environment, tell the host. */
230
+ async function afterWrite() {
231
+ await refresh();
232
+ emit("changed");
233
+ }
234
+
235
+ onMounted(() => {
236
+ void loadPresets();
237
+ void refresh();
238
+ });
239
+
240
+ watch(
241
+ () => [props.providersUrl, props.providersWriteUrl, props.environmentUrl],
242
+ () => void refresh(),
243
+ );
244
+
245
+ // ── Table ──────────────────────────────────────────────────────────────────
246
+
247
+ const envVars = computed(() => {
248
+ const byName = new Map<string, EnvVarView>();
249
+ for (const v of envDocument.value?.vars ?? []) byName.set(v.name, v);
250
+ return byName;
251
+ });
252
+
253
+ const envEditable = computed(() => envDocument.value?.editable === true);
254
+
255
+ const canEnterKey = computed(
256
+ () => Boolean(props.environmentUrl) && envDocument.value?.editable !== false,
257
+ );
258
+
259
+ /** Where an entry lives relative to this scope's own document. */
260
+ function stateOf(entry: Pick<ProvidersPanelEntry, "layers">): ProvidersPanelRowState {
261
+ if (props.layer !== "workspace" || !entry.layers.includes("instance")) return "own";
262
+ return entry.layers.includes("workspace") ? "patched" : "inherited";
263
+ }
264
+
265
+ /** Whether the scope's own document defines the entry and the panel may write it. */
266
+ function isEditable(entry: ProvidersPanelEntry): boolean {
267
+ return !props.readOnly && !entry.locked && stateOf(entry) === "own";
268
+ }
269
+
270
+ /** The select's options: every env var name worth offering, then the literal ref and the two actions. */
271
+ function credentialOptionsOf(entry: ProvidersPanelEntry): ProvidersPanelCredentialOption[] {
272
+ const doc = envDocument.value;
273
+ const currentEnv = envNameOfRef(entry.credentialRef);
274
+ const names = new Set<string>();
275
+ for (const v of doc?.vars ?? []) names.add(v.name);
276
+ for (const m of doc?.missing ?? []) names.add(m.name);
277
+ const rowPresetEnv = presets.value.find((p) => p.id === entry.preset)?.credential.env;
278
+ if (rowPresetEnv) names.add(rowPresetEnv);
279
+ for (const p of detected.value) if (p.credential.env) names.add(p.credential.env);
280
+ if (currentEnv) names.add(currentEnv);
281
+
282
+ const options: ProvidersPanelCredentialOption[] = [...names].sort().map((name) => {
283
+ const v = envVars.value.get(name);
284
+ if (!doc) return { value: name, label: name };
285
+ if (!v) return { value: name, label: `${name} (not set)`, defined: false };
286
+ return {
287
+ value: name,
288
+ label: v.hint ? `${name} ••••${v.hint}` : name,
289
+ defined: true,
290
+ hint: v.hint,
291
+ source: v.source,
292
+ };
293
+ });
294
+ if (entry.credentialRef && !currentEnv) {
295
+ options.push({ value: entry.credentialRef, label: entry.credentialRef });
296
+ }
297
+ if (canEnterKey.value) {
298
+ options.push({ value: CREDENTIAL_ENTER_KEY, label: "Enter a key…", action: "enter-key" });
299
+ }
300
+ options.push({ value: CREDENTIAL_OTHER_REF, label: "Other reference…", action: "other-ref" });
301
+ return options;
302
+ }
303
+
304
+ function statusOf(entry: ProvidersPanelEntry, needsCredential: boolean): string {
305
+ if (!needsCredential) return "No credential needed.";
306
+ const ref = entry.credentialRef;
307
+ const envName = envNameOfRef(ref);
308
+ if (entry.configured) {
309
+ const source = envName
310
+ ? (() => {
311
+ const v = envVars.value.get(envName);
312
+ return v ? ENV_SOURCE_LABEL[v.source] : undefined;
313
+ })()
314
+ : ref
315
+ ? nonEnvSourceLabel(ref)
316
+ : undefined;
317
+ return source ? `configured · ${source}` : "configured";
318
+ }
319
+ const missing = envName ?? entry.envKeyName;
320
+ return missing ? `missing ${missing}` : (entry.error ?? "not configured");
321
+ }
322
+
323
+ function overrideNoticesOf(entry: ProvidersPanelEntry): string[] {
324
+ const notices: string[] = [];
325
+ const envName = envNameOfRef(entry.credentialRef);
326
+ const v = envName ? envVars.value.get(envName) : undefined;
327
+ if (v && v.shadows.length > 0) {
328
+ notices.push(`Also defined in ${v.shadows.join(", ")}; the ${v.source} value is used.`);
329
+ }
330
+ if (v && props.layer === "workspace" && (v.source === "instance" || v.source === "process")) {
331
+ notices.push(OVERRIDES_INSTANCE_NOTICE);
332
+ }
333
+ if (entry.locked) notices.push("Locked by the control plane.");
334
+ return notices;
335
+ }
336
+
337
+ function buildRow(entry: ProvidersPanelEntry): ProvidersPanelRow {
338
+ const state = stateOf(entry);
339
+ const writable = !props.readOnly && !entry.locked;
340
+ const needsCredential = entry.credentialKind !== "none";
341
+ const envName = envNameOfRef(entry.credentialRef);
342
+ return {
343
+ ...entry,
344
+ state,
345
+ badge:
346
+ state === "patched"
347
+ ? "instance · overridden here"
348
+ : state === "inherited"
349
+ ? "instance"
350
+ : props.layer,
351
+ inheritedNote: state === "inherited" ? "Inherited from the instance." : undefined,
352
+ editable: writable && state === "own",
353
+ canReset: writable && state === "patched",
354
+ canToggle: writable,
355
+ needsCredential,
356
+ canEditCredential: needsCredential && writable,
357
+ credentialValue: envName ?? entry.credentialRef ?? "",
358
+ credentialOptions: needsCredential ? credentialOptionsOf(entry) : [],
359
+ origin: entry.preset ?? entry.surface ?? entry.kind,
360
+ status: statusOf(entry, needsCredential),
361
+ overrideNotices: overrideNoticesOf(entry),
362
+ };
363
+ }
364
+
365
+ const rows = computed<ProvidersPanelRow[]>(() => providers.value.map(buildRow));
366
+
367
+ // ── Add / edit dialog ──────────────────────────────────────────────────────
368
+
369
+ const dialogVisible = ref(false);
370
+ /** The row being edited; `null` while adding. */
371
+ const editing = ref<ProvidersPanelEntry | null>(null);
372
+ const form = reactive<ProvidersPanelForm>({ ...EMPTY_FORM });
373
+ const dialogError = ref<string | null>(null);
374
+ const saving = ref(false);
375
+
376
+ const presetOptions = computed(() => [
377
+ ...presets.value.map((preset) => ({ label: preset.label, value: preset.id })),
378
+ { label: "Custom", value: CUSTOM_PRESET },
379
+ ]);
380
+
381
+ const surfaceOptions = AI_PROVIDER_SURFACES.map((surface) => ({
382
+ label: surface,
383
+ value: surface,
384
+ }));
385
+
386
+ const selectedPreset = computed(() => presets.value.find((preset) => preset.id === form.preset));
387
+
388
+ const isCustom = computed(() => form.preset === CUSTOM_PRESET);
389
+
390
+ /** Kind of a custom entry: the edited one's, else `llm`. */
391
+ const customKind = computed<AIProviderKind>(() => editing.value?.kind ?? "llm");
392
+
393
+ /** Custom `llm`/`embed` entries name their wire protocol; voice entries have none. */
394
+ const showSurface = computed(
395
+ () => isCustom.value && (customKind.value === "llm" || customKind.value === "embed"),
396
+ );
397
+
398
+ const baseUrlPlaceholder = computed(
399
+ () => selectedPreset.value?.base_url ?? "https://api.example.com/v1",
400
+ );
401
+
402
+ /** Whether "Set value now" is offered: the scope has an `.env` route. */
403
+ const canSetValue = computed(() => Boolean(props.environmentUrl));
404
+
405
+ /** The dialog collects a credential for a new entry only. */
406
+ const showCredentialFields = computed(() => editing.value === null);
407
+
408
+ const idError = computed(() => {
409
+ const id = form.id.trim();
410
+ return id && !PROVIDER_ID_PATTERN.test(id)
411
+ ? "Use lower-case letters, digits, '-' and '_', starting with a letter or digit."
412
+ : null;
413
+ });
414
+
415
+ const dialogTitle = computed(() =>
416
+ editing.value ? `Edit provider — ${editing.value.id}` : "Add provider",
417
+ );
418
+
419
+ function resetDialog(values: Partial<ProvidersPanelForm>, row: ProvidersPanelEntry | null) {
420
+ Object.assign(form, EMPTY_FORM, values);
421
+ editing.value = row;
422
+ dialogError.value = null;
423
+ dialogVisible.value = true;
424
+ }
425
+
426
+ /** Open the add dialog, optionally on a preset. */
427
+ function openAdd(presetId?: string) {
428
+ if (props.readOnly) return;
429
+ resetDialog({}, null);
430
+ if (presetId) selectPreset(presetId);
431
+ }
432
+
433
+ /** Open the dialog on an entry this scope's document defines; its id stays fixed. */
434
+ function openEdit(row: ProvidersPanelEntry) {
435
+ if (!isEditable(row)) return;
436
+ const preset = presets.value.find((p) => p.id === row.preset);
437
+ resetDialog(
438
+ {
439
+ preset: preset ? preset.id : CUSTOM_PRESET,
440
+ id: row.id,
441
+ surface: row.surface ?? "",
442
+ // The preset's own endpoint shows as the placeholder, not as a pinned value.
443
+ baseUrl: row.baseUrl && row.baseUrl !== preset?.base_url ? row.baseUrl : "",
444
+ },
445
+ row,
446
+ );
447
+ }
448
+
449
+ /**
450
+ * Switch the preset. Fields still holding the previous preset's defaults
451
+ * (the id while adding, the env var, a custom surface) follow the new one;
452
+ * anything the user typed is kept.
453
+ */
454
+ function selectPreset(value: string) {
455
+ const previous = selectedPreset.value;
456
+ const next = presets.value.find((preset) => preset.id === value);
457
+ form.preset = value;
458
+ if (!editing.value && (form.id === "" || form.id === previous?.id)) form.id = next?.id ?? "";
459
+ if (form.envName === "" || form.envName === previous?.credential.env)
460
+ form.envName = next?.credential.env ?? "";
461
+ if (value === CUSTOM_PRESET && form.surface === "") form.surface = previous?.surface ?? "";
462
+ }
463
+
464
+ /** Why the form cannot be saved, or `null`. */
465
+ function formRefusal(): string | null {
466
+ if (!form.preset) return "Choose a preset or Custom.";
467
+ if (!form.id.trim()) return "Enter a provider id.";
468
+ if (idError.value) return idError.value;
469
+ if (showSurface.value && !form.surface) return "Choose the surface a custom provider speaks.";
470
+ if (isCustom.value && !form.baseUrl.trim()) return "A custom provider needs a base URL.";
471
+ if (showCredentialFields.value) {
472
+ const envName = form.envName.trim();
473
+ if (envName) {
474
+ const refusal = validateEnvVarName(envName);
475
+ if (refusal) return refusal;
476
+ }
477
+ if (form.secret && !envName) return "Name the environment variable the value is stored in.";
478
+ }
479
+ return null;
480
+ }
481
+
482
+ /**
483
+ * The `PUT ${providersWriteUrl}/:id` body. The write replaces every editable
484
+ * field, so an edit starts from the defining document's values the dialog
485
+ * does not show (`layerFields`) and keeps the stored credential.
486
+ */
487
+ function buildBody(): Record<string, unknown> {
488
+ const row = editing.value;
489
+ const body: Record<string, unknown> = { ...(row?.layerFields ?? {}) };
490
+ const preset = selectedPreset.value;
491
+ // Facts of the edited entry only carry over while it keeps its preset (or stays custom).
492
+ const kept = row && form.preset === (row.preset ?? CUSTOM_PRESET) ? row : null;
493
+ if (isCustom.value) {
494
+ body.kind = customKind.value;
495
+ if (showSurface.value) body.surface = form.surface;
496
+ } else {
497
+ body.preset = form.preset;
498
+ // A surface the entry overrode over its preset's survives the edit.
499
+ if (kept?.surface && kept.surface !== preset?.surface) body.surface = kept.surface;
500
+ }
501
+ const baseUrl = form.baseUrl.trim();
502
+ if (baseUrl) body.base_url = baseUrl;
503
+ const credentialKind: AIProviderCredentialKind =
504
+ kept?.credentialKind ?? (isCustom.value ? "bearer" : (preset?.credential.kind ?? "bearer"));
505
+ const envName = row ? "" : form.envName.trim();
506
+ if (envName) body.credential = { kind: credentialKind, ref: `env:${envName}` };
507
+ else if (row?.credentialRef) body.credential = { kind: credentialKind, ref: row.credentialRef };
508
+ return body;
509
+ }
510
+
511
+ /**
512
+ * Save the dialog. A "Set value now" secret is written first, through the
513
+ * scope's `.env` route as `NAME=value`; only then is the entry — carrying
514
+ * the `env:NAME` reference — written. Refusals from either request stay
515
+ * inline in the dialog.
516
+ */
517
+ async function saveProvider() {
518
+ if (props.readOnly) return;
519
+ const refusal = formRefusal();
520
+ if (refusal) {
521
+ dialogError.value = refusal;
522
+ return;
523
+ }
524
+ const id = form.id.trim();
525
+ saving.value = true;
526
+ dialogError.value = null;
527
+ try {
528
+ if (showCredentialFields.value && form.secret && props.environmentUrl) {
529
+ const update: EnvSettingsUpdate = {
530
+ set: { [form.envName.trim()]: form.secret },
531
+ unset: [],
532
+ };
533
+ await $fetch(props.environmentUrl, { method: "PUT", body: update });
534
+ form.secret = "";
535
+ }
536
+ await $fetch(`${props.providersWriteUrl}/${encodeURIComponent(id)}`, {
537
+ method: "PUT",
538
+ body: buildBody(),
539
+ });
540
+ dialogVisible.value = false;
541
+ editing.value = null;
542
+ await afterWrite();
543
+ } catch (err) {
544
+ dialogError.value = requestErrorMessage(err, "Could not save the provider.");
545
+ } finally {
546
+ saving.value = false;
547
+ }
548
+ }
549
+
550
+ // ── Delete ─────────────────────────────────────────────────────────────────
551
+
552
+ const deleteTarget = ref<ProvidersPanelEntry | null>(null);
553
+ const deleteError = ref<string | null>(null);
554
+ const deleting = ref(false);
555
+
556
+ /** The confirm dialog's `v-model`. Closing it cancels the delete. */
557
+ const deleteOpen = computed({
558
+ get: () => deleteTarget.value !== null,
559
+ set: (open: boolean) => {
560
+ if (!open) deleteTarget.value = null;
561
+ },
562
+ });
563
+
564
+ function requestDelete(row: ProvidersPanelEntry) {
565
+ if (!isEditable(row)) return;
566
+ deleteError.value = null;
567
+ deleteTarget.value = row;
568
+ }
569
+
570
+ async function confirmDelete() {
571
+ const target = deleteTarget.value;
572
+ if (!target || props.readOnly) return;
573
+ deleting.value = true;
574
+ deleteError.value = null;
575
+ try {
576
+ await $fetch(`${props.providersWriteUrl}/${encodeURIComponent(target.id)}`, {
577
+ method: "DELETE",
578
+ });
579
+ deleteTarget.value = null;
580
+ await afterWrite();
581
+ } catch (err) {
582
+ deleteError.value = requestErrorMessage(err, `Could not delete ${target.id}.`);
583
+ } finally {
584
+ deleting.value = false;
585
+ }
586
+ }
587
+
588
+ // ── Detected in environment ────────────────────────────────────────────────
589
+
590
+ /** Preset id whose one-click add is in flight. */
591
+ const detectedBusy = ref<string | null>(null);
592
+ const detectedError = ref<string | null>(null);
593
+
594
+ /**
595
+ * Add a detected preset the way boot seeding would:
596
+ * `{ preset, credential: { kind, ref: "env:<VAR>" } }` under the preset id,
597
+ * or `<preset>-env` when an entry already holds that id. With both taken the
598
+ * add dialog opens on the preset instead.
599
+ */
600
+ async function addDetected(preset: ProvidersPanelPreset) {
601
+ if (props.readOnly) return;
602
+ const taken = new Set(providers.value.map((entry) => entry.id));
603
+ const id = [preset.id, `${preset.id}-env`].find((candidate) => !taken.has(candidate));
604
+ const envName = preset.credential.env;
605
+ if (!id || !envName) {
606
+ openAdd(preset.id);
607
+ return;
608
+ }
609
+ detectedBusy.value = preset.id;
610
+ detectedError.value = null;
611
+ try {
612
+ await $fetch(`${props.providersWriteUrl}/${encodeURIComponent(id)}`, {
613
+ method: "PUT",
614
+ body: {
615
+ preset: preset.id,
616
+ credential: { kind: preset.credential.kind, ref: `env:${envName}` },
617
+ },
618
+ });
619
+ await afterWrite();
620
+ } catch (err) {
621
+ detectedError.value = requestErrorMessage(err, `Could not add ${preset.label}.`);
622
+ } finally {
623
+ detectedBusy.value = null;
624
+ }
625
+ }
626
+
627
+ // ── Row actions ────────────────────────────────────────────────────────────
628
+
629
+ const rowBusy = ref<string | null>(null);
630
+ const rowError = ref<{ id: string; message: string } | null>(null);
631
+
632
+ /**
633
+ * The full body of an own entry, rebuilt from its row the way the dialog
634
+ * builds one: an own write replaces every editable field, so everything the
635
+ * row shows is sent back.
636
+ */
637
+ function ownBody(row: ProvidersPanelEntry): Record<string, unknown> {
638
+ const body: Record<string, unknown> = { ...(row.layerFields ?? {}) };
639
+ const preset = presets.value.find((p) => p.id === row.preset);
640
+ if (row.preset) {
641
+ body.preset = row.preset;
642
+ if (row.surface && row.surface !== preset?.surface) body.surface = row.surface;
643
+ } else {
644
+ body.kind = row.kind;
645
+ if (row.surface) body.surface = row.surface;
646
+ }
647
+ if (row.baseUrl && row.baseUrl !== preset?.base_url) body.base_url = row.baseUrl;
648
+ if (row.credentialRef) {
649
+ body.credential = {
650
+ kind: row.credentialKind ?? FALLBACK_CREDENTIAL_KIND,
651
+ ref: row.credentialRef,
652
+ };
653
+ }
654
+ return body;
655
+ }
656
+
657
+ /**
658
+ * `PUT` a credential and/or enabled change. An own row sends its full body
659
+ * with the change applied; an inherited or patched row sends only the
660
+ * change, which the server merges into the workspace patch.
661
+ */
662
+ async function putRowChange(
663
+ row: ProvidersPanelRow,
664
+ change: { credential?: { kind: AIProviderCredentialKind; ref: string }; enabled?: boolean },
665
+ ) {
666
+ const body = row.state === "own" ? { ...ownBody(row), ...change } : change;
667
+ await $fetch(`${props.providersWriteUrl}/${encodeURIComponent(row.id)}`, {
668
+ method: "PUT",
669
+ body,
670
+ });
671
+ }
672
+
673
+ function credentialFor(row: ProvidersPanelRow, ref: string) {
674
+ return { kind: row.credentialKind ?? FALLBACK_CREDENTIAL_KIND, ref };
675
+ }
676
+
677
+ /** Run one row's write, then reload and tell the host; a refusal lands in {@link rowError}. */
678
+ async function runRowWrite(id: string, work: () => Promise<void>, fallback: string) {
679
+ rowBusy.value = id;
680
+ rowError.value = null;
681
+ try {
682
+ await work();
683
+ await afterWrite();
684
+ } catch (err) {
685
+ rowError.value = { id, message: requestErrorMessage(err, fallback) };
686
+ } finally {
687
+ rowBusy.value = null;
688
+ }
689
+ }
690
+
691
+ async function setCredentialEnv(row: ProvidersPanelRow, name: string) {
692
+ if (!row.canEditCredential) return;
693
+ const ref = `env:${name}`;
694
+ if (row.credentialRef === ref) return;
695
+ await runRowWrite(
696
+ row.id,
697
+ () => putRowChange(row, { credential: credentialFor(row, ref) }),
698
+ `Could not change the credential of ${row.id}.`,
699
+ );
700
+ }
701
+
702
+ async function setEnabled(row: ProvidersPanelRow, enabled: boolean) {
703
+ if (!row.canToggle) return;
704
+ await runRowWrite(
705
+ row.id,
706
+ () => putRowChange(row, { enabled }),
707
+ `Could not ${enabled ? "enable" : "disable"} ${row.id}.`,
708
+ );
709
+ }
710
+
711
+ async function resetPatch(row: ProvidersPanelRow) {
712
+ if (!row.canReset) return;
713
+ await runRowWrite(
714
+ row.id,
715
+ async () => {
716
+ await $fetch(`${props.providersWriteUrl}/${encodeURIComponent(row.id)}`, {
717
+ method: "DELETE",
718
+ });
719
+ },
720
+ `Could not reset ${row.id}.`,
721
+ );
722
+ }
723
+
724
+ // ── Inline key editor ──────────────────────────────────────────────────────
725
+
726
+ const keyEditing = ref<string | null>(null);
727
+ const keyForm = reactive({ name: "", value: "" });
728
+ const keyError = ref<string | null>(null);
729
+
730
+ const refEditing = ref<string | null>(null);
731
+ const refForm = reactive({ ref: "" });
732
+ const refError = ref<string | null>(null);
733
+
734
+ function cancelKeyEditor() {
735
+ keyEditing.value = null;
736
+ keyForm.name = "";
737
+ keyForm.value = "";
738
+ keyError.value = null;
739
+ }
740
+
741
+ function cancelRefEditor() {
742
+ refEditing.value = null;
743
+ refForm.ref = "";
744
+ refError.value = null;
745
+ }
746
+
747
+ /** Open the key editor, name prefilled: the row's env var, else its preset's conventional one. */
748
+ function openKeyEditor(row: ProvidersPanelRow) {
749
+ if (!row.canEditCredential || !canEnterKey.value) return;
750
+ cancelRefEditor();
751
+ cancelKeyEditor();
752
+ const presetEnv = presets.value.find((p) => p.id === row.preset)?.credential.env;
753
+ keyForm.name = envNameOfRef(row.credentialRef) ?? presetEnv ?? "";
754
+ keyEditing.value = row.id;
755
+ }
756
+
757
+ /**
758
+ * Save the key: `NAME=value` into the scope's `.env` first, then the row's
759
+ * reference when it is not `env:NAME` already. The value never reaches the
760
+ * provider route.
761
+ */
762
+ async function saveKeyEditor() {
763
+ const row = rows.value.find((r) => r.id === keyEditing.value);
764
+ if (!row?.canEditCredential || !props.environmentUrl) return;
765
+ const name = keyForm.name.trim();
766
+ const refusal = validateEnvVarName(name);
767
+ if (refusal) {
768
+ keyError.value = refusal;
769
+ return;
770
+ }
771
+ if (!keyForm.value) {
772
+ keyError.value = "Enter the key.";
773
+ return;
774
+ }
775
+ rowBusy.value = row.id;
776
+ keyError.value = null;
777
+ let wroteEnv = false;
778
+ try {
779
+ const update: EnvSettingsUpdate = { set: { [name]: keyForm.value }, unset: [] };
780
+ await $fetch(props.environmentUrl, { method: "PUT", body: update });
781
+ wroteEnv = true;
782
+ const ref = `env:${name}`;
783
+ if (row.credentialRef !== ref) {
784
+ await putRowChange(row, { credential: credentialFor(row, ref) });
785
+ }
786
+ cancelKeyEditor();
787
+ } catch (err) {
788
+ keyError.value = requestErrorMessage(err, "Could not save the key.");
789
+ } finally {
790
+ rowBusy.value = null;
791
+ }
792
+ if (wroteEnv) await afterWrite();
793
+ }
794
+
795
+ // ── Inline reference editor ────────────────────────────────────────────────
796
+
797
+ function openRefEditor(row: ProvidersPanelRow) {
798
+ if (!row.canEditCredential) return;
799
+ cancelKeyEditor();
800
+ cancelRefEditor();
801
+ refForm.ref = row.credentialRef && !envNameOfRef(row.credentialRef) ? row.credentialRef : "";
802
+ refEditing.value = row.id;
803
+ }
804
+
805
+ async function saveRefEditor() {
806
+ const row = rows.value.find((r) => r.id === refEditing.value);
807
+ if (!row?.canEditCredential) return;
808
+ const ref = refForm.ref.trim();
809
+ const refusal = ref ? validateCredentialRef(ref) : "Enter a reference.";
810
+ if (refusal) {
811
+ refError.value = refusal;
812
+ return;
813
+ }
814
+ rowBusy.value = row.id;
815
+ refError.value = null;
816
+ try {
817
+ await putRowChange(row, { credential: credentialFor(row, ref) });
818
+ cancelRefEditor();
819
+ await afterWrite();
820
+ } catch (err) {
821
+ refError.value = requestErrorMessage(err, "Could not save the reference.");
822
+ } finally {
823
+ rowBusy.value = null;
824
+ }
825
+ }
826
+
827
+ function onCredentialSelect(row: ProvidersPanelRow, value: string) {
828
+ if (value === CREDENTIAL_ENTER_KEY) openKeyEditor(row);
829
+ else if (value === CREDENTIAL_OTHER_REF) openRefEditor(row);
830
+ else if (value !== row.credentialValue) void setCredentialEnv(row, value);
831
+ }
832
+
833
+ // ── Environment writes ─────────────────────────────────────────────────────
834
+
835
+ const envValues = reactive<Record<string, string>>({});
836
+ const envBusy = ref(false);
837
+
838
+ /** Write one change to the scope's `.env`, then reload and tell the host. */
839
+ async function writeEnvironment(update: EnvSettingsUpdate, fallback: string): Promise<boolean> {
840
+ if (!props.environmentUrl || !envEditable.value) return false;
841
+ envBusy.value = true;
842
+ envError.value = null;
843
+ try {
844
+ await $fetch(props.environmentUrl, { method: "PUT", body: update });
845
+ } catch (err) {
846
+ envError.value = requestErrorMessage(err, fallback);
847
+ return false;
848
+ } finally {
849
+ envBusy.value = false;
850
+ }
851
+ await afterWrite();
852
+ return true;
853
+ }
854
+
855
+ async function setEnvValue(name: string) {
856
+ const value = envValues[name];
857
+ if (!value) return;
858
+ if (await writeEnvironment({ set: { [name]: value }, unset: [] }, `Could not save ${name}.`)) {
859
+ delete envValues[name];
860
+ }
861
+ }
862
+
863
+ return {
864
+ // table
865
+ rows,
866
+ loading,
867
+ loadError,
868
+ refresh,
869
+ // dialog
870
+ dialogVisible,
871
+ dialogTitle,
872
+ editing,
873
+ form,
874
+ dialogError,
875
+ saving,
876
+ presetOptions,
877
+ surfaceOptions,
878
+ isCustom,
879
+ showSurface,
880
+ baseUrlPlaceholder,
881
+ canSetValue,
882
+ showCredentialFields,
883
+ idError,
884
+ openAdd,
885
+ openEdit,
886
+ selectPreset,
887
+ saveProvider,
888
+ // delete
889
+ deleteTarget,
890
+ deleteOpen,
891
+ deleteError,
892
+ deleting,
893
+ requestDelete,
894
+ confirmDelete,
895
+ // detected
896
+ detected,
897
+ detectedBusy,
898
+ detectedError,
899
+ addDetected,
900
+ // row actions
901
+ onCredentialSelect,
902
+ setCredentialEnv,
903
+ setEnabled,
904
+ resetPatch,
905
+ rowBusy,
906
+ rowError,
907
+ // key editor
908
+ canEnterKey,
909
+ keyEditing,
910
+ keyForm,
911
+ keyError,
912
+ openKeyEditor,
913
+ cancelKeyEditor,
914
+ saveKeyEditor,
915
+ // reference editor
916
+ refEditing,
917
+ refForm,
918
+ refError,
919
+ openRefEditor,
920
+ cancelRefEditor,
921
+ saveRefEditor,
922
+ // environment
923
+ envDocument,
924
+ envEditable,
925
+ envValues,
926
+ envError,
927
+ envBusy,
928
+ setEnvValue,
929
+ };
930
+ }