@skaile/forge-settings 0.0.0-stage → 1.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 (83) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +94 -2
  4. package/dist/module.d.mts +17 -0
  5. package/dist/module.json +9 -0
  6. package/dist/module.mjs +23 -0
  7. package/dist/runtime/ai-sdk/index.d.ts +45 -0
  8. package/dist/runtime/ai-sdk/index.js +39 -0
  9. package/dist/runtime/components/ForgeProvidersPanel.d.vue.ts +21 -0
  10. package/dist/runtime/components/ForgeProvidersPanel.vue +435 -0
  11. package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +21 -0
  12. package/dist/runtime/editor/index.d.ts +58 -0
  13. package/dist/runtime/editor/index.js +83 -0
  14. package/dist/runtime/model/errors.d.ts +24 -0
  15. package/dist/runtime/model/errors.js +10 -0
  16. package/dist/runtime/server/credential-policy.d.ts +60 -0
  17. package/dist/runtime/server/credential-policy.js +64 -0
  18. package/dist/runtime/server/credential-store.d.ts +184 -0
  19. package/dist/runtime/server/credential-store.js +234 -0
  20. package/dist/runtime/server/env-layers.d.ts +62 -0
  21. package/dist/runtime/server/env-layers.js +185 -0
  22. package/dist/runtime/server/env.d.ts +30 -0
  23. package/dist/runtime/server/env.js +15 -0
  24. package/dist/runtime/server/harness-settings.d.ts +150 -0
  25. package/dist/runtime/server/harness-settings.js +166 -0
  26. package/dist/runtime/server/index.d.ts +23 -0
  27. package/dist/runtime/server/index.js +14 -0
  28. package/dist/runtime/server/provider-discovery.d.ts +73 -0
  29. package/dist/runtime/server/provider-discovery.js +77 -0
  30. package/dist/runtime/server/provider-layers.d.ts +27 -0
  31. package/dist/runtime/server/provider-layers.js +37 -0
  32. package/dist/runtime/server/provider-service.d.ts +126 -0
  33. package/dist/runtime/server/provider-service.js +87 -0
  34. package/dist/runtime/server/provider-settings.d.ts +78 -0
  35. package/dist/runtime/server/provider-settings.js +81 -0
  36. package/dist/runtime/server/provider-write.d.ts +154 -0
  37. package/dist/runtime/server/provider-write.js +223 -0
  38. package/dist/runtime/server/settings-defaults.d.ts +26 -0
  39. package/dist/runtime/server/settings-defaults.js +46 -0
  40. package/dist/runtime/shared/env-vars.d.ts +55 -0
  41. package/dist/runtime/shared/env-vars.js +22 -0
  42. package/dist/runtime/shared/harness-providers.d.ts +62 -0
  43. package/dist/runtime/shared/harness-providers.js +38 -0
  44. package/dist/runtime/shared/harnesses.d.ts +19 -0
  45. package/dist/runtime/shared/harnesses.js +15 -0
  46. package/dist/runtime/shared/provider-registry.d.ts +255 -0
  47. package/dist/runtime/shared/provider-registry.js +112 -0
  48. package/dist/runtime/shared/settings-types.d.ts +84 -0
  49. package/dist/runtime/shared/settings-types.js +0 -0
  50. package/dist/runtime/shims-vue.d.ts +8 -0
  51. package/dist/runtime/vue/index.d.ts +14 -0
  52. package/dist/runtime/vue/index.js +6 -0
  53. package/dist/runtime/vue/settings-modal-scope.d.ts +62 -0
  54. package/dist/runtime/vue/settings-modal-scope.js +2 -0
  55. package/dist/runtime/vue/useProvidersPanel.d.ts +232 -0
  56. package/dist/runtime/vue/useProvidersPanel.js +655 -0
  57. package/dist/types.d.mts +9 -0
  58. package/package.json +117 -4
  59. package/src/runtime/ai-sdk/index.ts +91 -0
  60. package/src/runtime/components/ForgeProvidersPanel.vue +449 -0
  61. package/src/runtime/editor/index.ts +157 -0
  62. package/src/runtime/model/errors.ts +31 -0
  63. package/src/runtime/server/credential-policy.ts +137 -0
  64. package/src/runtime/server/credential-store.ts +473 -0
  65. package/src/runtime/server/env-layers.ts +290 -0
  66. package/src/runtime/server/env.ts +46 -0
  67. package/src/runtime/server/harness-settings.ts +373 -0
  68. package/src/runtime/server/index.ts +24 -0
  69. package/src/runtime/server/provider-discovery.ts +164 -0
  70. package/src/runtime/server/provider-layers.ts +70 -0
  71. package/src/runtime/server/provider-service.ts +244 -0
  72. package/src/runtime/server/provider-settings.ts +192 -0
  73. package/src/runtime/server/provider-write.ts +438 -0
  74. package/src/runtime/server/settings-defaults.ts +87 -0
  75. package/src/runtime/shared/env-vars.ts +82 -0
  76. package/src/runtime/shared/harness-providers.ts +118 -0
  77. package/src/runtime/shared/harnesses.ts +34 -0
  78. package/src/runtime/shared/provider-registry.ts +391 -0
  79. package/src/runtime/shared/settings-types.ts +87 -0
  80. package/src/runtime/shims-vue.d.ts +8 -0
  81. package/src/runtime/vue/index.ts +15 -0
  82. package/src/runtime/vue/settings-modal-scope.ts +66 -0
  83. package/src/runtime/vue/useProvidersPanel.ts +1087 -0
