@skaile/forge-settings 1.4.0 → 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.
- package/CHANGELOG.md +25 -0
- package/README.md +34 -11
- package/dist/module.json +1 -1
- package/dist/runtime/ai-sdk/index.d.ts +33 -26
- package/dist/runtime/ai-sdk/index.js +34 -16
- package/dist/runtime/components/ForgeProvidersPanel.d.vue.ts +2 -2
- package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +2 -2
- package/dist/runtime/editor/index.d.ts +55 -16
- package/dist/runtime/editor/index.js +82 -40
- package/dist/runtime/model/selector.d.ts +16 -0
- package/dist/runtime/model/selector.js +12 -0
- package/dist/runtime/server/credential-store.d.ts +16 -4
- package/dist/runtime/server/credential-store.js +3 -4
- package/dist/runtime/server/env-layers.d.ts +1 -1
- package/dist/runtime/server/env-layers.js +3 -3
- package/dist/runtime/server/index.d.ts +7 -8
- package/dist/runtime/server/index.js +1 -3
- package/dist/runtime/server/provider-discovery.d.ts +28 -8
- package/dist/runtime/server/provider-discovery.js +57 -33
- package/dist/runtime/server/provider-layers.d.ts +1 -1
- package/dist/runtime/server/provider-layers.js +3 -5
- package/dist/runtime/server/provider-service.d.ts +2 -2
- package/dist/runtime/server/provider-write.d.ts +1 -1
- package/dist/runtime/server/provider-write.js +3 -3
- package/dist/runtime/server/voice-provider.d.ts +61 -0
- package/dist/runtime/server/voice-provider.js +31 -0
- package/dist/runtime/shared/data-zone.d.ts +28 -26
- package/dist/runtime/shared/data-zone.js +4 -4
- package/dist/runtime/shared/harness-providers.d.ts +7 -7
- package/dist/runtime/shared/provider-accounts.d.ts +1 -1
- package/dist/runtime/shared/provider-registry.d.ts +19 -220
- package/dist/runtime/shared/provider-registry.js +25 -81
- package/dist/runtime/vue/providers-panel-model.d.ts +4 -4
- package/dist/runtime/{server → workspaces}/harness-settings.d.ts +3 -3
- package/dist/runtime/workspaces/index.d.ts +16 -0
- package/dist/runtime/workspaces/index.js +5 -0
- package/dist/runtime/workspaces/language-model.d.ts +45 -0
- package/dist/runtime/workspaces/language-model.js +26 -0
- package/dist/runtime/workspaces/model-endpoint.d.ts +38 -0
- package/dist/runtime/workspaces/model-endpoint.js +54 -0
- package/dist/runtime/workspaces/provider-settings.d.ts +35 -0
- package/dist/runtime/{server → workspaces}/provider-settings.js +0 -31
- package/dist/runtime/{server → workspaces}/settings-defaults.d.ts +1 -1
- package/dist/runtime/{shared → workspaces}/settings-types.d.ts +4 -4
- package/package.json +11 -2
- package/src/runtime/ai-sdk/index.ts +88 -47
- package/src/runtime/components/ForgeProvidersPanel.vue +2 -2
- package/src/runtime/editor/index.ts +158 -63
- package/src/runtime/model/selector.ts +31 -0
- package/src/runtime/server/credential-store.ts +20 -10
- package/src/runtime/server/env-layers.ts +4 -4
- package/src/runtime/server/index.ts +7 -8
- package/src/runtime/server/provider-discovery.ts +114 -48
- package/src/runtime/server/provider-layers.ts +6 -6
- package/src/runtime/server/provider-service.ts +2 -2
- package/src/runtime/server/provider-write.ts +3 -3
- package/src/runtime/server/voice-provider.ts +110 -0
- package/src/runtime/shared/data-zone.ts +40 -33
- package/src/runtime/shared/harness-providers.ts +7 -7
- package/src/runtime/shared/provider-accounts.ts +1 -1
- package/src/runtime/shared/provider-registry.ts +82 -314
- package/src/runtime/vue/providers-panel-model.ts +5 -5
- package/src/runtime/vue/useProvidersPanel.ts +5 -2
- package/src/runtime/{server → workspaces}/harness-settings.ts +3 -3
- package/src/runtime/workspaces/index.ts +17 -0
- package/src/runtime/workspaces/language-model.ts +82 -0
- package/src/runtime/workspaces/model-endpoint.ts +100 -0
- package/src/runtime/workspaces/provider-settings.ts +96 -0
- package/src/runtime/{server → workspaces}/settings-defaults.ts +1 -1
- package/src/runtime/{shared → workspaces}/settings-types.ts +4 -4
- package/dist/runtime/server/provider-settings.d.ts +0 -78
- package/src/runtime/server/provider-settings.ts +0 -192
- /package/dist/runtime/{server → workspaces}/harness-settings.js +0 -0
- /package/dist/runtime/{server → workspaces}/settings-defaults.js +0 -0
- /package/dist/runtime/{shared → workspaces}/settings-types.js +0 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a running `skaile serve --model-endpoint` can be reached: the
|
|
3
|
+
* OpenAI-compatible URL and its bearer token.
|
|
4
|
+
*
|
|
5
|
+
* Read-only: the runner writes, owns and removes `.skaile/model-endpoint.json`;
|
|
6
|
+
* this module only reads what the host points it at. No directory climbing, no
|
|
7
|
+
* working-directory lookup, and the token never appears in an error.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFile } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { isPlainObject } from "../shared/guards";
|
|
13
|
+
|
|
14
|
+
/** Env var names the runner hands its endpoint URL and token through. */
|
|
15
|
+
export const WORKSPACE_MODEL_ENDPOINT_URL_ENV = "SKAILE_MODEL_ENDPOINT_URL";
|
|
16
|
+
export const WORKSPACE_MODEL_ENDPOINT_TOKEN_ENV = "SKAILE_MODEL_ENDPOINT_TOKEN";
|
|
17
|
+
|
|
18
|
+
/** Address of a Workspaces model endpoint. `token` is a secret. */
|
|
19
|
+
export interface WorkspaceModelEndpoint {
|
|
20
|
+
/** `http(s)://<host>:<port>/v1`. */
|
|
21
|
+
url: string;
|
|
22
|
+
/** Bearer token; never log it. */
|
|
23
|
+
token: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Options for {@link readWorkspaceModelEndpoint}. */
|
|
27
|
+
export interface ReadWorkspaceModelEndpointOptions {
|
|
28
|
+
/** Environment snapshot the URL/token pair is read from. */
|
|
29
|
+
env: Record<string, string | undefined>;
|
|
30
|
+
/** Project whose `.skaile/model-endpoint.json` is read when the env pair is absent. */
|
|
31
|
+
projectDir?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const PREFIX = "Skaile model endpoint configuration";
|
|
35
|
+
|
|
36
|
+
function isHttpUrl(value: string): boolean {
|
|
37
|
+
try {
|
|
38
|
+
const { protocol } = new URL(value);
|
|
39
|
+
return protocol === "http:" || protocol === "https:";
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The endpoint address, or `null` when none is configured.
|
|
47
|
+
*
|
|
48
|
+
* 1. `SKAILE_MODEL_ENDPOINT_URL` + `SKAILE_MODEL_ENDPOINT_TOKEN` in `env` win.
|
|
49
|
+
* Both must be set and the URL must be HTTP(S); one without the other is a
|
|
50
|
+
* configuration error, not an absence.
|
|
51
|
+
* 2. Otherwise, only when `projectDir` is given, `<projectDir>/.skaile/model-endpoint.json`
|
|
52
|
+
* (`{ url, token, pid }`). A missing file is `null`.
|
|
53
|
+
*
|
|
54
|
+
* @throws Error a malformed or incomplete configuration; the message names the
|
|
55
|
+
* source and the problem, never the token.
|
|
56
|
+
*/
|
|
57
|
+
export async function readWorkspaceModelEndpoint(
|
|
58
|
+
options: ReadWorkspaceModelEndpointOptions,
|
|
59
|
+
): Promise<WorkspaceModelEndpoint | null> {
|
|
60
|
+
const envUrl = options.env[WORKSPACE_MODEL_ENDPOINT_URL_ENV]?.trim() ?? "";
|
|
61
|
+
const envToken = options.env[WORKSPACE_MODEL_ENDPOINT_TOKEN_ENV]?.trim() ?? "";
|
|
62
|
+
if (envUrl || envToken) {
|
|
63
|
+
if (!envUrl || !envToken) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`${PREFIX}: ${WORKSPACE_MODEL_ENDPOINT_URL_ENV} and ${WORKSPACE_MODEL_ENDPOINT_TOKEN_ENV} must be set together`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (!isHttpUrl(envUrl)) {
|
|
69
|
+
throw new Error(`${PREFIX}: ${WORKSPACE_MODEL_ENDPOINT_URL_ENV} is not an http(s) URL`);
|
|
70
|
+
}
|
|
71
|
+
return { url: envUrl, token: envToken };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (options.projectDir === undefined) return null;
|
|
75
|
+
const file = join(options.projectDir, ".skaile", "model-endpoint.json");
|
|
76
|
+
let text: string;
|
|
77
|
+
try {
|
|
78
|
+
text = await readFile(file, "utf8");
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
81
|
+
throw new Error(`${PREFIX}: ${file} could not be read`);
|
|
82
|
+
}
|
|
83
|
+
let parsed: unknown;
|
|
84
|
+
try {
|
|
85
|
+
parsed = JSON.parse(text);
|
|
86
|
+
} catch {
|
|
87
|
+
// The parser's message can quote the file's content, i.e. the token.
|
|
88
|
+
throw new Error(`${PREFIX}: ${file} is not valid JSON`);
|
|
89
|
+
}
|
|
90
|
+
const record = isPlainObject(parsed) ? parsed : {};
|
|
91
|
+
const url = typeof record.url === "string" ? record.url.trim() : "";
|
|
92
|
+
const token = typeof record.token === "string" ? record.token.trim() : "";
|
|
93
|
+
if (!url || !token) {
|
|
94
|
+
throw new Error(`${PREFIX}: ${file} needs a non-empty "url" and "token"`);
|
|
95
|
+
}
|
|
96
|
+
if (!isHttpUrl(url)) {
|
|
97
|
+
throw new Error(`${PREFIX}: ${file} "url" is not an http(s) URL`);
|
|
98
|
+
}
|
|
99
|
+
return { url, token };
|
|
100
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The legacy `settings.json` migration of a Workspaces host.
|
|
3
|
+
*
|
|
4
|
+
* A `settings.json` written before the 5.0.0 registry migration holds
|
|
5
|
+
* `provider` + a free-text `model`, and an `apiKeys` map of secrets. On first
|
|
6
|
+
* load the pair collapses into one `"<providerId>/<modelId>"` selector, and the
|
|
7
|
+
* `apiKeys` map is dropped with a warning — a plaintext key is never
|
|
8
|
+
* auto-written anywhere; the user moves it into `.env`.
|
|
9
|
+
*
|
|
10
|
+
* Voice selection over the same settings fields is generic:
|
|
11
|
+
* `resolveVoiceProvider` in `@skaile/forge-settings/server`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
|
|
16
|
+
/** What {@link migrateLegacyProviderSettings} did to one settings file. */
|
|
17
|
+
export interface LegacySettingsMigration {
|
|
18
|
+
/** The file that was inspected. */
|
|
19
|
+
path: string;
|
|
20
|
+
/** Whether the file was rewritten. */
|
|
21
|
+
changed: boolean;
|
|
22
|
+
/** The selector `provider` + `model` collapsed into, when that happened. */
|
|
23
|
+
model?: string;
|
|
24
|
+
/** Everything dropped or left untouched, and why. Secret-free. */
|
|
25
|
+
warnings: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Migrate one legacy `settings.json` in place. Idempotent: a file with no
|
|
30
|
+
* legacy `provider` and no `apiKeys` is left byte-identical.
|
|
31
|
+
*
|
|
32
|
+
* An `apiKeys` map is removed from the file and never stored anywhere else:
|
|
33
|
+
* the registry holds references only, and a plaintext key has no safe
|
|
34
|
+
* automatic destination. Each non-empty entry is reported through `warnings`
|
|
35
|
+
* (without the key) so the user can move it into `.env`.
|
|
36
|
+
*
|
|
37
|
+
* @param settingsPath - Absolute path to a `settings.json`.
|
|
38
|
+
*/
|
|
39
|
+
export function migrateLegacyProviderSettings(settingsPath: string): LegacySettingsMigration {
|
|
40
|
+
const result: LegacySettingsMigration = {
|
|
41
|
+
path: settingsPath,
|
|
42
|
+
changed: false,
|
|
43
|
+
warnings: [],
|
|
44
|
+
};
|
|
45
|
+
if (!existsSync(settingsPath)) return result;
|
|
46
|
+
|
|
47
|
+
let settings: Record<string, unknown>;
|
|
48
|
+
try {
|
|
49
|
+
settings = JSON.parse(readFileSync(settingsPath, "utf8")) as Record<string, unknown>;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
result.warnings.push(`${settingsPath} is not valid JSON (${(err as Error).message})`);
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
if (typeof settings !== "object" || settings === null || Array.isArray(settings)) {
|
|
55
|
+
result.warnings.push(`${settingsPath} does not contain a settings object`);
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── provider + free-text model → one selector ───────────────────────────
|
|
60
|
+
const legacyProvider = typeof settings.provider === "string" ? settings.provider.trim() : "";
|
|
61
|
+
if (legacyProvider) {
|
|
62
|
+
const model = typeof settings.model === "string" ? settings.model.trim() : "";
|
|
63
|
+
if (model && !model.includes("/")) {
|
|
64
|
+
settings.model = `${legacyProvider}/${model}`;
|
|
65
|
+
result.model = settings.model as string;
|
|
66
|
+
} else if (!model) {
|
|
67
|
+
result.warnings.push(
|
|
68
|
+
`provider '${legacyProvider}' had no model to namespace — the session now resolves ` +
|
|
69
|
+
"its model through the registry's own order",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
delete settings.provider;
|
|
73
|
+
result.changed = true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── apiKeys map → dropped, never stored ─────────────────────────────────
|
|
77
|
+
if ("apiKeys" in settings) {
|
|
78
|
+
const apiKeys = settings.apiKeys;
|
|
79
|
+
const hasSecret =
|
|
80
|
+
typeof apiKeys === "object" &&
|
|
81
|
+
apiKeys !== null &&
|
|
82
|
+
Object.values(apiKeys).some((value) => typeof value === "string" && value !== "");
|
|
83
|
+
if (hasSecret) {
|
|
84
|
+
result.warnings.push(
|
|
85
|
+
"apiKeys found in settings: move each key into .env and reference it from providers.yml (skaile providers add)",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
delete settings.apiKeys;
|
|
89
|
+
result.changed = true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (result.changed) {
|
|
93
|
+
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { SETTINGS_DEFAULTS as CORE_DEFAULTS } from "@skaile/workspaces/sdk/core";
|
|
10
|
-
import type { SkaileSettings } from "
|
|
10
|
+
import type { SkaileSettings } from "./settings-types";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* App-level defaults merged on top of core defaults.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The settings document type forge apps share.
|
|
2
|
+
* The settings document type forge apps on Skaile Workspaces share.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* It extends the Workspaces `settings.json` type, so it lives in the explicit
|
|
5
|
+
* `@skaile/forge-settings/workspaces` entry: the generic entries never see it.
|
|
6
|
+
* A leaf module: it imports types only.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { SkaileSettings as CoreSettings } from "@skaile/workspaces/sdk/core";
|
|
@@ -1,78 +0,0 @@
|
|
|
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;
|
|
@@ -1,192 +0,0 @@
|
|
|
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
|
-
|
|
18
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
|
-
import type {
|
|
20
|
-
AIProviderVoiceEntry,
|
|
21
|
-
AIProviderVoiceKind,
|
|
22
|
-
AIProviderVoiceParams,
|
|
23
|
-
AIProviderRegistryView,
|
|
24
|
-
} from "../shared/provider-registry";
|
|
25
|
-
import {
|
|
26
|
-
entriesOfKind,
|
|
27
|
-
selectVoiceEntryId,
|
|
28
|
-
VOICE_OVERRIDE_PARAM,
|
|
29
|
-
} from "../shared/provider-registry";
|
|
30
|
-
import type { SkaileSettings } from "../shared/settings-types";
|
|
31
|
-
|
|
32
|
-
/** What {@link migrateLegacyProviderSettings} did to one settings file. */
|
|
33
|
-
export interface LegacySettingsMigration {
|
|
34
|
-
/** The file that was inspected. */
|
|
35
|
-
path: string;
|
|
36
|
-
/** Whether the file was rewritten. */
|
|
37
|
-
changed: boolean;
|
|
38
|
-
/** The selector `provider` + `model` collapsed into, when that happened. */
|
|
39
|
-
model?: string;
|
|
40
|
-
/** Everything dropped or left untouched, and why. Secret-free. */
|
|
41
|
-
warnings: string[];
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Migrate one legacy `settings.json` in place. Idempotent: a file with no
|
|
46
|
-
* legacy `provider` and no `apiKeys` is left byte-identical.
|
|
47
|
-
*
|
|
48
|
-
* An `apiKeys` map is removed from the file and never stored anywhere else:
|
|
49
|
-
* the registry holds references only, and a plaintext key has no safe
|
|
50
|
-
* automatic destination. Each non-empty entry is reported through `warnings`
|
|
51
|
-
* (without the key) so the user can move it into `.env`.
|
|
52
|
-
*
|
|
53
|
-
* @param settingsPath - Absolute path to a `settings.json`.
|
|
54
|
-
*/
|
|
55
|
-
export function migrateLegacyProviderSettings(settingsPath: string): LegacySettingsMigration {
|
|
56
|
-
const result: LegacySettingsMigration = {
|
|
57
|
-
path: settingsPath,
|
|
58
|
-
changed: false,
|
|
59
|
-
warnings: [],
|
|
60
|
-
};
|
|
61
|
-
if (!existsSync(settingsPath)) return result;
|
|
62
|
-
|
|
63
|
-
let settings: Record<string, unknown>;
|
|
64
|
-
try {
|
|
65
|
-
settings = JSON.parse(readFileSync(settingsPath, "utf8")) as Record<string, unknown>;
|
|
66
|
-
} catch (err) {
|
|
67
|
-
result.warnings.push(`${settingsPath} is not valid JSON (${(err as Error).message})`);
|
|
68
|
-
return result;
|
|
69
|
-
}
|
|
70
|
-
if (typeof settings !== "object" || settings === null || Array.isArray(settings)) {
|
|
71
|
-
result.warnings.push(`${settingsPath} does not contain a settings object`);
|
|
72
|
-
return result;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// ── provider + free-text model → one selector ───────────────────────────
|
|
76
|
-
const legacyProvider = typeof settings.provider === "string" ? settings.provider.trim() : "";
|
|
77
|
-
if (legacyProvider) {
|
|
78
|
-
const model = typeof settings.model === "string" ? settings.model.trim() : "";
|
|
79
|
-
if (model && !model.includes("/")) {
|
|
80
|
-
settings.model = `${legacyProvider}/${model}`;
|
|
81
|
-
result.model = settings.model as string;
|
|
82
|
-
} else if (!model) {
|
|
83
|
-
result.warnings.push(
|
|
84
|
-
`provider '${legacyProvider}' had no model to namespace — the session now resolves ` +
|
|
85
|
-
"its model through the registry's own order",
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
delete settings.provider;
|
|
89
|
-
result.changed = true;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// ── apiKeys map → dropped, never stored ─────────────────────────────────
|
|
93
|
-
if ("apiKeys" in settings) {
|
|
94
|
-
const apiKeys = settings.apiKeys;
|
|
95
|
-
const hasSecret =
|
|
96
|
-
typeof apiKeys === "object" &&
|
|
97
|
-
apiKeys !== null &&
|
|
98
|
-
Object.values(apiKeys).some((value) => typeof value === "string" && value !== "");
|
|
99
|
-
if (hasSecret) {
|
|
100
|
-
result.warnings.push(
|
|
101
|
-
"apiKeys found in settings: move each key into .env and reference it from providers.yml (skaile providers add)",
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
delete settings.apiKeys;
|
|
105
|
-
result.changed = true;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (result.changed) {
|
|
109
|
-
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
110
|
-
}
|
|
111
|
-
return result;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// ── Voice selection ────────────────────────────────────────────────────────
|
|
115
|
-
|
|
116
|
-
/** Settings fields that name a voice entry and override its parameters. */
|
|
117
|
-
export type VoiceSettings = Pick<
|
|
118
|
-
SkaileSettings,
|
|
119
|
-
"sttProvider" | "ttsProvider" | "sttLanguage" | "ttsVoice"
|
|
120
|
-
>;
|
|
121
|
-
|
|
122
|
-
/** The `kind: stt|tts` entry a request runs against, with its parameters resolved. */
|
|
123
|
-
export interface VoiceProviderSelection {
|
|
124
|
-
/** Registry id of the entry. */
|
|
125
|
-
providerId: string;
|
|
126
|
-
entry: AIProviderVoiceEntry;
|
|
127
|
-
/** Endpoint from the entry; absent when the entry inherits its preset's default. */
|
|
128
|
-
baseUrl?: string;
|
|
129
|
-
/**
|
|
130
|
-
* The entry's `params` with overrides applied — Deepgram `{model, language}`,
|
|
131
|
-
* ElevenLabs `{voice_id, output_format}`. This is the only place a forge app
|
|
132
|
-
* should read voice defaults from.
|
|
133
|
-
*/
|
|
134
|
-
params: AIProviderVoiceParams;
|
|
135
|
-
/** True when settings named no usable entry and registry order decided. */
|
|
136
|
-
fallback: boolean;
|
|
137
|
-
/** Non-fatal notes, e.g. settings naming an entry the registry does not have. */
|
|
138
|
-
warnings: string[];
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Resolve which registry entry serves speech-to-text or text-to-speech.
|
|
143
|
-
*
|
|
144
|
-
* Selection is `settings.sttProvider`/`settings.ttsProvider`, falling back to
|
|
145
|
-
* the first enabled entry of that kind in registry order when the setting is
|
|
146
|
-
* absent or names an entry that is gone — a missing setting must not silence an
|
|
147
|
-
* app that has a perfectly good entry configured.
|
|
148
|
-
*
|
|
149
|
-
* Parameter precedence, lowest first: the entry's `params`, then the settings
|
|
150
|
-
* overrides (`sttLanguage` → `language`, `ttsVoice` → `voice_id`), then
|
|
151
|
-
* `overrides` from the request. The per-request override is the contract
|
|
152
|
-
* forge-concept already honoured and the other apps silently dropped.
|
|
153
|
-
*
|
|
154
|
-
* @returns The selection, or `undefined` when the registry has no enabled entry
|
|
155
|
-
* of that kind.
|
|
156
|
-
*/
|
|
157
|
-
export function resolveVoiceProvider(
|
|
158
|
-
registry: AIProviderRegistryView,
|
|
159
|
-
kind: AIProviderVoiceKind,
|
|
160
|
-
settings: VoiceSettings = {},
|
|
161
|
-
overrides: AIProviderVoiceParams = {},
|
|
162
|
-
): VoiceProviderSelection | undefined {
|
|
163
|
-
const candidates = entriesOfKind(registry, kind);
|
|
164
|
-
if (candidates.length === 0) return undefined;
|
|
165
|
-
|
|
166
|
-
const warnings: string[] = [];
|
|
167
|
-
const wanted = (kind === "stt" ? settings.sttProvider : settings.ttsProvider)?.trim();
|
|
168
|
-
const ids = candidates.map(([id]) => id);
|
|
169
|
-
// `candidates` is non-empty, so the shared rule always names one of these ids.
|
|
170
|
-
const providerId = selectVoiceEntryId(ids, wanted) as string;
|
|
171
|
-
const entry = candidates[ids.indexOf(providerId)][1];
|
|
172
|
-
if (wanted && wanted !== providerId) {
|
|
173
|
-
warnings.push(
|
|
174
|
-
`settings name '${wanted}' for ${kind}, which is not an enabled ${kind} entry — ` +
|
|
175
|
-
`using '${providerId}' instead`,
|
|
176
|
-
);
|
|
177
|
-
}
|
|
178
|
-
const fallback = wanted !== providerId;
|
|
179
|
-
|
|
180
|
-
const settingsOverride: AIProviderVoiceParams = {};
|
|
181
|
-
const override = (kind === "stt" ? settings.sttLanguage : settings.ttsVoice)?.trim();
|
|
182
|
-
if (override) settingsOverride[VOICE_OVERRIDE_PARAM[kind]] = override;
|
|
183
|
-
|
|
184
|
-
return {
|
|
185
|
-
providerId,
|
|
186
|
-
entry,
|
|
187
|
-
...(entry.base_url ? { baseUrl: entry.base_url } : {}),
|
|
188
|
-
params: { ...entry.params, ...settingsOverride, ...overrides },
|
|
189
|
-
fallback,
|
|
190
|
-
warnings,
|
|
191
|
-
};
|
|
192
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|