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