@@ -0,0 +1,77 @@
1
+ import { resolveEntryBaseUrl } from "@skaile/workspaces/core";
2
+ import { fetchProviderModels, getStaticModels } from "@skaile/workspaces/sdk/bridge";
3
+ import { isEntryEnabled, registryEntries } from "../shared/provider-registry.js";
4
+ import { entryPolicyRefusal } from "./credential-policy.js";
5
+ import { resolveEntryCredential } from "./credential-store.js";
6
+ const MAX_ERROR_LENGTH = 200;
7
+ const MIN_SECRET_LENGTH = 4;
8
+ export function scrubSecrets(text, secrets) {
9
+ let out = text;
10
+ for (const secret of secrets) {
11
+ if (!secret || secret.length < MIN_SECRET_LENGTH) continue;
12
+ const forms = /* @__PURE__ */ new Set([
13
+ secret,
14
+ encodeURIComponent(secret),
15
+ Buffer.from(secret, "utf8").toString("base64")
16
+ ]);
17
+ for (const form of forms) out = out.split(form).join("***");
18
+ }
19
+ return out.length > MAX_ERROR_LENGTH ? `${out.slice(0, MAX_ERROR_LENGTH)}\u2026` : out;
20
+ }
21
+ export async function discoverProviderModels(providerId, registry, options) {
22
+ const match = registryEntries(registry).find(([id]) => id === providerId);
23
+ if (!match)
24
+ return { status: "error", providerId, error: `'${providerId}' is not in this registry` };
25
+ const entry = match[1];
26
+ if (entry.kind !== "llm") {
27
+ return { status: "unsupported", providerId, reason: "only llm entries list models" };
28
+ }
29
+ if (!isEntryEnabled(entry)) return { status: "error", providerId, error: "entry is disabled" };
30
+ const refusal = options.policy ? entryPolicyRefusal(entry, options.policy) : void 0;
31
+ if (refusal) return { status: "error", providerId, error: refusal };
32
+ const credential = resolveEntryCredential(entry, {
33
+ env: options.env,
34
+ ...options.policy ? { policy: options.policy } : {},
35
+ ...options.key ? { key: options.key } : {},
36
+ ...options.path ? { path: options.path } : {}
37
+ });
38
+ const secrets = [credential.value, ...options.redact ?? []];
39
+ const scrub = (text) => scrubSecrets(text, secrets);
40
+ if (!entry.preset) {
41
+ return {
42
+ status: "unsupported",
43
+ providerId,
44
+ reason: "this entry names no catalog preset, so its models cannot be listed; enter a model id"
45
+ };
46
+ }
47
+ if (credential.error && !credential.value) {
48
+ return { status: "error", providerId, error: scrub(credential.error) };
49
+ }
50
+ const staticList = getStaticModels(entry.preset).map(({ id, name }) => ({ id, name }));
51
+ if (!credential.value) {
52
+ return staticList.length > 0 ? {
53
+ status: "static",
54
+ providerId,
55
+ models: staticList,
56
+ reason: "no credential to query the provider with"
57
+ } : {
58
+ status: "unsupported",
59
+ providerId,
60
+ reason: "no credential, and no offline catalog for this preset"
61
+ };
62
+ }
63
+ const baseUrl = resolveEntryBaseUrl(entry);
64
+ const live = await fetchProviderModels(entry.preset, credential.value, {
65
+ ...baseUrl ? { baseUrl } : {},
66
+ env: options.env
67
+ });
68
+ if (live.ok && live.models.length > 0) {
69
+ return {
70
+ status: "live",
71
+ providerId,
72
+ models: live.models.map(({ id, name }) => ({ id, name }))
73
+ };
74
+ }
75
+ const reason = scrub(live.error ?? "the provider returned no models");
76
+ return staticList.length > 0 ? { status: "static", providerId, models: staticList, reason } : { status: "error", providerId, error: reason };
77
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Reading and writing one provider-registry layer document
3
+ * (`providers.yml` / `providers.yaml`) as written on disk.
4
+ *
5
+ * The upstream loader validates and drops entries it dislikes; an editor must
6
+ * not, or rewriting a file would delete a hand-written entry. These helpers
7
+ * therefore read the raw `providers` map and write it back unchanged apart
8
+ * from the entry being edited.
9
+ */
10
+ import { type AIProviderPreset } from "@skaile/workspaces/sdk/bridge";
11
+ /**
12
+ * The file a write to the layer at `path` goes to: the `.yaml` spelling when
13
+ * only that one exists, so an edit never shadows a hand-written document.
14
+ */
15
+ export declare function providerLayerDocument(path: string): string;
16
+ /** The catalog preset `id` names, if the catalog has one. */
17
+ export declare function catalogPreset(id: unknown): AIProviderPreset | undefined;
18
+ /**
19
+ * A layer document's `providers` map exactly as written — entries the
20
+ * validator would reject included, so rewriting the file never drops a
21
+ * hand-written entry. A missing or empty file is an empty layer.
22
+ *
23
+ * @throws When the file is not YAML or not a registry document.
24
+ */
25
+ export declare function readProviderLayer(file: string): Record<string, unknown>;
26
+ /** Write a layer's `providers` map back in the shape the upstream CLI writes. */
27
+ export declare function writeProviderLayer(file: string, providers: Record<string, unknown>): void;
@@ -0,0 +1,37 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import {
3
+ PROVIDERS_FILENAME,
4
+ saveProviderRegistryFile
5
+ } from "@skaile/workspaces/core";
6
+ import { AI_PROVIDER_PRESETS } from "@skaile/workspaces/sdk/bridge";
7
+ import { parse as parseYaml } from "yaml";
8
+ const PROVIDERS_FILENAME_ALIAS = "providers.yaml";
9
+ function isPlainObject(value) {
10
+ return typeof value === "object" && value !== null && !Array.isArray(value);
11
+ }
12
+ export function providerLayerDocument(path) {
13
+ if (existsSync(path)) return path;
14
+ const alias = `${path.slice(0, -PROVIDERS_FILENAME.length)}${PROVIDERS_FILENAME_ALIAS}`;
15
+ return existsSync(alias) ? alias : path;
16
+ }
17
+ export function catalogPreset(id) {
18
+ return typeof id === "string" && Object.hasOwn(AI_PROVIDER_PRESETS, id) ? AI_PROVIDER_PRESETS[id] : void 0;
19
+ }
20
+ export function readProviderLayer(file) {
21
+ if (!existsSync(file)) return {};
22
+ let document;
23
+ try {
24
+ document = parseYaml(readFileSync(file, "utf8"));
25
+ } catch (err) {
26
+ throw new Error(`${file} is not valid YAML (${err.message})`);
27
+ }
28
+ if (document === null || document === void 0) return {};
29
+ if (!isPlainObject(document)) throw new Error(`${file} does not contain a registry document`);
30
+ const providers = document.providers;
31
+ if (providers === void 0 || providers === null) return {};
32
+ if (!isPlainObject(providers)) throw new Error(`${file}: 'providers' is not a map of entries`);
33
+ return providers;
34
+ }
35
+ export function writeProviderLayer(file, providers) {
36
+ saveProviderRegistryFile(file, { version: 1, providers });
37
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The provider inventory a forge app shows and selects from.
3
+ *
4
+ * Every fact here comes out of the AI provider registry the session was handed:
5
+ * which providers exist, whether one serves chat or speech, where it points,
6
+ * which credential reference backs it and whether that reference resolves.
7
+ * forge-settings owns no provider list and no provider-type table any more — an
8
+ * entry's `kind` is the discriminator, and the registry is the list.
9
+ *
10
+ * What forge deliberately does not do here:
11
+ *
12
+ * - **Endpoint probing.** "Does this endpoint accept this key" is the
13
+ * runtime's `skaile providers test` / the catalog's entry probe, which
14
+ * knows every surface's shape. {@link testAIProviderCredential} answers the
15
+ * question forge owns: does the credential reference resolve, and where
16
+ * from. That is the failure mode a settings modal must explain.
17
+ * - **Live model discovery.** Model lists come from the entry's own
18
+ * `model_map`/`capabilities`; harness-backed discovery is
19
+ * `listModelsForHarness(harnessId, registry)` in the runtime.
20
+ */
21
+ import type { AIProviderCapabilities, AIProviderCredentialKind, AIProviderKind, AIProviderRegistryView, AIProviderSurface, AIProviderVoiceParams } from "../shared/provider-registry.js";
22
+ import { type CredentialSource, type CredentialStoreOptions } from "./credential-store.js";
23
+ /** One registry entry, as a settings UI needs to see it. */
24
+ export interface ProviderInfo {
25
+ /** Registry id — the left half of a `"<providerId>/<modelId>"` selector. */
26
+ id: string;
27
+ /** Display name: the entry's `label`, else its id. */
28
+ name: string;
29
+ /** What the entry produces. Replaces forge's old llm/stt/tts guesswork. */
30
+ kind: AIProviderKind;
31
+ /** Whether the entry's credential resolved to a value in this process. */
32
+ configured: boolean;
33
+ /** Which reference form answered — `ambient` means a cloud SDK chain signs. */
34
+ source: CredentialSource;
35
+ /** Why the credential did not resolve. Secret-free, safe to render. */
36
+ error?: string;
37
+ /** The entry's `credential.ref`. A reference, never a secret. */
38
+ credentialRef?: string;
39
+ /** How the credential is presented on the wire. */
40
+ credentialKind?: AIProviderCredentialKind;
41
+ /**
42
+ * Whether the entry's reference is an `env:` one. The variable is shared by
43
+ * every user of the server, so a settings UI names it rather than pretending
44
+ * the value behind it is the user's to change.
45
+ */
46
+ hasEnvKey: boolean;
47
+ /** Name of that environment variable, when {@link hasEnvKey}. */
48
+ envKeyName?: string;
49
+ /** Endpoint the entry pins, when it pins one. */
50
+ baseUrl?: string;
51
+ /** Catalog preset the entry inherits from, when it names one. */
52
+ preset?: string;
53
+ /** Wire protocol of an `llm`/`embed` entry (its own or its preset's). */
54
+ surface?: AIProviderSurface;
55
+ /** The entry's `enabled` flag; `false` only in a listing with `includeDisabled`. */
56
+ enabled: boolean;
57
+ /**
58
+ * Voice parameters a `kind: stt|tts` entry declares — Deepgram
59
+ * `{model, language}`, ElevenLabs `{voice_id, output_format}`. A settings UI
60
+ * shows these as the defaults a per-request override replaces; `llm`/`embed`
61
+ * entries never carry them.
62
+ */
63
+ params?: AIProviderVoiceParams;
64
+ /** Frozen by the control plane: a lower layer cannot change or disable it. */
65
+ locked: boolean;
66
+ }
67
+ /** One selectable model of one provider. */
68
+ export interface ModelInfo {
69
+ /** The selector to store in `settings.model`: `"<providerId>/<modelId>"`. */
70
+ id: string;
71
+ /** Display name — the logical model id. */
72
+ name: string;
73
+ /** Registry id of the entry serving the model. */
74
+ providerId: string;
75
+ /** Logical id: the `model_map` key. */
76
+ modelId: string;
77
+ /** Provider-side id the logical id maps to. */
78
+ providerModelId: string;
79
+ /** Capability facts the entry declares for this model, when any. */
80
+ capabilities?: AIProviderCapabilities;
81
+ }
82
+ /** Result of resolving one entry's credential on demand. */
83
+ export interface ProviderTestResult {
84
+ /** Whether the credential resolved to a usable value. */
85
+ ok: boolean;
86
+ /** Registry id that was tested. */
87
+ provider: string;
88
+ /** Which reference form answered. */
89
+ source: CredentialSource;
90
+ /** Why it did not resolve. Secret-free. */
91
+ error?: string;
92
+ }
93
+ /** What {@link listAIProviders} resolves credentials against, and which entries it lists. */
94
+ export interface ListAIProvidersOptions extends CredentialStoreOptions {
95
+ /** List `enabled: false` entries too (a registry editor's view). Default `false`. */
96
+ includeDisabled?: boolean;
97
+ }
98
+ /**
99
+ * Describe every enabled registry entry, in registry order — every entry with
100
+ * `includeDisabled`.
101
+ *
102
+ * Unconfigured entries are included: an entry whose `env:` variable is missing
103
+ * is exactly what the user has to see and fix, and hiding it (the pre-5.0.0
104
+ * behaviour, which filtered on "has a key") turned a typo into an empty list.
105
+ */
106
+ export declare function listAIProviders(registry: AIProviderRegistryView, options?: ListAIProvidersOptions): ProviderInfo[];
107
+ /**
108
+ * List the models the registry declares, as `"<providerId>/<modelId>"`
109
+ * selectors.
110
+ *
111
+ * Sourced from each `llm`/`embed` entry's `model_map` (plus any id that only
112
+ * appears in `capabilities`) — the same two tables the runtime's model-reference
113
+ * resolver reads, so a selector built from this list always resolves.
114
+ *
115
+ * @param providerId - Restrict to one entry; omit for every enabled entry.
116
+ */
117
+ export declare function listAIProviderModels(registry: AIProviderRegistryView, providerId?: string): ModelInfo[];
118
+ /**
119
+ * Resolve one entry's credential and report the outcome.
120
+ *
121
+ * This is the check behind a settings modal's per-provider indicator: it proves
122
+ * the reference points at something this server can read, and names the layer
123
+ * that owns it when it does not (`cmd:` in the spawned runtime, `platform:` in
124
+ * the control plane).
125
+ */
126
+ export declare function testAIProviderCredential(providerId: string, registry: AIProviderRegistryView, options?: CredentialStoreOptions): ProviderTestResult;
@@ -0,0 +1,87 @@
1
+ import {
2
+ entriesOfKind,
3
+ isEntryEnabled,
4
+ isVoiceEntry,
5
+ parseCredentialRef,
6
+ registryEntries
7
+ } from "../shared/provider-registry.js";
8
+ import {
9
+ resolveEntryCredential
10
+ } from "./credential-store.js";
11
+ export function listAIProviders(registry, options = {}) {
12
+ const providers = [];
13
+ for (const [id, entry] of registryEntries(registry)) {
14
+ const enabled = isEntryEnabled(entry);
15
+ if (!enabled && !options.includeDisabled) continue;
16
+ const resolved = resolveEntryCredential(entry, options);
17
+ const ref = entry.credential?.ref;
18
+ const parsed = ref ? parseCredentialRef(ref) : void 0;
19
+ providers.push({
20
+ id,
21
+ name: entry.label?.trim() || id,
22
+ kind: entry.kind,
23
+ // No error means usable: a value resolved, none is needed (`kind: none`),
24
+ // or an ambient cloud chain signs the request.
25
+ configured: resolved.error === void 0,
26
+ source: resolved.source,
27
+ ...resolved.error ? { error: resolved.error } : {},
28
+ ...ref ? { credentialRef: ref } : {},
29
+ ...entry.credential ? { credentialKind: entry.credential.kind } : {},
30
+ hasEnvKey: parsed?.form === "env",
31
+ ...parsed?.form === "env" ? { envKeyName: parsed.name } : {},
32
+ ...entry.base_url ? { baseUrl: entry.base_url } : {},
33
+ ...entry.preset ? { preset: entry.preset } : {},
34
+ ...!isVoiceEntry(entry) && entry.surface ? { surface: entry.surface } : {},
35
+ enabled,
36
+ ...isVoiceEntry(entry) && entry.params ? { params: entry.params } : {},
37
+ locked: entry.locked === true
38
+ });
39
+ }
40
+ return providers;
41
+ }
42
+ export function listAIProviderModels(registry, providerId) {
43
+ const models = [];
44
+ for (const kind of ["llm", "embed"]) {
45
+ for (const [id, entry] of entriesOfKind(registry, kind)) {
46
+ if (providerId && id !== providerId) continue;
47
+ const logicalIds = /* @__PURE__ */ new Set([
48
+ ...Object.keys(entry.model_map ?? {}),
49
+ ...Object.keys(entry.capabilities ?? {})
50
+ ]);
51
+ for (const modelId of logicalIds) {
52
+ const capabilities = entry.capabilities?.[modelId];
53
+ models.push({
54
+ id: `${id}/${modelId}`,
55
+ name: modelId,
56
+ providerId: id,
57
+ modelId,
58
+ providerModelId: entry.model_map?.[modelId] ?? modelId,
59
+ ...capabilities ? { capabilities } : {}
60
+ });
61
+ }
62
+ }
63
+ }
64
+ return models;
65
+ }
66
+ export function testAIProviderCredential(providerId, registry, options = {}) {
67
+ const match = registryEntries(registry).find(([id]) => id === providerId);
68
+ if (!match) {
69
+ return {
70
+ ok: false,
71
+ provider: providerId,
72
+ source: "none",
73
+ error: `'${providerId}' is not an entry in this registry`
74
+ };
75
+ }
76
+ const [, entry] = match;
77
+ if (!isEntryEnabled(entry)) {
78
+ return { ok: false, provider: providerId, source: "none", error: "entry is disabled" };
79
+ }
80
+ const resolved = resolveEntryCredential(entry, options);
81
+ return {
82
+ ok: resolved.error === void 0,
83
+ provider: providerId,
84
+ source: resolved.source,
85
+ ...resolved.error ? { error: resolved.error } : {}
86
+ };
87
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Where a forge app's settings meet the AI provider registry.
3
+ *
4
+ * Two jobs:
5
+ *
6
+ * 1. **Migration.** A `settings.json` written before the 5.0.0 registry migration holds
7
+ * `provider` + a free-text `model`, and an `apiKeys` map of secrets. On
8
+ * first load the pair collapses into one `"<providerId>/<modelId>"`
9
+ * selector, and the `apiKeys` map is dropped with a warning — a plaintext
10
+ * key is never auto-written anywhere; the user moves it into `.env`.
11
+ * 2. **Voice selection.** `sttProvider`/`ttsProvider` are registry ids of
12
+ * `kind: stt|tts` entries; the endpoint and the voice parameters live on
13
+ * the entry, and `sttLanguage`/`ttsVoice` are per-request overrides of two
14
+ * of those parameters. Resolving all of that used to be three apps'
15
+ * worth of hardcoded endpoints and voice ids.
16
+ */
17
+ import type { AIProviderVoiceEntry, AIProviderVoiceKind, AIProviderVoiceParams, AIProviderRegistryView } from "../shared/provider-registry.js";
18
+ import type { SkaileSettings } from "../shared/settings-types.js";
19
+ /** What {@link migrateLegacyProviderSettings} did to one settings file. */
20
+ export interface LegacySettingsMigration {
21
+ /** The file that was inspected. */
22
+ path: string;
23
+ /** Whether the file was rewritten. */
24
+ changed: boolean;
25
+ /** The selector `provider` + `model` collapsed into, when that happened. */
26
+ model?: string;
27
+ /** Everything dropped or left untouched, and why. Secret-free. */
28
+ warnings: string[];
29
+ }
30
+ /**
31
+ * Migrate one legacy `settings.json` in place. Idempotent: a file with no
32
+ * legacy `provider` and no `apiKeys` is left byte-identical.
33
+ *
34
+ * An `apiKeys` map is removed from the file and never stored anywhere else:
35
+ * the registry holds references only, and a plaintext key has no safe
36
+ * automatic destination. Each non-empty entry is reported through `warnings`
37
+ * (without the key) so the user can move it into `.env`.
38
+ *
39
+ * @param settingsPath - Absolute path to a `settings.json`.
40
+ */
41
+ export declare function migrateLegacyProviderSettings(settingsPath: string): LegacySettingsMigration;
42
+ /** Settings fields that name a voice entry and override its parameters. */
43
+ export type VoiceSettings = Pick<SkaileSettings, "sttProvider" | "ttsProvider" | "sttLanguage" | "ttsVoice">;
44
+ /** The `kind: stt|tts` entry a request runs against, with its parameters resolved. */
45
+ export interface VoiceProviderSelection {
46
+ /** Registry id of the entry. */
47
+ providerId: string;
48
+ entry: AIProviderVoiceEntry;
49
+ /** Endpoint from the entry; absent when the entry inherits its preset's default. */
50
+ baseUrl?: string;
51
+ /**
52
+ * The entry's `params` with overrides applied — Deepgram `{model, language}`,
53
+ * ElevenLabs `{voice_id, output_format}`. This is the only place a forge app
54
+ * should read voice defaults from.
55
+ */
56
+ params: AIProviderVoiceParams;
57
+ /** True when settings named no usable entry and registry order decided. */
58
+ fallback: boolean;
59
+ /** Non-fatal notes, e.g. settings naming an entry the registry does not have. */
60
+ warnings: string[];
61
+ }
62
+ /**
63
+ * Resolve which registry entry serves speech-to-text or text-to-speech.
64
+ *
65
+ * Selection is `settings.sttProvider`/`settings.ttsProvider`, falling back to
66
+ * the first enabled entry of that kind in registry order when the setting is
67
+ * absent or names an entry that is gone — a missing setting must not silence an
68
+ * app that has a perfectly good entry configured.
69
+ *
70
+ * Parameter precedence, lowest first: the entry's `params`, then the settings
71
+ * overrides (`sttLanguage` → `language`, `ttsVoice` → `voice_id`), then
72
+ * `overrides` from the request. The per-request override is the contract
73
+ * forge-concept already honoured and the other apps silently dropped.
74
+ *
75
+ * @returns The selection, or `undefined` when the registry has no enabled entry
76
+ * of that kind.
77
+ */
78
+ export declare function resolveVoiceProvider(registry: AIProviderRegistryView, kind: AIProviderVoiceKind, settings?: VoiceSettings, overrides?: AIProviderVoiceParams): VoiceProviderSelection | undefined;
@@ -0,0 +1,81 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import {
3
+ entriesOfKind,
4
+ selectVoiceEntryId,
5
+ VOICE_OVERRIDE_PARAM
6
+ } from "../shared/provider-registry.js";
7
+ export function migrateLegacyProviderSettings(settingsPath) {
8
+ const result = {
9
+ path: settingsPath,
10
+ changed: false,
11
+ warnings: []
12
+ };
13
+ if (!existsSync(settingsPath)) return result;
14
+ let settings;
15
+ try {
16
+ settings = JSON.parse(readFileSync(settingsPath, "utf8"));
17
+ } catch (err) {
18
+ result.warnings.push(`${settingsPath} is not valid JSON (${err.message})`);
19
+ return result;
20
+ }
21
+ if (typeof settings !== "object" || settings === null || Array.isArray(settings)) {
22
+ result.warnings.push(`${settingsPath} does not contain a settings object`);
23
+ return result;
24
+ }
25
+ const legacyProvider = typeof settings.provider === "string" ? settings.provider.trim() : "";
26
+ if (legacyProvider) {
27
+ const model = typeof settings.model === "string" ? settings.model.trim() : "";
28
+ if (model && !model.includes("/")) {
29
+ settings.model = `${legacyProvider}/${model}`;
30
+ result.model = settings.model;
31
+ } else if (!model) {
32
+ result.warnings.push(
33
+ `provider '${legacyProvider}' had no model to namespace \u2014 the session now resolves its model through the registry's own order`
34
+ );
35
+ }
36
+ delete settings.provider;
37
+ result.changed = true;
38
+ }
39
+ if ("apiKeys" in settings) {
40
+ const apiKeys = settings.apiKeys;
41
+ const hasSecret = typeof apiKeys === "object" && apiKeys !== null && Object.values(apiKeys).some((value) => typeof value === "string" && value !== "");
42
+ if (hasSecret) {
43
+ result.warnings.push(
44
+ "apiKeys found in settings: move each key into .env and reference it from providers.yml (skaile providers add)"
45
+ );
46
+ }
47
+ delete settings.apiKeys;
48
+ result.changed = true;
49
+ }
50
+ if (result.changed) {
51
+ writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}
52
+ `, "utf8");
53
+ }
54
+ return result;
55
+ }
56
+ export function resolveVoiceProvider(registry, kind, settings = {}, overrides = {}) {
57
+ const candidates = entriesOfKind(registry, kind);
58
+ if (candidates.length === 0) return void 0;
59
+ const warnings = [];
60
+ const wanted = (kind === "stt" ? settings.sttProvider : settings.ttsProvider)?.trim();
61
+ const ids = candidates.map(([id]) => id);
62
+ const providerId = selectVoiceEntryId(ids, wanted);
63
+ const entry = candidates[ids.indexOf(providerId)][1];
64
+ if (wanted && wanted !== providerId) {
65
+ warnings.push(
66
+ `settings name '${wanted}' for ${kind}, which is not an enabled ${kind} entry \u2014 using '${providerId}' instead`
67
+ );
68
+ }
69
+ const fallback = wanted !== providerId;
70
+ const settingsOverride = {};
71
+ const override = (kind === "stt" ? settings.sttLanguage : settings.ttsVoice)?.trim();
72
+ if (override) settingsOverride[VOICE_OVERRIDE_PARAM[kind]] = override;
73
+ return {
74
+ providerId,
75
+ entry,
76
+ ...entry.base_url ? { baseUrl: entry.base_url } : {},
77
+ params: { ...entry.params, ...settingsOverride, ...overrides },
78
+ fallback,
79
+ warnings
80
+ };
81
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Editing the provider registry's on-disk layers over HTTP: the instance
3
+ * layer (admins) and a workspace's `<root>/.skaile/providers.yml` (write
4
+ * access, unless the admin locked providers).
5
+ *
6
+ * A layer file holds references, never secrets: a body that carries a key
7
+ * value is refused before anything else, and `credential.ref` must be one of
8
+ * the four reference forms. Every write is gated on the registry it would
9
+ * produce — the scope's layers merged over the catalog presets the entries
10
+ * name, as the spawned child merges them — so an entry the child would drop is
11
+ * refused with the upstream diagnostics instead of being written.
12
+ *
13
+ * An entry a lower document defines can be **patched** from a higher scope:
14
+ * a body holding only {@link PATCH_KEYS} is stored under that id in the
15
+ * scope's own document, where the registry merge applies it field by field
16
+ * over the inherited entry. Removing it again (`removeProviderEntry`) is the
17
+ * patch's reset.
18
+ *
19
+ * Writes never touch live sessions: a running chat keeps its endpoint until
20
+ * its model or harness changes.
21
+ *
22
+ * Auth stays in the app: it builds the {@link ProviderWriteScope} (paths, env,
23
+ * lock state), asks {@link providerScopeWritable}, and answers 403 itself.
24
+ */
25
+ import { type AIProviderPreset } from "@skaile/workspaces/sdk/bridge";
26
+ import type { AIProviderRegistry } from "../shared/provider-registry.js";
27
+ import { type CredentialPolicy } from "./credential-policy.js";
28
+ /**
29
+ * A refused provider write. Framework-free: a Nitro / h3 host answers a thrown
30
+ * error carrying `statusCode`, `statusMessage` and `data` with exactly that
31
+ * status, message and body, the same as one made by `createError`.
32
+ */
33
+ export declare class ProviderWriteError extends Error {
34
+ readonly statusCode: number;
35
+ readonly statusMessage: string;
36
+ readonly data?: unknown;
37
+ constructor(input: {
38
+ statusCode: number;
39
+ statusMessage: string;
40
+ data?: unknown;
41
+ });
42
+ }
43
+ /** The refusal for a body that carries a secret instead of a reference. */
44
+ export declare const PROVIDER_SECRET_REFUSAL = "Secrets go into the scope's .env; reference them as env:NAME";
45
+ /** Entry fields a higher scope may set on an entry a lower document defines. */
46
+ export declare const PATCH_KEYS: readonly ["credential", "enabled"];
47
+ /** One registry layer file a write goes to, and the layers merged beneath it. */
48
+ export interface ProviderWriteScope {
49
+ /** Which registry the caller edits. */
50
+ name: "instance" | "workspace";
51
+ /** The scope's own document (its `.yaml` spelling when only that one exists). */
52
+ file: string;
53
+ /** Documents merged beneath it, lowest first; empty for the lowest layer. */
54
+ below: string[];
55
+ /** The scope's effective environment, as `env:` credential references resolve against it. */
56
+ env: Record<string, string | undefined>;
57
+ /** The control plane's provider lock is in force: only an admin may write. */
58
+ lock: boolean;
59
+ /**
60
+ * The host's credential policy. A write whose resulting entry names a
61
+ * reference or environment variable the policy refuses is a 400 and nothing
62
+ * is written. Absent: no restriction.
63
+ */
64
+ policy?: CredentialPolicy;
65
+ }
66
+ /**
67
+ * Whether the caller may write the scope's document: an admin always; anyone
68
+ * else only to a workspace document while providers are not locked.
69
+ */
70
+ export declare function providerScopeWritable(scope: ProviderWriteScope, caller: {
71
+ isAdmin: boolean;
72
+ }): boolean;
73
+ /**
74
+ * Upsert entry `id` in the scope's document.
75
+ *
76
+ * - **Own entry** — the body sets the editable fields (`BODY_KEYS`) — one it
77
+ * omits is removed — and optionally `kind`; fields it does not set (`kind`,
78
+ * `transport`, `params`, …) keep their written value.
79
+ * - **Patch** — when a lower document defines `id` and the body holds only
80
+ * {@link PATCH_KEYS}, the body is merged into the scope's stored entry for
81
+ * `id` (created when absent), leaving every other stored field alone.
82
+ *
83
+ * The file and its directory are created when absent; other entries are kept
84
+ * as written.
85
+ *
86
+ * 400: invalid id, secret-bearing or malformed body, a body for an inherited
87
+ * entry that is neither a patch nor over an own full entry, or any error
88
+ * diagnostic for the entry (as `data.diagnostics`; nothing is written). 403:
89
+ * the entry is `locked: true` in some document. 409: the document on disk is
90
+ * not a registry.
91
+ */
92
+ export declare function upsertProviderEntry(scope: ProviderWriteScope, id: string, body: unknown): {
93
+ id: string;
94
+ entry: Record<string, unknown>;
95
+ };
96
+ /**
97
+ * Remove entry `id` from the scope's document; 404 when that document does
98
+ * not define it. On a patched entry that deletes the patch and the inherited
99
+ * entry shows through again.
100
+ */
101
+ export declare function removeProviderEntry(scope: ProviderWriteScope, id: string): {
102
+ id: string;
103
+ };
104
+ /** A catalog preset as the settings modal lists it. */
105
+ export interface ProviderPresetSummary {
106
+ id: string;
107
+ label: string;
108
+ kind: AIProviderPreset["kind"];
109
+ surface?: AIProviderPreset["surface"];
110
+ base_url?: string;
111
+ base_url_env?: string;
112
+ credential: {
113
+ kind: AIProviderPreset["credential"]["kind"];
114
+ env?: string;
115
+ };
116
+ }
117
+ /** Catalog presets (all of them by default), in catalog order. */
118
+ export declare function providerPresetSummaries(presets?: AIProviderPreset[]): ProviderPresetSummary[];
119
+ /**
120
+ * Presets a scope could configure right now: their `credential.env` is set
121
+ * in `env`, and no entry of `registry` is the preset's id or names it as its
122
+ * `preset`.
123
+ *
124
+ * @param env - The scope's layered environment.
125
+ * @param registry - The scope's merged registry.
126
+ */
127
+ export declare function detectedProviderPresets(env: Record<string, string | undefined>, registry: AIProviderRegistry): ProviderPresetSummary[];
128
+ /** Which registry document holds a provider entry the scope lists. */
129
+ export type ProviderLayerName = ProviderWriteScope["name"];
130
+ /**
131
+ * The defining document's values for the `PUT`-editable fields a settings UI
132
+ * edit dialog does not show. A `PUT` removes every editable field it omits,
133
+ * so an edit sends these back unchanged instead of dropping them.
134
+ */
135
+ export interface ProviderLayerFields {
136
+ label?: unknown;
137
+ headers?: unknown;
138
+ model_map?: unknown;
139
+ enabled?: unknown;
140
+ }
141
+ /**
142
+ * Tag each provider view of a scope with every registry document that holds
143
+ * its id — `layers`, lowest first: `"instance"` for each lower document, then
144
+ * the scope's own name when its own document holds it. An id no document
145
+ * holds (not expected) is attributed to the lowest layer of the scope.
146
+ * `layerFields` are the {@link ProviderLayerFields} of the scope's own entry,
147
+ * or of the lowest document holding the id when the scope has none.
148
+ */
149
+ export declare function withProviderLayers<T extends {
150
+ id: string;
151
+ }>(views: T[], scope: ProviderWriteScope): Array<T & {
152
+ layers: ProviderLayerName[];
153
+ layerFields: ProviderLayerFields;
154
+ }>;