@skaile/forge-settings 1.3.2 → 1.4.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 (46) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +20 -0
  3. package/dist/module.json +1 -1
  4. package/dist/runtime/components/ForgeProvidersPanel.d.vue.ts +13 -0
  5. package/dist/runtime/components/ForgeProvidersPanel.vue +461 -78
  6. package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +13 -0
  7. package/dist/runtime/server/harness-settings.d.ts +50 -12
  8. package/dist/runtime/server/harness-settings.js +93 -9
  9. package/dist/runtime/server/index.d.ts +2 -0
  10. package/dist/runtime/server/index.js +2 -0
  11. package/dist/runtime/server/provider-service.d.ts +26 -2
  12. package/dist/runtime/server/provider-service.js +16 -1
  13. package/dist/runtime/server/provider-write.d.ts +25 -9
  14. package/dist/runtime/server/provider-write.js +72 -4
  15. package/dist/runtime/shared/data-zone.d.ts +66 -0
  16. package/dist/runtime/shared/data-zone.js +79 -0
  17. package/dist/runtime/shared/harness-providers.d.ts +41 -2
  18. package/dist/runtime/shared/harness-providers.js +31 -8
  19. package/dist/runtime/shared/harnesses.d.ts +7 -0
  20. package/dist/runtime/shared/harnesses.js +18 -0
  21. package/dist/runtime/shared/provider-accounts.d.ts +215 -0
  22. package/dist/runtime/shared/provider-accounts.js +255 -0
  23. package/dist/runtime/shared/provider-registry.d.ts +38 -0
  24. package/dist/runtime/shared/provider-registry.js +16 -0
  25. package/dist/runtime/shared/settings-types.d.ts +7 -0
  26. package/dist/runtime/vue/index.d.ts +2 -0
  27. package/dist/runtime/vue/index.js +2 -0
  28. package/dist/runtime/vue/providers-panel-model.d.ts +98 -5
  29. package/dist/runtime/vue/providers-panel-model.js +13 -1
  30. package/dist/runtime/vue/useProvidersPanel.d.ts +66 -5
  31. package/dist/runtime/vue/useProvidersPanel.js +417 -29
  32. package/package.json +2 -2
  33. package/src/runtime/components/ForgeProvidersPanel.vue +477 -78
  34. package/src/runtime/server/harness-settings.ts +165 -21
  35. package/src/runtime/server/index.ts +2 -0
  36. package/src/runtime/server/provider-service.ts +53 -1
  37. package/src/runtime/server/provider-write.ts +129 -15
  38. package/src/runtime/shared/data-zone.ts +172 -0
  39. package/src/runtime/shared/harness-providers.ts +77 -6
  40. package/src/runtime/shared/harnesses.ts +25 -0
  41. package/src/runtime/shared/provider-accounts.ts +526 -0
  42. package/src/runtime/shared/provider-registry.ts +58 -0
  43. package/src/runtime/shared/settings-types.ts +7 -0
  44. package/src/runtime/vue/index.ts +2 -0
  45. package/src/runtime/vue/providers-panel-model.ts +123 -5
  46. package/src/runtime/vue/useProvidersPanel.ts +586 -37
@@ -24,6 +24,7 @@ import {
24
24
  type WritableComputedRef,
25
25
  watch,
26
26
  } from "vue";
