@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
@@ -0,0 +1,526 @@
1
+ /**
2
+ * Provider accounts: how one signs in to a catalog preset, what a provider
3
+ * row says about its account, and the Microsoft Azure account that writes up
4
+ * to two registry entries (Azure OpenAI and Claude on Foundry) sharing one
5
+ * resource and one credential.
6
+ *
7
+ * Pure and browser-safe: the settings panel and the server's write gate read
8
+ * the same rules. The facts come from the catalog preset
9
+ * (`@skaile/provider-registry/catalog`'s `AIProviderPreset`): its `credential.kind`, its
10
+ * `transport`, its default `params` and its `required_params`. The catalog
11
+ * names no list of accepted sign-in kinds, so they are derived here from those
12
+ * facts.
13
+ */
14
+
15
+ import {
16
+ type AIProviderCredential,
17
+ type AIProviderCredentialKind,
18
+ type AIProviderTransport,
19
+ validateCredentialRef,
20
+ } from "./provider-registry";
21
+
22
+ // ── Presets ──────────────────────────────────────────────────────────────────
23
+
24
+ /** The preset facts the account helpers read; a catalog preset and its summary both fit. */
25
+ export interface ProviderAccountPreset {
26
+ id: string;
27
+ label: string;
28
+ transport?: AIProviderTransport;
29
+ credential: { kind: AIProviderCredentialKind; env?: string };
30
+ /** Default routing parameters. */
31
+ params?: Record<string, string>;
32
+ /** `params` keys the operator must supply. */
33
+ required_params?: string[];
34
+ }
35
+
36
+ /**
37
+ * The `params` keys a preset requires that `params` leaves empty: the
38
+ * preset's `required_params` minus the ones its own `params` default.
39
+ * A value that is not a non-blank string, number or boolean counts as missing.
40
+ */
41
+ export function missingRequiredParams(
42
+ preset: Pick<ProviderAccountPreset, "required_params" | "params">,
43
+ params: Record<string, unknown> | undefined,
44
+ ): string[] {
45
+ return (preset.required_params ?? []).filter((key) => {
46
+ if (preset.params?.[key] !== undefined) return false;
47
+ const value = params?.[key];
48
+ if (typeof value === "number" || typeof value === "boolean") return false;
49
+ return typeof value !== "string" || value.trim() === "";
50
+ });
51
+ }
52
+
53
+ /** What the add/edit dialog calls each routing parameter. */
54
+ export const PROVIDER_PARAM_LABELS: Record<string, string> = {
55
+ resource: "Resource",
56
+ deployment: "Deployment",
57
+ api_version: "API version",
58
+ region: "Region",
59
+ project: "Project",
60
+ location: "Location",
61
+ publisher: "Publisher",
62
+ };
63
+
64
+ /**
65
+ * Parameters the operator sets only in expert mode: the dialog prefills them
66
+ * and shows them read-only otherwise.
67
+ */
68
+ export const EXPERT_PARAMS: readonly string[] = ["api_version"];
69
+
70
+ /**
71
+ * The parameter fields an entry of `preset` needs: its required params the
72
+ * preset gives no default for, then any further key `current` already holds.
73
+ */
74
+ export function presetParamKeys(
75
+ preset: Pick<ProviderAccountPreset, "required_params" | "params"> | null | undefined,
76
+ current: Record<string, unknown> | undefined = undefined,
77
+ ): string[] {
78
+ const keys = (preset?.required_params ?? []).filter((key) => preset?.params?.[key] === undefined);
79
+ for (const key of Object.keys(current ?? {})) if (!keys.includes(key)) keys.push(key);
80
+ return keys;
81
+ }
82
+
83
+ /** The value the dialog prefills for parameter `key` of `presetId`, else `""`. */
84
+ export function defaultParamValue(presetId: string | undefined, key: string): string {
85
+ return presetId === AZURE_OPENAI_PRESET && key === "api_version"
86
+ ? AZURE_OPENAI_DEFAULT_API_VERSION
87
+ : "";
88
+ }
89
+
90
+ // ── Sign-in kinds ────────────────────────────────────────────────────────────
91
+
92
+ /** One way to sign in to a provider, as the add flow's "How do you sign in?" step lists it. */
93
+ export type ProviderSignInId =
94
+ | "api_key"
95
+ | "entra"
96
+ | "aws"
97
+ | "google"
98
+ | "oauth"
99
+ | "none"
100
+ | "reference";
101
+
102
+ /** What the sign-in step offers for one {@link ProviderSignInId}. */
103
+ export interface ProviderSignInOption {
104
+ id: ProviderSignInId;
105
+ label: string;
106
+ description: string;
107
+ /** The `credential.kind` the entry gets. */
108
+ credentialKind: AIProviderCredentialKind;
109
+ /**
110
+ * The fields the next step shows: `key` = environment variable name plus
111
+ * an optional value for the scope's `.env`; `reference` = a typed
112
+ * `env:` / `keyring:` / `cmd:` / `platform:` reference; `none` = nothing
113
+ * (no credential, or the server's ambient cloud chain signs).
114
+ */
115
+ fields: "key" | "reference" | "none";
116
+ }
117
+
118
+ /** Transports that run on a Microsoft Azure resource. */
119
+ export const AZURE_TRANSPORTS: readonly AIProviderTransport[] = ["azure-openai", "foundry"];
120
+
121
+ /** Whether a transport runs on a Microsoft Azure resource (and so accepts Entra ID). */
122
+ export function isAzureTransport(transport: AIProviderTransport | undefined): boolean {
123
+ return transport !== undefined && AZURE_TRANSPORTS.includes(transport);
124
+ }
125
+
126
+ function apiKeyOption(kind: AIProviderCredentialKind): ProviderSignInOption {
127
+ return {
128
+ id: "api_key",
129
+ label: "API key",
130
+ description:
131
+ "A key from the provider. It is stored in this scope's environment; the entry only names the variable.",
132
+ credentialKind: kind,
133
+ fields: "key",
134
+ };
135
+ }
136
+
137
+ const ENTRA_OPTION: ProviderSignInOption = {
138
+ id: "entra",
139
+ label: "Entra ID",
140
+ description:
141
+ "Microsoft Entra ID: the server's Azure identity signs the requests (managed identity, az login or AZURE_CLIENT_ID / AZURE_CLIENT_SECRET / AZURE_TENANT_ID). No key is stored.",
142
+ credentialKind: "azure_entra",
143
+ fields: "none",
144
+ };
145
+
146
+ const AWS_OPTION: ProviderSignInOption = {
147
+ id: "aws",
148
+ label: "AWS credentials",
149
+ description: "The server's AWS default credential chain signs the requests. No key is stored.",
150
+ credentialKind: "sigv4",
151
+ fields: "none",
152
+ };
153
+
154
+ const GOOGLE_OPTION: ProviderSignInOption = {
155
+ id: "google",
156
+ label: "Google credentials",
157
+ description:
158
+ "Google application default credentials on the server sign the requests. No key is stored.",
159
+ credentialKind: "google_adc",
160
+ fields: "none",
161
+ };
162
+
163
+ const OAUTH_OPTION: ProviderSignInOption = {
164
+ id: "oauth",
165
+ label: "OAuth",
166
+ description: "An OAuth token held behind a reference (keyring, command or control plane).",
167
+ credentialKind: "oauth",
168
+ fields: "reference",
169
+ };
170
+
171
+ const NONE_OPTION: ProviderSignInOption = {
172
+ id: "none",
173
+ label: "No sign-in",
174
+ description: "The endpoint takes no credential, such as a model server on this machine.",
175
+ credentialKind: "none",
176
+ fields: "none",
177
+ };
178
+
179
+ function referenceOption(kind: AIProviderCredentialKind): ProviderSignInOption {
180
+ return {
181
+ id: "reference",
182
+ label: "Reference",
183
+ description:
184
+ "Type the credential reference yourself: env:NAME, keyring:service/account, cmd:… or platform:….",
185
+ credentialKind: kind,
186
+ fields: "reference",
187
+ };
188
+ }
189
+
190
+ /**
191
+ * The sign-in kinds a preset accepts, in the order the sign-in step lists
192
+ * them. `null` is a custom entry (no preset).
193
+ *
194
+ * - `none` presets (local model servers): No sign-in only.
195
+ * - `api_key_header` / `bearer` presets: API key; Azure transports
196
+ * (`azure-openai`, `foundry`) list Entra ID first, then API key.
197
+ * - `oauth` / `command` presets: a reference (the token lives behind one).
198
+ * - `expert`: the raw Reference form is added for every preset that takes a
199
+ * credential.
200
+ */
201
+ export function presetSignInOptions(
202
+ preset: Pick<ProviderAccountPreset, "credential" | "transport"> | null,
203
+ options: { expert?: boolean } = {},
204
+ ): ProviderSignInOption[] {
205
+ const out: ProviderSignInOption[] = [];
206
+ const kind = preset?.credential.kind;
207
+ if (!preset) {
208
+ out.push(apiKeyOption("bearer"), NONE_OPTION);
209
+ } else if (kind === "none") {
210
+ out.push(NONE_OPTION);
211
+ } else if (kind === "sigv4") {
212
+ out.push(AWS_OPTION);
213
+ } else if (kind === "google_adc") {
214
+ out.push(GOOGLE_OPTION);
215
+ } else if (kind === "azure_entra") {
216
+ out.push(ENTRA_OPTION);
217
+ } else if (kind === "oauth") {
218
+ out.push(OAUTH_OPTION);
219
+ } else if (kind === "command") {
220
+ out.push(referenceOption("command"));
221
+ } else if (kind === "api_key_header" || kind === "bearer") {
222
+ if (isAzureTransport(preset.transport)) out.push(ENTRA_OPTION);
223
+ out.push(apiKeyOption(kind));
224
+ }
225
+ const referenceKind: AIProviderCredentialKind = kind ?? "bearer";
226
+ if (options.expert && referenceKind !== "none" && !out.some((o) => o.fields === "reference")) {
227
+ out.push(referenceOption(referenceKind));
228
+ }
229
+ return out;
230
+ }
231
+
232
+ /** What the sign-in fields hold. */
233
+ export interface ProviderSignInInput {
234
+ /** `key` fields: the environment variable the key lives in. */
235
+ envName?: string;
236
+ /** `reference` fields: the typed reference. */
237
+ ref?: string;
238
+ }
239
+
240
+ /** The credential a sign-in choice writes, or why it cannot be written. */
241
+ export type ProviderSignInCredential =
242
+ | { ok: true; credential?: AIProviderCredential }
243
+ | { ok: false; error: string };
244
+
245
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
246
+
247
+ /**
248
+ * The `credential` of an entry signed in with `option`.
249
+ *
250
+ * `key`: `{ kind, ref: "env:<NAME>" }`; a blank name falls back to
251
+ * `defaultEnv` (the preset's conventional variable) and, without one, writes
252
+ * no credential so the preset's own applies — unless `requireCredential`.
253
+ * `reference`: `{ kind, ref }` after the reference check. `none` fields: a
254
+ * cloud chain writes `{ kind }` without a reference, No sign-in writes
255
+ * `{ kind: "none" }`.
256
+ */
257
+ export function signInCredential(
258
+ option: ProviderSignInOption,
259
+ input: ProviderSignInInput,
260
+ context: { defaultEnv?: string; requireCredential?: boolean } = {},
261
+ ): ProviderSignInCredential {
262
+ if (option.fields === "none") return { ok: true, credential: { kind: option.credentialKind } };
263
+ if (option.fields === "reference") {
264
+ const ref = input.ref?.trim() ?? "";
265
+ if (!ref) return { ok: false, error: "Enter a credential reference." };
266
+ const refusal = validateCredentialRef(ref);
267
+ if (refusal) return { ok: false, error: refusal };
268
+ return { ok: true, credential: { kind: option.credentialKind, ref } };
269
+ }
270
+ const name = input.envName?.trim() || context.defaultEnv || "";
271
+ if (!name) {
272
+ return context.requireCredential
273
+ ? { ok: false, error: "Name the environment variable the key is stored in." }
274
+ : { ok: true };
275
+ }
276
+ if (!ENV_NAME.test(name)) {
277
+ return { ok: false, error: `'${name}' is not a valid environment variable name.` };
278
+ }
279
+ return { ok: true, credential: { kind: option.credentialKind, ref: `env:${name}` } };
280
+ }
281
+
282
+ /** The sign-in option an existing credential corresponds to, among `options`. */
283
+ export function signInOptionOf(
284
+ credential: { kind?: AIProviderCredentialKind; ref?: string } | undefined,
285
+ options: readonly ProviderSignInOption[],
286
+ ): ProviderSignInOption | undefined {
287
+ if (!credential?.kind) return options[0];
288
+ const sameKind = options.filter((o) => o.credentialKind === credential.kind);
289
+ const ref = credential.ref;
290
+ const wanted: ProviderSignInOption["fields"] = !ref
291
+ ? sameKind.some((o) => o.fields === "none")
292
+ ? "none"
293
+ : "key"
294
+ : ref.startsWith("env:")
295
+ ? "key"
296
+ : "reference";
297
+ return sameKind.find((o) => o.fields === wanted) ?? sameKind[0];
298
+ }
299
+
300
+ // ── Account line ─────────────────────────────────────────────────────────────
301
+
302
+ /** What a row says for each credential kind. */
303
+ export const CREDENTIAL_KIND_LABELS: Record<AIProviderCredentialKind, string> = {
304
+ api_key_header: "API key",
305
+ bearer: "API key",
306
+ azure_entra: "Entra ID",
307
+ sigv4: "AWS credentials",
308
+ google_adc: "Google credentials",
309
+ oauth: "OAuth",
310
+ command: "Command",
311
+ none: "No sign-in",
312
+ };
313
+
314
+ /** The family an Azure-transport entry belongs to. */
315
+ export const AZURE_FAMILY_LABEL = "Microsoft Azure";
316
+
317
+ /**
318
+ * The provider family a row belongs to: "Microsoft Azure" for an
319
+ * Azure transport, else its preset's label, else "Custom".
320
+ */
321
+ export function providerFamilyLabel(
322
+ entry: { preset?: string; transport?: AIProviderTransport },
323
+ presets: readonly Pick<ProviderAccountPreset, "id" | "label" | "transport">[],
324
+ ): string {
325
+ const preset = entry.preset ? presets.find((p) => p.id === entry.preset) : undefined;
326
+ if (isAzureTransport(entry.transport ?? preset?.transport)) return AZURE_FAMILY_LABEL;
327
+ return preset?.label ?? (entry.preset ? entry.preset : "Custom");
328
+ }
329
+
330
+ /**
331
+ * The account line of a provider row: family · sign-in kind · resource, each
332
+ * part only when known — "Microsoft Azure · Entra ID · contoso-ai".
333
+ */
334
+ export function providerAccountLine(
335
+ entry: {
336
+ preset?: string;
337
+ transport?: AIProviderTransport;
338
+ credentialKind?: AIProviderCredentialKind;
339
+ transportParams?: Record<string, string>;
340
+ },
341
+ presets: readonly Pick<ProviderAccountPreset, "id" | "label" | "transport" | "credential">[],
342
+ ): string {
343
+ const preset = entry.preset ? presets.find((p) => p.id === entry.preset) : undefined;
344
+ const kind = entry.credentialKind ?? preset?.credential.kind;
345
+ const resource = entry.transportParams?.resource?.trim();
346
+ return [
347
+ providerFamilyLabel(entry, presets),
348
+ kind ? CREDENTIAL_KIND_LABELS[kind] : null,
349
+ resource || null,
350
+ ]
351
+ .filter((part): part is string => !!part)
352
+ .join(" · ");
353
+ }
354
+
355
+ // ── Credential expiry ────────────────────────────────────────────────────────
356
+
357
+ /** A credential's expiry as a row shows it. */
358
+ export interface CredentialExpiry {
359
+ /** Whole days left, rounded down; `0` = expires within a day, negative = expired. */
360
+ days: number;
361
+ expired: boolean;
362
+ /** "Expires in 12 days" · "Expires in 1 day" · "Expires today" · "Expired". */
363
+ text: string;
364
+ }
365
+
366
+ const DAY_MS = 24 * 60 * 60 * 1000;
367
+
368
+ /** The expiry line for an ISO timestamp, or `null` when there is none or it does not parse. */
369
+ export function credentialExpiry(
370
+ expiresAt: string | null | undefined,
371
+ now: number = Date.now(),
372
+ ): CredentialExpiry | null {
373
+ if (!expiresAt) return null;
374
+ const at = Date.parse(expiresAt);
375
+ if (!Number.isFinite(at)) return null;
376
+ if (at <= now) return { days: Math.floor((at - now) / DAY_MS), expired: true, text: "Expired" };
377
+ const days = Math.floor((at - now) / DAY_MS);
378
+ const text = days === 0 ? "Expires today" : `Expires in ${days} day${days === 1 ? "" : "s"}`;
379
+ return { days, expired: false, text };
380
+ }
381
+
382
+ // ── Microsoft Azure account ──────────────────────────────────────────────────
383
+
384
+ /** Catalog preset of the Azure OpenAI entry. */
385
+ export const AZURE_OPENAI_PRESET = "azure-openai";
386
+ /** Catalog preset of the Claude on Foundry entry. */
387
+ export const FOUNDRY_PRESET = "foundry";
388
+
389
+ /**
390
+ * The `api_version` the Azure account prefills when the preset names none
391
+ * (the catalog's `azure-openai` preset requires it but has no default).
392
+ */
393
+ export const AZURE_OPENAI_DEFAULT_API_VERSION = "2025-04-01-preview";
394
+
395
+ /** One deployment row: the Azure deployment name and the logical model id it serves. */
396
+ export interface ProviderDeploymentRow {
397
+ deployment: string;
398
+ /** Logical model id (the `model_map` key). Blank: the deployment name. */
399
+ model: string;
400
+ }
401
+
402
+ /**
403
+ * `model_map` (logical id → deployment name) from deployment rows. Rows with
404
+ * both fields blank are skipped; a model without a deployment, or two rows
405
+ * naming the same logical id, is refused.
406
+ */
407
+ export function deploymentModelMap(
408
+ rows: readonly ProviderDeploymentRow[],
409
+ ): { ok: true; modelMap: Record<string, string> } | { ok: false; error: string } {
410
+ const modelMap: Record<string, string> = {};
411
+ for (const row of rows) {
412
+ const deployment = row.deployment.trim();
413
+ const model = row.model.trim();
414
+ if (!deployment && !model) continue;
415
+ if (!deployment) return { ok: false, error: `Name the deployment that serves '${model}'.` };
416
+ const id = model || deployment;
417
+ if (Object.hasOwn(modelMap, id)) return { ok: false, error: `Model '${id}' is listed twice.` };
418
+ modelMap[id] = deployment;
419
+ }
420
+ return { ok: true, modelMap };
421
+ }
422
+
423
+ /** What the Microsoft Azure card collects. */
424
+ export interface AzureAccountInput {
425
+ /** Azure resource name: the `{resource}` of `<resource>.openai.azure.com` / `<resource>.services.ai.azure.com`. */
426
+ resource: string;
427
+ /** The one credential both entries get (from {@link signInCredential}). */
428
+ credential?: AIProviderCredential;
429
+ /** Set: write an Azure OpenAI entry. At least one deployment. */
430
+ openai?: { deployments: readonly ProviderDeploymentRow[]; apiVersion: string };
431
+ /** Set: write a Claude on Foundry entry; deployments are optional. */
432
+ foundry?: { deployments: readonly ProviderDeploymentRow[] };
433
+ /** Entry ids the scope already uses; new ids avoid them. */
434
+ takenIds?: readonly string[];
435
+ }
436
+
437
+ /** One entry to `PUT`: its id and body. */
438
+ export interface ProviderEntryDraft {
439
+ id: string;
440
+ body: Record<string, unknown>;
441
+ }
442
+
443
+ /** Azure resource names: letters, digits and inner hyphens. */
444
+ const AZURE_RESOURCE = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/;
445
+
446
+ function freeId(base: string, resource: string, taken: Set<string>): string {
447
+ if (!taken.has(base)) return base;
448
+ const slug = resource
449
+ .toLowerCase()
450
+ .replace(/[^a-z0-9_-]+/g, "-")
451
+ .replace(/^-+|-+$/g, "");
452
+ const withResource = slug ? `${base}-${slug}` : base;
453
+ if (!taken.has(withResource)) return withResource;
454
+ for (let n = 2; ; n++) {
455
+ const candidate = `${withResource}-${n}`;
456
+ if (!taken.has(candidate)) return candidate;
457
+ }
458
+ }
459
+
460
+ /**
461
+ * The registry entries one Microsoft Azure account writes: an Azure OpenAI
462
+ * entry (preset `azure-openai`, `params` `{ resource, deployment, api_version }`
463
+ * with the first deployment as the routed one, `model_map` from the
464
+ * deployment rows) and/or a Claude on Foundry entry (preset `foundry`,
465
+ * `params.resource`, optional `model_map`). Both carry the same resource and
466
+ * the same credential. Ids are the preset ids, else `<preset>-<resource>`,
467
+ * else numbered, never one of `takenIds`.
468
+ */
469
+ export function azureAccountEntries(
470
+ input: AzureAccountInput,
471
+ ): { ok: true; entries: ProviderEntryDraft[] } | { ok: false; error: string } {
472
+ const resource = input.resource.trim();
473
+ if (!resource) return { ok: false, error: "Enter the Azure resource name." };
474
+ if (!AZURE_RESOURCE.test(resource)) {
475
+ return {
476
+ ok: false,
477
+ error:
478
+ "An Azure resource name has letters, digits and hyphens, and starts and ends with a letter or digit.",
479
+ };
480
+ }
481
+ if (!input.openai && !input.foundry) {
482
+ return { ok: false, error: "Choose Azure OpenAI, Claude on Foundry or both." };
483
+ }
484
+ const taken = new Set(input.takenIds ?? []);
485
+ const entries: ProviderEntryDraft[] = [];
486
+ const credential = input.credential ? { credential: { ...input.credential } } : {};
487
+
488
+ if (input.openai) {
489
+ const map = deploymentModelMap(input.openai.deployments);
490
+ if (!map.ok) return map;
491
+ const first = Object.values(map.modelMap)[0];
492
+ if (!first) return { ok: false, error: "Add at least one Azure OpenAI deployment." };
493
+ const apiVersion = input.openai.apiVersion.trim();
494
+ if (!apiVersion) return { ok: false, error: "Enter the Azure OpenAI api_version." };
495
+ const id = freeId(AZURE_OPENAI_PRESET, resource, taken);
496
+ taken.add(id);
497
+ entries.push({
498
+ id,
499
+ body: {
500
+ preset: AZURE_OPENAI_PRESET,
501
+ label: "Azure OpenAI",
502
+ params: { resource, deployment: first, api_version: apiVersion },
503
+ model_map: map.modelMap,
504
+ ...credential,
505
+ },
506
+ });
507
+ }
508
+
509
+ if (input.foundry) {
510
+ const map = deploymentModelMap(input.foundry.deployments);
511
+ if (!map.ok) return map;
512
+ const id = freeId(FOUNDRY_PRESET, resource, taken);
513
+ taken.add(id);
514
+ entries.push({
515
+ id,
516
+ body: {
517
+ preset: FOUNDRY_PRESET,
518
+ label: "Claude on Foundry",
519
+ params: { resource },
520
+ ...(Object.keys(map.modelMap).length > 0 ? { model_map: map.modelMap } : {}),
521
+ ...credential,
522
+ },
523
+ });
524
+ }
525
+ return { ok: true, entries };
526
+ }