27
+ import { DATA_ZONE_LABELS, dataZoneRefusal, entryDataZone } from "../shared/data-zone";
27
28
  import {
28
29
  type EnvLayerId,
29
30
  type EnvSettingsDocument,
@@ -31,11 +32,32 @@ import {
31
32
  type EnvVarView,
32
33
  validateEnvVarName,
33
34
  } from "../shared/env-vars";
35
+ import { isPlainObject } from "../shared/guards";
36
+ import {
37
+ AZURE_OPENAI_DEFAULT_API_VERSION,
38
+ AZURE_OPENAI_PRESET,
39
+ azureAccountEntries,
40
+ credentialExpiry,
41
+ defaultParamValue,
42
+ EXPERT_PARAMS,
43
+ FOUNDRY_PRESET,
44
+ missingRequiredParams,
45
+ PROVIDER_PARAM_LABELS,
46
+ type ProviderSignInCredential,
47
+ type ProviderSignInId,
48
+ type ProviderSignInOption,
49
+ presetParamKeys,
50
+ presetSignInOptions,
51
+ providerAccountLine,
52
+ signInCredential,
53
+ signInOptionOf,
54
+ } from "../shared/provider-accounts";
34
55
  import {
35
56
  AI_PROVIDER_SURFACES,
36
57
  type AIProviderCredentialKind,
37
58
  type AIProviderKind,
38
59
  type AIProviderSurface,
60
+ type DataZone,
39
61
  validateCredentialRef,
40
62
  } from "../shared/provider-registry";
41
63
  import {
@@ -44,6 +66,7 @@ import {
44
66
  urlProvidersClient,
45
67
  } from "./providers-client";
46
68
  import {
69
+ AZURE_ACCOUNT,
47
70
  CREDENTIAL_ENTER_KEY,
48
71
  CREDENTIAL_OTHER_REF,
49
72
  CUSTOM_PRESET,
@@ -54,29 +77,56 @@ import {
54
77
  nonEnvSourceLabel,
55
78
  OVERRIDES_INSTANCE_NOTICE,
56
79
  PROVIDER_ID_PATTERN,
80
+ type ProvidersPanelAzureForm,
57
81
  type ProvidersPanelCredentialOption,
82
+ type ProvidersPanelDialogStep,
58
83
  type ProvidersPanelEmit,
59
84
  type ProvidersPanelEntry,
60
85
  type ProvidersPanelForm,
86
+ type ProvidersPanelModelsForm,
87
+ type ProvidersPanelModelsMode,
61
88
  type ProvidersPanelPreset,
62
89
  type ProvidersPanelProps,
63
90
  type ProvidersPanelRow,
64
91
  type ProvidersPanelRowState,
92
+ providerModelChoices,
93
+ providerModelsChip,
65
94
  requestErrorMessage,
66
95
  } from "./providers-panel-model";
67
96
 
68
97
  export type {
98
+ ProvidersPanelAzureForm,
69
99
  ProvidersPanelCredentialOption,
100
+ ProvidersPanelDialogStep,
70
101
  ProvidersPanelEmit,
71
102
  ProvidersPanelEntry,
72
103
  ProvidersPanelForm,
73
104
  ProvidersPanelLayer,
105
+ ProvidersPanelModelsForm,
106
+ ProvidersPanelModelsMode,
74
107
  ProvidersPanelPreset,
75
108
  ProvidersPanelProps,
76
109
  ProvidersPanelRow,
77
110
  ProvidersPanelRowState,
78
111
  } from "./providers-panel-model";
79
- export { CREDENTIAL_ENTER_KEY, CREDENTIAL_OTHER_REF, CUSTOM_PRESET } from "./providers-panel-model";
112
+ export {
113
+ AZURE_ACCOUNT,
114
+ CREDENTIAL_ENTER_KEY,
115
+ CREDENTIAL_OTHER_REF,
116
+ CUSTOM_PRESET,
117
+ providerModelChoices,
118
+ providerModelsChip,
119
+ } from "./providers-panel-model";
120
+
121
+ /** One parameter field of the add/edit dialog. */
122
+ export interface ProvidersPanelParamField {
123
+ key: string;
124
+ label: string;
125
+ /** The preset requires it. */
126
+ required: boolean;
127
+ /** Editable; expert-only parameters are read-only outside expert mode. */
128
+ editable: boolean;
129
+ }
80
130
 
81
131
  /** What both `ForgeProvidersPanel` variants bind to. */
82
132
  export interface ProvidersPanelState {
@@ -104,14 +154,48 @@ export interface ProvidersPanelState {
104
154
  baseUrlPlaceholder: ComputedRef<string>;
105
155
  /** "Set value now" is offered: the scope has an `.env` route. */
106
156
  canSetValue: ComputedRef<boolean>;
107
- /** The dialog shows the env name and secret fields: adding, not editing. */
157
+ /** The dialog shows the env name and secret fields: the chosen sign-in takes a key. */
108
158
  showCredentialFields: ComputedRef<boolean>;
109
159
  idError: ComputedRef<string | null>;
160
+ /** Open the add dialog: on the library, or on `presetId`'s account step. */
110
161
  openAdd: (presetId?: string) => void;
111
162
  openEdit: (row: ProvidersPanelEntry) => void;
112
163
  selectPreset: (value: string) => void;
164
+ /** Create (Add) or save (Edit). Adding with an explicit client then tests what it created. */
113
165
  saveProvider: () => Promise<void>;
114
166
 
167
+ // Add flow: library → account (sign-in, fields) → done (test)
168
+ dialogStep: Ref<ProvidersPanelDialogStep>;
169
+ /** The library's presets, catalog order; the Azure OpenAI and Foundry presets sit behind the Azure card. */
170
+ libraryPresets: ComputedRef<ProvidersPanelPreset[]>;
171
+ /** Pick a library card: a preset id, {@link CUSTOM_PRESET} or {@link AZURE_ACCOUNT}. */
172
+ chooseLibrary: (value: string) => void;
173
+ /** From the account step of an add back to the library. */
174
+ backToLibrary: () => void;
175
+ /** The account step is the Microsoft Azure card. */
176
+ isAzureAccount: ComputedRef<boolean>;
177
+ /** "How do you sign in?": the kinds the chosen preset accepts (the reference form only in expert mode). */
178
+ signInOptions: ComputedRef<ProviderSignInOption[]>;
179
+ signInOption: ComputedRef<ProviderSignInOption | undefined>;
180
+ selectSignIn: (id: ProviderSignInId) => void;
181
+ /** The sign-in step is shown; editing an entry whose credential none of the options expresses keeps it as is. */
182
+ showSignIn: ComputedRef<boolean>;
183
+ /** The reference field is shown: the chosen sign-in is a typed reference. */
184
+ showReferenceField: ComputedRef<boolean>;
185
+ /** The variable a blank key name falls back to: the preset's (the Azure card: Azure OpenAI's). */
186
+ defaultEnvName: ComputedRef<string | undefined>;
187
+ /** Parameter fields of the chosen preset (required ones, plus any the edited entry holds). */
188
+ paramFields: ComputedRef<ProvidersPanelParamField[]>;
189
+ azureForm: ProvidersPanelAzureForm;
190
+ addDeploymentRow: () => void;
191
+ removeDeploymentRow: (index: number) => void;
192
+ /** Ids the last add created; the done step lists them with their test results. */
193
+ createdIds: Ref<string[]>;
194
+ /** Close the dialog from the done step. */
195
+ finishDialog: () => void;
196
+ /** "Renew" on a row: re-open its sign-in (the edit dialog, else the row's key or reference editor). */
197
+ openSignIn: (row: ProvidersPanelRow) => void;
198
+
115
199
  deleteTarget: Ref<ProvidersPanelEntry | null>;
116
200
  /** The confirmation dialog's `v-model`; closing it cancels. */
117
201
  deleteOpen: WritableComputedRef<boolean>;
@@ -134,6 +218,8 @@ export interface ProvidersPanelState {
134
218
  setEnabled: (row: ProvidersPanelRow, enabled: boolean) => Promise<void>;
135
219
  /** Delete the workspace patch of an inherited entry. */
136
220
  resetPatch: (row: ProvidersPanelRow) => Promise<void>;
221
+ /** "EU processing is on for this account": writes the entry's `eu_processing`. */
222
+ setEuProcessing: (row: ProvidersPanelRow, on: boolean) => Promise<void>;
137
223
  /** Row id with a write in flight. */
138
224
  rowBusy: Ref<string | null>;
139
225
  rowError: Ref<{ id: string; message: string } | null>;
@@ -143,7 +229,7 @@ export interface ProvidersPanelState {
143
229
  testBusy: Ref<string | null>;
144
230
  /** The last test result per row id. */
145
231
  testResults: Record<string, ProvidersPanelTestResult>;
146
- testProvider: (row: ProvidersPanelEntry) => Promise<void>;
232
+ testProvider: (row: Pick<ProvidersPanelEntry, "id">) => Promise<void>;
147
233
 
148
234
  // Inline key editor
149
235
  /** "Enter a key…" is offered: the scope has an editable `.env` route. */
@@ -165,6 +251,25 @@ export interface ProvidersPanelState {
165
251
  cancelRefEditor: () => void;
166
252
  saveRefEditor: () => Promise<void>;
167
253
 
254
+ // "Models allowed here"
255
+ /** Row id whose model filter is open. */
256
+ modelsEditing: Ref<string | null>;
257
+ modelsForm: ProvidersPanelModelsForm;
258
+ /** The models offered: the entry's `model_map` ids, else what `client.models` lists. */
259
+ modelsKnown: Ref<string[]>;
260
+ modelsLoading: Ref<boolean>;
261
+ /** Why the entry's models could not be listed (the checklist then holds only allowed ids). */
262
+ modelsListMessage: Ref<string | null>;
263
+ /** The checklist after the search. */
264
+ modelChoices: ComputedRef<string[]>;
265
+ modelsError: Ref<string | null>;
266
+ openModelsEditor: (row: ProvidersPanelRow) => Promise<void>;
267
+ setModelsMode: (mode: ProvidersPanelModelsMode) => void;
268
+ toggleModel: (id: string, on: boolean) => void;
269
+ cancelModelsEditor: () => void;
270
+ /** Write `allowed_models`: `null` for All models, else the ticked ids in checklist order. */
271
+ saveModelsEditor: () => Promise<void>;
272
+
168
273
  // Environment
169
274
  envDocument: Ref<EnvSettingsDocument | null>;
170
275
  /** The environment document says the caller may write its layer. */
@@ -360,11 +465,20 @@ export function useProvidersPanel(
360
465
  return notices;
361
466
  }
362
467
 
468
+ /** The muted note of a row the host's data-zone rule keeps out; `null` when it does not. */
469
+ function blockedNoteOf(zone: DataZone, euProcessing: boolean | undefined): string | null {
470
+ const rule = props.dataZoneRule;
471
+ if (!rule || rule === "any") return null;
472
+ return dataZoneRefusal(rule, zone, euProcessing) ?? null;
473
+ }
474
+
363
475
  function buildRow(entry: ProvidersPanelEntry): ProvidersPanelRow {
364
476
  const state = stateOf(entry);
365
477
  const writable = !props.readOnly && !entry.locked;
366
478
  const needsCredential = entry.credentialKind !== "none";
367
479
  const envName = envNameOfRef(entry.credentialRef);
480
+ const zone = entry.dataZone ?? entryDataZone({ preset: entry.preset });
481
+ const isModelEntry = entry.kind === "llm" || entry.kind === "embed";
368
482
  return {
369
483
  ...entry,
370
484
  state,
@@ -385,6 +499,15 @@ export function useProvidersPanel(
385
499
  origin: entry.preset ?? entry.surface ?? entry.kind,
386
500
  status: statusOf(entry, needsCredential),
387
501
  overrideNotices: overrideNoticesOf(entry),
502
+ accountLine: providerAccountLine(entry, presets.value),
503
+ expiry: needsCredential ? credentialExpiry(entry.credentialExpiresAt) : null,
504
+ canRenew: needsCredential && writable,
505
+ zone,
506
+ zoneLabel: DATA_ZONE_LABELS[zone],
507
+ canSetEuProcessing: zone === "eu_option" && writable && state === "own",
508
+ blockedNote: blockedNoteOf(zone, entry.euProcessing),
509
+ modelsChip: isModelEntry ? providerModelsChip(entry.allowedModels) : null,
510
+ canFilterModels: isModelEntry && writable && state === "own",
388
511
  };
389
512
  }
390
513
 
@@ -395,15 +518,36 @@ export function useProvidersPanel(
395
518
  const dialogVisible = ref(false);
396
519
  /** The row being edited; `null` while adding. */
397
520
  const editing = ref<ProvidersPanelEntry | null>(null);
398
- const form = reactive<ProvidersPanelForm>({ ...EMPTY_FORM });
521
+ const form = reactive<ProvidersPanelForm>({ ...EMPTY_FORM, params: {} });
399
522
  const dialogError = ref<string | null>(null);
400
523
  const saving = ref(false);
524
+ const dialogStep = ref<ProvidersPanelDialogStep>("account");
525
+ const createdIds = ref<string[]>([]);
526
+ const azureForm = reactive<ProvidersPanelAzureForm>(emptyAzureForm());
527
+
528
+ /** A fresh Azure card: Azure OpenAI chosen, one blank deployment row, the default `api_version`. */
529
+ function emptyAzureForm(): ProvidersPanelAzureForm {
530
+ return {
531
+ resource: "",
532
+ useOpenAI: true,
533
+ useFoundry: false,
534
+ deployments: [{ deployment: "", model: "" }],
535
+ apiVersion: AZURE_OPENAI_DEFAULT_API_VERSION,
536
+ };
537
+ }
401
538
 
402
539
  const presetOptions = computed(() => [
403
540
  ...presets.value.map((preset) => ({ label: preset.label, value: preset.id })),
404
541
  { label: "Custom", value: CUSTOM_PRESET },
405
542
  ]);
406
543
 
544
+ /** Azure OpenAI and Claude on Foundry are added through the Microsoft Azure card. */
545
+ const libraryPresets = computed(() =>
546
+ presets.value.filter(
547
+ (preset) => preset.id !== AZURE_OPENAI_PRESET && preset.id !== FOUNDRY_PRESET,
548
+ ),
549
+ );
550
+
407
551
  const surfaceOptions = AI_PROVIDER_SURFACES.map((surface) => ({
408
552
  label: surface,
409
553
  value: surface,
@@ -413,6 +557,8 @@ export function useProvidersPanel(
413
557
 
414
558
  const isCustom = computed(() => form.preset === CUSTOM_PRESET);
415
559
 
560
+ const isAzureAccount = computed(() => form.preset === AZURE_ACCOUNT);
561
+
416
562
  /** Kind of a custom entry: the edited one's, else `llm`. */
417
563
  const customKind = computed<AIProviderKind>(() => editing.value?.kind ?? "llm");
418
564
 
@@ -428,8 +574,83 @@ export function useProvidersPanel(
428
574
  /** Whether "Set value now" is offered: the scope has an `.env` route. */
429
575
  const canSetValue = computed(() => environmentOn.value);
430
576
 
431
- /** The dialog collects a credential for a new entry only. */
432
- const showCredentialFields = computed(() => editing.value === null);
577
+ /** The preset whose sign-in kinds the Azure card offers: Azure OpenAI's, Foundry's when only that is used. */
578
+ const azureSignInPreset = computed<Pick<ProvidersPanelPreset, "credential" | "transport">>(() => {
579
+ const openai = presets.value.find((p) => p.id === AZURE_OPENAI_PRESET);
580
+ const foundry = presets.value.find((p) => p.id === FOUNDRY_PRESET);
581
+ const chosen = !azureForm.useOpenAI && azureForm.useFoundry ? foundry : openai;
582
+ return (
583
+ chosen ??
584
+ openai ??
585
+ foundry ?? {
586
+ credential: { kind: "api_key_header", env: "AZURE_OPENAI_API_KEY" },
587
+ transport: "azure-openai",
588
+ }
589
+ );
590
+ });
591
+
592
+ /** The sign-in options the account step offers. */
593
+ const signInOptions = computed<ProviderSignInOption[]>(() => {
594
+ const expert = props.expert === true;
595
+ if (isAzureAccount.value) return presetSignInOptions(azureSignInPreset.value, { expert });
596
+ if (!form.preset) return [];
597
+ return presetSignInOptions(isCustom.value ? null : (selectedPreset.value ?? null), { expert });
598
+ });
599
+
600
+ const signInOption = computed(() => signInOptions.value.find((o) => o.id === form.signIn));
601
+
602
+ /** The sign-in option the edited entry's stored credential corresponds to. */
603
+ const storedSignIn = computed(() => {
604
+ const row = editing.value;
605
+ if (!row) return undefined;
606
+ const kind = row.credentialKind ?? selectedPreset.value?.credential.kind;
607
+ return signInOptionOf({ kind, ref: row.credentialRef }, signInOptions.value);
608
+ });
609
+
610
+ /**
611
+ * Editing an entry whose stored credential none of the options expresses
612
+ * (an `oauth` credential, or a `keyring:` reference outside expert mode)
613
+ * keeps it as is; the row's picker still changes it.
614
+ */
615
+ const showSignIn = computed(() => {
616
+ const row = editing.value;
617
+ if (!row) return signInOptions.value.length > 0;
618
+ const option = storedSignIn.value;
619
+ const kind = row.credentialKind ?? selectedPreset.value?.credential.kind;
620
+ if (!option || (kind && option.credentialKind !== kind)) return false;
621
+ const ref = row.credentialRef;
622
+ if (!ref) return option.fields !== "reference";
623
+ return option.fields === (envNameOfRef(ref) ? "key" : "reference");
624
+ });
625
+
626
+ /** The dialog collects a key (env name, optional value) for the chosen sign-in. */
627
+ const showCredentialFields = computed(
628
+ () => showSignIn.value && signInOption.value?.fields === "key",
629
+ );
630
+
631
+ const showReferenceField = computed(
632
+ () => showSignIn.value && signInOption.value?.fields === "reference",
633
+ );
634
+
635
+ /** The conventional variable a blank key name falls back to. */
636
+ const defaultEnvName = computed(() =>
637
+ isAzureAccount.value
638
+ ? azureSignInPreset.value.credential.env
639
+ : selectedPreset.value?.credential.env,
640
+ );
641
+
642
+ const paramFields = computed<ProvidersPanelParamField[]>(() => {
643
+ if (isCustom.value || isAzureAccount.value) return [];
644
+ const preset = selectedPreset.value;
645
+ const kept = editing.value && editing.value.preset === form.preset ? editing.value : null;
646
+ const required = new Set(preset?.required_params ?? []);
647
+ return presetParamKeys(preset, kept?.transportParams).map((key) => ({
648
+ key,
649
+ label: PROVIDER_PARAM_LABELS[key] ?? key,
650
+ required: required.has(key),
651
+ editable: props.expert === true || !EXPERT_PARAMS.includes(key),
652
+ }));
653
+ });
433
654
 
434
655
  const idError = computed(() => {
435
656
  const id = form.id.trim();
@@ -438,28 +659,74 @@ export function useProvidersPanel(
438
659
  : null;
439
660
  });
440
661
 
441
- const dialogTitle = computed(() =>
442
- editing.value ? `Edit provider — ${editing.value.id}` : "Add provider",
443
- );
662
+ const dialogTitle = computed(() => {
663
+ if (editing.value) return `Edit provider — ${editing.value.id}`;
664
+ if (dialogStep.value === "done") return "Provider added";
665
+ if (dialogStep.value === "account" && isAzureAccount.value) return "Add Microsoft Azure";
666
+ return "Add provider";
667
+ });
444
668
 
445
669
  function resetDialog(values: Partial<ProvidersPanelForm>, row: ProvidersPanelEntry | null) {
446
670
  Object.assign(form, EMPTY_FORM, values);
671
+ form.params = { ...(values.params ?? {}) };
672
+ Object.assign(azureForm, emptyAzureForm());
447
673
  editing.value = row;
448
674
  dialogError.value = null;
675
+ createdIds.value = [];
676
+ dialogStep.value = "account";
449
677
  dialogVisible.value = true;
450
678
  }
451
679
 
452
- /** Open the add dialog, optionally on a preset. */
680
+ /** Pick the first sign-in option when the current one is not offered. */
681
+ function settleSignIn() {
682
+ if (!signInOptions.value.some((o) => o.id === form.signIn)) {
683
+ form.signIn = signInOptions.value[0]?.id ?? "";
684
+ }
685
+ }
686
+
687
+ /** Open the add dialog: on the library, or straight on `presetId`'s account step. */
453
688
  function openAdd(presetId?: string) {
454
689
  if (props.readOnly) return;
455
690
  resetDialog({}, null);
456
- if (presetId) selectPreset(presetId);
691
+ if (presetId) chooseLibrary(presetId);
692
+ else dialogStep.value = "library";
693
+ }
694
+
695
+ function chooseLibrary(value: string) {
696
+ if (props.readOnly || editing.value) return;
697
+ dialogError.value = null;
698
+ if (value === AZURE_ACCOUNT) {
699
+ form.preset = AZURE_ACCOUNT;
700
+ form.id = "";
701
+ form.envName = "";
702
+ form.params = {};
703
+ } else {
704
+ if (form.preset === AZURE_ACCOUNT) form.preset = "";
705
+ selectPreset(value);
706
+ }
707
+ settleSignIn();
708
+ dialogStep.value = "account";
709
+ }
710
+
711
+ function backToLibrary() {
712
+ if (editing.value) return;
713
+ dialogError.value = null;
714
+ dialogStep.value = "library";
715
+ }
716
+
717
+ function selectSignIn(id: ProviderSignInId) {
718
+ if (signInOptions.value.some((o) => o.id === id)) form.signIn = id;
457
719
  }
458
720
 
459
721
  /** Open the dialog on an entry this scope's document defines; its id stays fixed. */
460
722
  function openEdit(row: ProvidersPanelEntry) {
461
723
  if (!isEditable(row)) return;
462
724
  const preset = presets.value.find((p) => p.id === row.preset);
725
+ const ref = row.credentialRef;
726
+ const params: Record<string, string> = {};
727
+ for (const key of presetParamKeys(preset, row.transportParams)) {
728
+ params[key] = row.transportParams?.[key] ?? defaultParamValue(preset?.id, key);
729
+ }
463
730
  resetDialog(
464
731
  {
465
732
  preset: preset ? preset.id : CUSTOM_PRESET,
@@ -467,15 +734,19 @@ export function useProvidersPanel(
467
734
  surface: row.surface ?? "",
468
735
  // The preset's own endpoint shows as the placeholder, not as a pinned value.
469
736
  baseUrl: row.baseUrl && row.baseUrl !== preset?.base_url ? row.baseUrl : "",
737
+ envName: envNameOfRef(ref) ?? "",
738
+ credentialRef: ref && !envNameOfRef(ref) ? ref : "",
739
+ params,
470
740
  },
471
741
  row,
472
742
  );
743
+ form.signIn = storedSignIn.value?.id ?? signInOptions.value[0]?.id ?? "";
473
744
  }
474
745
 
475
746
  /**
476
747
  * Switch the preset. Fields still holding the previous preset's defaults
477
- * (the id while adding, the env var, a custom surface) follow the new one;
478
- * anything the user typed is kept.
748
+ * (the id while adding, the env var, a custom surface, the parameters)
749
+ * follow the new one; anything the user typed is kept.
479
750
  */
480
751
  function selectPreset(value: string) {
481
752
  const previous = selectedPreset.value;
@@ -485,6 +756,50 @@ export function useProvidersPanel(
485
756
  if (form.envName === "" || form.envName === previous?.credential.env)
486
757
  form.envName = next?.credential.env ?? "";
487
758
  if (value === CUSTOM_PRESET && form.surface === "") form.surface = previous?.surface ?? "";
759
+ const params: Record<string, string> = {};
760
+ for (const key of presetParamKeys(next)) {
761
+ const typed = form.params[key]?.trim();
762
+ params[key] =
763
+ typed && typed !== defaultParamValue(previous?.id, key)
764
+ ? typed
765
+ : defaultParamValue(next?.id, key);
766
+ }
767
+ form.params = params;
768
+ settleSignIn();
769
+ }
770
+
771
+ function addDeploymentRow() {
772
+ azureForm.deployments.push({ deployment: "", model: "" });
773
+ }
774
+
775
+ function removeDeploymentRow(index: number) {
776
+ azureForm.deployments.splice(index, 1);
777
+ if (azureForm.deployments.length === 0) addDeploymentRow();
778
+ }
779
+
780
+ /** The credential the chosen sign-in writes. */
781
+ function dialogCredential(): ProviderSignInCredential {
782
+ const option = signInOption.value;
783
+ if (!option) return { ok: false, error: "Choose how you sign in." };
784
+ return signInCredential(
785
+ option,
786
+ { envName: form.envName, ref: form.credentialRef },
787
+ { defaultEnv: defaultEnvName.value },
788
+ );
789
+ }
790
+
791
+ /** Why the key fields cannot be saved, or `null`. */
792
+ function keyFieldsRefusal(): string | null {
793
+ if (!showCredentialFields.value) return null;
794
+ const envName = form.envName.trim();
795
+ if (envName) {
796
+ const refusal = validateEnvVarName(envName);
797
+ if (refusal) return refusal;
798
+ }
799
+ if (form.secret && !envName && !defaultEnvName.value) {
800
+ return "Name the environment variable the value is stored in.";
801
+ }
802
+ return null;
488
803
  }
489
804
 
490
805
  /** Why the form cannot be saved, or `null`. */
@@ -494,13 +809,17 @@ export function useProvidersPanel(
494
809
  if (idError.value) return idError.value;
495
810
  if (showSurface.value && !form.surface) return "Choose the surface a custom provider speaks.";
496
811
  if (isCustom.value && !form.baseUrl.trim()) return "A custom provider needs a base URL.";
497
- if (showCredentialFields.value) {
498
- const envName = form.envName.trim();
499
- if (envName) {
500
- const refusal = validateEnvVarName(envName);
501
- if (refusal) return refusal;
812
+ if (!isCustom.value && selectedPreset.value) {
813
+ const missing = missingRequiredParams(selectedPreset.value, form.params)[0];
814
+ if (missing) {
815
+ return `Enter the ${(PROVIDER_PARAM_LABELS[missing] ?? missing).toLowerCase()} (${missing}).`;
502
816
  }
503
- if (form.secret && !envName) return "Name the environment variable the value is stored in.";
817
+ }
818
+ if (showSignIn.value) {
819
+ const keyRefusal = keyFieldsRefusal();
820
+ if (keyRefusal) return keyRefusal;
821
+ const credential = dialogCredential();
822
+ if (!credential.ok) return credential.error;
504
823
  }
505
824
  return null;
506
825
  }
@@ -508,7 +827,8 @@ export function useProvidersPanel(
508
827
  /**
509
828
  * The `PUT ${providersWriteUrl}/:id` body. The write replaces every editable
510
829
  * field, so an edit starts from the defining document's values the dialog
511
- * does not show (`layerFields`) and keeps the stored credential.
830
+ * does not show (`layerFields`). An edit whose credential the sign-in step
831
+ * cannot express keeps the stored one.
512
832
  */
513
833
  function buildBody(): Record<string, unknown> {
514
834
  const row = editing.value;
@@ -526,43 +846,131 @@ export function useProvidersPanel(
526
846
  }
527
847
  const baseUrl = form.baseUrl.trim();
528
848
  if (baseUrl) body.base_url = baseUrl;
529
- const credentialKind: AIProviderCredentialKind =
530
- kept?.credentialKind ?? (isCustom.value ? "bearer" : (preset?.credential.kind ?? "bearer"));
531
- const envName = row ? "" : form.envName.trim();
532
- if (envName) body.credential = { kind: credentialKind, ref: `env:${envName}` };
533
- else if (row?.credentialRef) body.credential = { kind: credentialKind, ref: row.credentialRef };
849
+
850
+ const fields = paramFields.value;
851
+ if (fields.length > 0) {
852
+ const params: Record<string, unknown> =
853
+ kept && isPlainObject(body.params) ? { ...body.params } : {};
854
+ for (const { key } of fields) {
855
+ const value = form.params[key]?.trim();
856
+ if (value) params[key] = value;
857
+ else delete params[key];
858
+ }
859
+ body.params = params;
860
+ } else if (!kept) {
861
+ // Another preset (or custom): the previous preset's parameters do not carry over.
862
+ delete body.params;
863
+ }
864
+
865
+ if (showSignIn.value) {
866
+ const credential = dialogCredential();
867
+ if (credential.ok && credential.credential) body.credential = credential.credential;
868
+ } else if (row?.credentialRef) {
869
+ const credentialKind: AIProviderCredentialKind =
870
+ kept?.credentialKind ?? (isCustom.value ? "bearer" : (preset?.credential.kind ?? "bearer"));
871
+ body.credential = { kind: credentialKind, ref: row.credentialRef };
872
+ } else if (kept?.credentialKind) {
873
+ body.credential = { kind: kept.credentialKind };
874
+ }
534
875
  return body;
535
876
  }
536
877
 
878
+ /** Write a "Set value now" secret to the scope's `.env` first, under the key name (else the preset's). */
879
+ async function writeDialogSecret() {
880
+ const name = form.envName.trim() || defaultEnvName.value || "";
881
+ if (!showCredentialFields.value || !form.secret || !environmentOn.value || !name) return;
882
+ const update: EnvSettingsUpdate = { set: { [name]: form.secret }, unset: [] };
883
+ await client().updateEnvironment(update);
884
+ form.secret = "";
885
+ }
886
+
887
+ /**
888
+ * After an add: with an explicit client, test every created entry and show
889
+ * the results on the done step; otherwise close the dialog.
890
+ */
891
+ async function finishAdd(ids: string[]) {
892
+ createdIds.value = ids;
893
+ await afterWrite();
894
+ if (!canTest.value) {
895
+ dialogVisible.value = false;
896
+ return;
897
+ }
898
+ dialogStep.value = "done";
899
+ for (const id of ids) await testProvider({ id });
900
+ }
901
+
902
+ /** Save the Azure card: the secret first, then each entry; ids written before a refusal are named. */
903
+ async function saveAzureAccount() {
904
+ const keyRefusal = keyFieldsRefusal();
905
+ const credential = dialogCredential();
906
+ const drafts = keyRefusal
907
+ ? { ok: false as const, error: keyRefusal }
908
+ : !credential.ok
909
+ ? credential
910
+ : azureAccountEntries({
911
+ resource: azureForm.resource,
912
+ credential: credential.credential,
913
+ openai: azureForm.useOpenAI
914
+ ? { deployments: azureForm.deployments, apiVersion: azureForm.apiVersion }
915
+ : undefined,
916
+ foundry: azureForm.useFoundry ? { deployments: [] } : undefined,
917
+ takenIds: providers.value.map((entry) => entry.id),
918
+ });
919
+ if (!drafts.ok) {
920
+ dialogError.value = drafts.error;
921
+ return;
922
+ }
923
+ saving.value = true;
924
+ dialogError.value = null;
925
+ const created: string[] = [];
926
+ try {
927
+ await writeDialogSecret();
928
+ for (const draft of drafts.entries) {
929
+ await client().upsert(draft.id, draft.body);
930
+ created.push(draft.id);
931
+ }
932
+ await finishAdd(created);
933
+ } catch (err) {
934
+ const message = requestErrorMessage(err, "Could not add the Azure account.");
935
+ dialogError.value =
936
+ created.length > 0 ? `${message} (created: ${created.join(", ")})` : message;
937
+ if (created.length > 0) await afterWrite();
938
+ } finally {
939
+ saving.value = false;
940
+ }
941
+ }
942
+
537
943
  /**
538
944
  * Save the dialog. A "Set value now" secret is written first, through the
539
945
  * scope's `.env` route as `NAME=value`; only then is the entry — carrying
540
946
  * the `env:NAME` reference — written. Refusals from either request stay
541
- * inline in the dialog.
947
+ * inline in the dialog. The Azure card writes up to two entries.
542
948
  */
543
949
  async function saveProvider() {
544
950
  if (props.readOnly) return;
951
+ if (isAzureAccount.value && !editing.value) {
952
+ await saveAzureAccount();
953
+ return;
954
+ }
545
955
  const refusal = formRefusal();
546
956
  if (refusal) {
547
957
  dialogError.value = refusal;
548
958
  return;
549
959
  }
550
960
  const id = form.id.trim();
961
+ const adding = editing.value === null;
551
962
  saving.value = true;
552
963
  dialogError.value = null;
553
964
  try {
554
- if (showCredentialFields.value && form.secret && environmentOn.value) {
555
- const update: EnvSettingsUpdate = {
556
- set: { [form.envName.trim()]: form.secret },
557
- unset: [],
558
- };
559
- await client().updateEnvironment(update);
560
- form.secret = "";
561
- }
965
+ await writeDialogSecret();
562
966
  await client().upsert(id, buildBody());
563
- dialogVisible.value = false;
564
967
  editing.value = null;
565
- await afterWrite();
968
+ if (adding) {
969
+ await finishAdd([id]);
970
+ } else {
971
+ dialogVisible.value = false;
972
+ await afterWrite();
973
+ }
566
974
  } catch (err) {
567
975
  dialogError.value = requestErrorMessage(err, "Could not save the provider.");
568
976
  } finally {
@@ -570,6 +978,20 @@ export function useProvidersPanel(
570
978
  }
571
979
  }
572
980
 
981
+ function finishDialog() {
982
+ dialogVisible.value = false;
983
+ createdIds.value = [];
984
+ }
985
+
986
+ /** "Renew": the edit dialog's sign-in for an own entry, else the row's key or reference editor. */
987
+ function openSignIn(row: ProvidersPanelRow) {
988
+ if (!row.canRenew) return;
989
+ if (isEditable(row)) openEdit(row);
990
+ else if (canEnterKey.value && (!row.credentialRef || envNameOfRef(row.credentialRef))) {
991
+ openKeyEditor(row);
992
+ } else openRefEditor(row);
993
+ }
994
+
573
995
  // ── Delete ─────────────────────────────────────────────────────────────────
574
996
 
575
997
  const deleteTarget = ref<ProvidersPanelEntry | null>(null);
@@ -734,6 +1156,100 @@ export function useProvidersPanel(
734
1156
  );
735
1157
  }
736
1158
 
1159
+ async function setEuProcessing(row: ProvidersPanelRow, on: boolean) {
1160
+ if (!row.canSetEuProcessing || row.euProcessing === on) return;
1161
+ await runRowWrite(
1162
+ row.id,
1163
+ () => client().upsert(row.id, { ...ownBody(row), eu_processing: on }),
1164
+ `Could not change EU processing of ${row.id}.`,
1165
+ );
1166
+ }
1167
+
1168
+ // ── Models allowed here ────────────────────────────────────────────────────
1169
+
1170
+ const modelsEditing = ref<string | null>(null);
1171
+ const modelsForm = reactive<ProvidersPanelModelsForm>({ mode: "all", selected: [], query: "" });
1172
+ const modelsKnown = ref<string[]>([]);
1173
+ const modelsLoading = ref(false);
1174
+ const modelsListMessage = ref<string | null>(null);
1175
+ const modelsError = ref<string | null>(null);
1176
+
1177
+ const modelChoices = computed(() =>
1178
+ providerModelChoices(modelsKnown.value, modelsForm.selected, modelsForm.query),
1179
+ );
1180
+
1181
+ /**
1182
+ * Open the row's model filter: its `allowed_models` ticked, the checklist
1183
+ * from its declared `model_map` ids, else from the client's model listing
1184
+ * (`client.models`, the entry's discovery).
1185
+ */
1186
+ async function openModelsEditor(row: ProvidersPanelRow) {
1187
+ if (!row.canFilterModels) return;
1188
+ modelsEditing.value = row.id;
1189
+ modelsForm.mode = row.allowedModels === undefined ? "all" : "only";
1190
+ modelsForm.selected = [...(row.allowedModels ?? [])];
1191
+ modelsForm.query = "";
1192
+ modelsError.value = null;
1193
+ modelsListMessage.value = null;
1194
+ if (row.modelIds && row.modelIds.length > 0) {
1195
+ modelsKnown.value = [...row.modelIds];
1196
+ return;
1197
+ }
1198
+ modelsKnown.value = [];
1199
+ modelsLoading.value = true;
1200
+ try {
1201
+ const listed = await client().models(row.id);
1202
+ if (modelsEditing.value !== row.id) return;
1203
+ modelsKnown.value = listed.models.map((model) => model.id);
1204
+ if (listed.models.length === 0) {
1205
+ modelsListMessage.value = listed.message ?? "This provider lists no models.";
1206
+ }
1207
+ } catch (err) {
1208
+ if (modelsEditing.value === row.id) {
1209
+ modelsListMessage.value = requestErrorMessage(
1210
+ err,
1211
+ `Could not list the models of ${row.id}.`,
1212
+ );
1213
+ }
1214
+ } finally {
1215
+ modelsLoading.value = false;
1216
+ }
1217
+ }
1218
+
1219
+ function setModelsMode(mode: ProvidersPanelModelsMode) {
1220
+ modelsForm.mode = mode;
1221
+ }
1222
+
1223
+ function toggleModel(id: string, on: boolean) {
1224
+ const has = modelsForm.selected.includes(id);
1225
+ if (on && !has) modelsForm.selected = [...modelsForm.selected, id];
1226
+ if (!on && has) modelsForm.selected = modelsForm.selected.filter((model) => model !== id);
1227
+ }
1228
+
1229
+ function cancelModelsEditor() {
1230
+ modelsEditing.value = null;
1231
+ modelsError.value = null;
1232
+ }
1233
+
1234
+ async function saveModelsEditor() {
1235
+ const row = rows.value.find((r) => r.id === modelsEditing.value);
1236
+ if (!row?.canFilterModels) return;
1237
+ const order = providerModelChoices(modelsKnown.value, modelsForm.selected);
1238
+ const allowed =
1239
+ modelsForm.mode === "all" ? null : order.filter((id) => modelsForm.selected.includes(id));
1240
+ rowBusy.value = row.id;
1241
+ modelsError.value = null;
1242
+ try {
1243
+ await client().upsert(row.id, { ...ownBody(row), allowed_models: allowed });
1244
+ modelsEditing.value = null;
1245
+ await afterWrite();
1246
+ } catch (err) {
1247
+ modelsError.value = requestErrorMessage(err, `Could not save the models of ${row.id}.`);
1248
+ } finally {
1249
+ rowBusy.value = null;
1250
+ }
1251
+ }
1252
+
737
1253
  // ── Connection test ────────────────────────────────────────────────────────
738
1254
 
739
1255
  /** Only an explicit client is known to serve the test; the URL routes are a host's concern. A read-only panel offers none. */
@@ -742,7 +1258,7 @@ export function useProvidersPanel(
742
1258
  const testResults = reactive<Record<string, ProvidersPanelTestResult>>({});
743
1259
 
744
1260
  /** Ask the client whether the entry's credential works; the answer shows in its row. */
745
- async function testProvider(row: ProvidersPanelEntry) {
1261
+ async function testProvider(row: Pick<ProvidersPanelEntry, "id">) {
746
1262
  if (!props.client || props.readOnly || testBusy.value !== null) return;
747
1263
  testBusy.value = row.id;
748
1264
  delete testResults[row.id];
@@ -925,6 +1441,25 @@ export function useProvidersPanel(
925
1441
  openEdit,
926
1442
  selectPreset,
927
1443
  saveProvider,
1444
+ // add flow
1445
+ dialogStep,
1446
+ libraryPresets,
1447
+ chooseLibrary,
1448
+ backToLibrary,
1449
+ isAzureAccount,
1450
+ signInOptions,
1451
+ signInOption,
1452
+ selectSignIn,
1453
+ showSignIn,
1454
+ showReferenceField,
1455
+ paramFields,
1456
+ azureForm,
1457
+ addDeploymentRow,
1458
+ removeDeploymentRow,
1459
+ createdIds,
1460
+ finishDialog,
1461
+ defaultEnvName,
1462
+ openSignIn,
928
1463
  // delete
929
1464
  deleteTarget,
930
1465
  deleteOpen,
@@ -942,6 +1477,7 @@ export function useProvidersPanel(
942
1477
  setCredentialEnv,
943
1478
  setEnabled,
944
1479
  resetPatch,
1480
+ setEuProcessing,
945
1481
  rowBusy,
946
1482
  rowError,
947
1483
  canTest,
@@ -963,6 +1499,19 @@ export function useProvidersPanel(
963
1499
  openRefEditor,
964
1500
  cancelRefEditor,
965
1501
  saveRefEditor,
1502
+ // models allowed here
1503
+ modelsEditing,
1504
+ modelsForm,
1505
+ modelsKnown,
1506
+ modelsLoading,
1507
+ modelsListMessage,
1508
+ modelChoices,
1509
+ modelsError,
1510
+ openModelsEditor,
1511
+ setModelsMode,
1512
+ toggleModel,
1513
+ cancelModelsEditor,
1514
+ saveModelsEditor,
966
1515
  // environment
967
1516
  envDocument,
968
1517
  envEditable,