@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.
- package/CHANGELOG.md +40 -0
- package/README.md +52 -9
- 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 +13 -0
- package/dist/runtime/components/ForgeProvidersPanel.vue +461 -78
- package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +13 -0
- 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 +9 -8
- package/dist/runtime/server/index.js +3 -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 +26 -2
- package/dist/runtime/server/provider-service.js +16 -1
- package/dist/runtime/server/provider-write.d.ts +26 -10
- package/dist/runtime/server/provider-write.js +75 -7
- 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 +68 -0
- package/dist/runtime/shared/data-zone.js +79 -0
- package/dist/runtime/shared/harness-providers.d.ts +42 -3
- package/dist/runtime/shared/harness-providers.js +31 -8
- package/dist/runtime/shared/harnesses.d.ts +7 -0
- package/dist/runtime/shared/harnesses.js +18 -0
- package/dist/runtime/shared/provider-accounts.d.ts +215 -0
- package/dist/runtime/shared/provider-accounts.js +255 -0
- package/dist/runtime/shared/provider-registry.d.ts +19 -182
- package/dist/runtime/shared/provider-registry.js +25 -65
- package/dist/runtime/vue/index.d.ts +2 -0
- package/dist/runtime/vue/index.js +2 -0
- package/dist/runtime/vue/providers-panel-model.d.ts +98 -5
- package/dist/runtime/vue/providers-panel-model.js +13 -1
- package/dist/runtime/vue/useProvidersPanel.d.ts +66 -5
- package/dist/runtime/vue/useProvidersPanel.js +417 -29
- package/dist/runtime/{server → workspaces}/harness-settings.d.ts +51 -13
- package/dist/runtime/{server → workspaces}/harness-settings.js +93 -9
- 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 +11 -4
- package/package.json +11 -2
- package/src/runtime/ai-sdk/index.ts +88 -47
- package/src/runtime/components/ForgeProvidersPanel.vue +477 -78
- 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 +9 -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 +53 -1
- package/src/runtime/server/provider-write.ts +132 -18
- package/src/runtime/server/voice-provider.ts +110 -0
- package/src/runtime/shared/data-zone.ts +179 -0
- package/src/runtime/shared/harness-providers.ts +78 -7
- package/src/runtime/shared/harnesses.ts +25 -0
- package/src/runtime/shared/provider-accounts.ts +526 -0
- package/src/runtime/shared/provider-registry.ts +82 -256
- package/src/runtime/vue/index.ts +2 -0
- package/src/runtime/vue/providers-panel-model.ts +123 -5
- package/src/runtime/vue/useProvidersPanel.ts +589 -37
- package/src/runtime/{server → workspaces}/harness-settings.ts +166 -22
- 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 +11 -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}/settings-defaults.js +0 -0
- /package/dist/runtime/{shared → workspaces}/settings-types.js +0 -0
|
@@ -4,11 +4,24 @@
|
|
|
4
4
|
*
|
|
5
5
|
* `@skaile/forge-editor`'s AI routes ask the host's `ModelProvider`
|
|
6
6
|
* (`listModels` / `languageModel`) before their own runtime-config presets.
|
|
7
|
-
* This adapter answers from the host's registry
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* This adapter answers from the host's registry and is a thin layer over
|
|
8
|
+
* `@skaile/forge-settings/ai-sdk`: a profile's id is the registry selector
|
|
9
|
+
* `<providerId>/<modelId>`, which `languageModel` receives back as
|
|
10
|
+
* `{ preset: id }` and resolves through the same constructor, credential
|
|
11
|
+
* resolver and policy as every other registry model. Every `llm` entry whose
|
|
12
|
+
* surface the AI SDK constructor speaks (`openai-completions`,
|
|
13
|
+
* `openai-responses`, `anthropic-messages`) and that has an endpoint is
|
|
14
|
+
* offered.
|
|
15
|
+
*
|
|
16
|
+
* Listing combines what the registry declares (`model_map` / `capabilities`)
|
|
17
|
+
* with what an entry's own models list reports, for entries that declare
|
|
18
|
+
* `discovery` or declare no models at all. Declared models work without a
|
|
19
|
+
* models list; a declared identity wins over a discovered one.
|
|
20
|
+
*
|
|
21
|
+
* The registry is authoritative for the selectors it owns: an empty registry
|
|
22
|
+
* lists `[]`, and a `<providerId>/…` selector naming a registry entry that is
|
|
23
|
+
* disabled, refused or unresolvable throws instead of falling through to the
|
|
24
|
+
* editor's presets. Only a selector the registry does not own answers `null`.
|
|
12
25
|
*
|
|
13
26
|
* The registry and the credential environment are supplied by the host on every
|
|
14
27
|
* call, so provider edits apply without a restart and nothing reads the ambient
|
|
@@ -16,104 +29,182 @@
|
|
|
16
29
|
* them to HTTP in `onFailure`.
|
|
17
30
|
*
|
|
18
31
|
* `@skaile/forge-editor` is an optional peer of this entry only, for its
|
|
19
|
-
* `ModelProvider` contract (types).
|
|
32
|
+
* `ModelProvider` contract (types).
|
|
20
33
|
*/
|
|
21
34
|
|
|
22
|
-
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
23
35
|
import type { ModelProfile, ModelRef } from "@skaile/forge-editor/ai";
|
|
24
36
|
import type { ModelProvider } from "@skaile/forge-editor/server";
|
|
25
|
-
import {
|
|
37
|
+
import { resolveEntryBaseUrl } from "@skaile/provider-registry";
|
|
38
|
+
import { AI_SDK_SURFACES, type LanguageModelObject } from "@skaile/provider-registry/ai-sdk";
|
|
39
|
+
import { resolveRegistryLanguageModel } from "../ai-sdk";
|
|
40
|
+
import { ModelResolutionError } from "../model/errors";
|
|
41
|
+
import { credentialStoreOptions, selectorProviderId } from "../model/selector";
|
|
26
42
|
import { type CredentialPolicy, entryPolicyRefusal } from "../server/credential-policy";
|
|
27
|
-
import {
|
|
43
|
+
import { discoverProviderModels } from "../server/provider-discovery";
|
|
28
44
|
import { listAIProviderModels } from "../server/provider-service";
|
|
29
45
|
import {
|
|
30
46
|
type AIProviderLlmEntry,
|
|
31
47
|
type AIProviderRegistryView,
|
|
48
|
+
asMergedRegistry,
|
|
32
49
|
entriesOfKind,
|
|
33
50
|
} from "../shared/provider-registry";
|
|
34
51
|
|
|
35
|
-
export { ModelResolutionError } from "../model/errors";
|
|
36
52
|
export type { ModelResolutionCode } from "../model/errors";
|
|
53
|
+
export { ModelResolutionError } from "../model/errors";
|
|
37
54
|
|
|
38
|
-
/**
|
|
39
|
-
const EDITOR_SURFACE = "openai-completions";
|
|
40
|
-
|
|
41
|
-
/** Policy for an editor model call; see `CredentialPolicy`. */
|
|
55
|
+
/** Policy and credential store for an editor model call; see `CredentialPolicy`. */
|
|
42
56
|
export interface EditorModelPolicyOptions {
|
|
43
57
|
/** Which credential references may be resolved; refused entries are not offered. */
|
|
44
58
|
policy?: CredentialPolicy;
|
|
59
|
+
/** Local encrypted store for `keyring:` references; else `FORGE_CREDENTIAL_STORE` of the env. */
|
|
60
|
+
path?: string;
|
|
61
|
+
/** 64-char hex key of the local store; else `FORGE_CREDENTIAL_KEY` of the env. */
|
|
62
|
+
key?: string;
|
|
45
63
|
}
|
|
46
64
|
|
|
47
|
-
/** The
|
|
48
|
-
function editorEntries(
|
|
65
|
+
/** The entries the AI SDK constructor can serve (surface and endpoint), by id; policy-refused ones left out. */
|
|
66
|
+
function editorEntries(
|
|
67
|
+
registry: AIProviderRegistryView,
|
|
68
|
+
policy: CredentialPolicy | undefined,
|
|
69
|
+
): Map<string, AIProviderLlmEntry> {
|
|
49
70
|
const entries = new Map<string, AIProviderLlmEntry>();
|
|
50
71
|
for (const [id, entry] of entriesOfKind(registry, "llm")) {
|
|
51
|
-
|
|
72
|
+
const surface = entry.surface ?? "openai-completions";
|
|
73
|
+
if (!(AI_SDK_SURFACES as readonly string[]).includes(surface)) continue;
|
|
74
|
+
if (!resolveEntryBaseUrl(entry)) continue;
|
|
75
|
+
if (entryPolicyRefusal(entry, policy)) continue;
|
|
76
|
+
entries.set(id, entry);
|
|
52
77
|
}
|
|
53
78
|
return entries;
|
|
54
79
|
}
|
|
55
80
|
|
|
81
|
+
/** One registry model as an editor profile: its selector `id`, shown as `<providerId> · <name>`. */
|
|
82
|
+
function registryProfile(
|
|
83
|
+
providerId: string,
|
|
84
|
+
entry: AIProviderLlmEntry,
|
|
85
|
+
model: { id: string; name: string; providerModelId: string },
|
|
86
|
+
): ModelProfile {
|
|
87
|
+
return {
|
|
88
|
+
id: model.id,
|
|
89
|
+
label: `${providerId} · ${model.name}`,
|
|
90
|
+
endpoint: resolveEntryBaseUrl(entry)!,
|
|
91
|
+
model: model.providerModelId,
|
|
92
|
+
source: "preset",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The declared models of the served `entries` as profiles, grouped by provider id in registry order. */
|
|
97
|
+
function declaredProfiles(
|
|
98
|
+
registry: AIProviderRegistryView,
|
|
99
|
+
entries: Map<string, AIProviderLlmEntry>,
|
|
100
|
+
): Map<string, ModelProfile[]> {
|
|
101
|
+
const byProvider = new Map<string, ModelProfile[]>();
|
|
102
|
+
for (const model of listAIProviderModels(registry)) {
|
|
103
|
+
const entry = entries.get(model.providerId);
|
|
104
|
+
if (!entry) continue;
|
|
105
|
+
const profile = registryProfile(model.providerId, entry, {
|
|
106
|
+
id: model.id,
|
|
107
|
+
name: model.modelId,
|
|
108
|
+
providerModelId: model.providerModelId,
|
|
109
|
+
});
|
|
110
|
+
const own = byProvider.get(model.providerId);
|
|
111
|
+
if (own) own.push(profile);
|
|
112
|
+
else byProvider.set(model.providerId, [profile]);
|
|
113
|
+
}
|
|
114
|
+
return byProvider;
|
|
115
|
+
}
|
|
116
|
+
|
|
56
117
|
/**
|
|
57
|
-
* The
|
|
58
|
-
* order. With a policy, entries it
|
|
118
|
+
* The models the registry declares (`model_map` / `capabilities`) as editor
|
|
119
|
+
* model profiles, in registry order — no network. With a policy, entries it
|
|
120
|
+
* refuses are left out. {@link createEditorModelProvider} lists these plus the
|
|
121
|
+
* models discovered live.
|
|
59
122
|
*/
|
|
60
123
|
export function editorModelProfiles(
|
|
61
124
|
registry: AIProviderRegistryView,
|
|
62
125
|
options: EditorModelPolicyOptions = {},
|
|
63
126
|
): ModelProfile[] {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
127
|
+
return [...declaredProfiles(registry, editorEntries(registry, options.policy)).values()].flat();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Declared and discovered models as editor profiles, in registry order, one
|
|
132
|
+
* profile per selector.
|
|
133
|
+
*
|
|
134
|
+
* An entry's models list is asked when the entry declares `discovery` (and not
|
|
135
|
+
* `type: none`) or declares no models; it is asked with the credential the
|
|
136
|
+
* policy allows, exactly as inference resolves it. A failed or unsupported
|
|
137
|
+
* list leaves that entry with its declared models. A discovered id that a
|
|
138
|
+
* declared model already names (as selector or as mapped provider id) is
|
|
139
|
+
* dropped: the declared identity wins.
|
|
140
|
+
*/
|
|
141
|
+
export async function listEditorModelProfiles(
|
|
142
|
+
registry: AIProviderRegistryView,
|
|
143
|
+
env: Record<string, string | undefined>,
|
|
144
|
+
options: EditorModelPolicyOptions = {},
|
|
145
|
+
): Promise<ModelProfile[]> {
|
|
146
|
+
const entries = editorEntries(registry, options.policy);
|
|
147
|
+
const declared = declaredProfiles(registry, entries);
|
|
148
|
+
const listed = await Promise.all(
|
|
149
|
+
[...entries].map(async ([providerId, entry]): Promise<ModelProfile[]> => {
|
|
150
|
+
const own = declared.get(providerId) ?? [];
|
|
151
|
+
const listable =
|
|
152
|
+
entry.discovery !== undefined ? entry.discovery.type !== "none" : own.length === 0;
|
|
153
|
+
if (!listable) return own;
|
|
154
|
+
const result = await discoverProviderModels(
|
|
155
|
+
providerId,
|
|
156
|
+
registry,
|
|
157
|
+
credentialStoreOptions(env, options),
|
|
158
|
+
);
|
|
159
|
+
if (result.status !== "live") return own;
|
|
160
|
+
const taken = new Set(
|
|
161
|
+
own.flatMap((profile) => [profile.id, `${providerId}/${profile.model}`]),
|
|
162
|
+
);
|
|
163
|
+
const discovered = result.models.flatMap((model) => {
|
|
164
|
+
const id = `${providerId}/${model.id}`;
|
|
165
|
+
if (taken.has(id)) return [];
|
|
166
|
+
taken.add(id);
|
|
167
|
+
return [
|
|
168
|
+
registryProfile(providerId, entry, { id, name: model.name, providerModelId: model.id }),
|
|
169
|
+
];
|
|
170
|
+
});
|
|
171
|
+
return [...own, ...discovered];
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
return listed.flat();
|
|
78
175
|
}
|
|
79
176
|
|
|
80
177
|
/**
|
|
81
178
|
* The language model of selector `ref.preset`, or `null` when the registry does
|
|
82
|
-
* not
|
|
83
|
-
* editor's own
|
|
179
|
+
* not own it: a browser-defined `{ endpoint, model, apiKey }` reference, or a
|
|
180
|
+
* preset id whose leading segment names no registry entry, is the editor's own
|
|
181
|
+
* to resolve.
|
|
182
|
+
*
|
|
183
|
+
* Delegates to `resolveRegistryLanguageModel` (`@skaile/forge-settings/ai-sdk`),
|
|
184
|
+
* so the selector's model id may be declared or discovered: an id the entry's
|
|
185
|
+
* `model_map` maps is sent as the mapped id, any other is sent verbatim.
|
|
84
186
|
*
|
|
85
|
-
* @throws {ModelResolutionError}
|
|
86
|
-
* entry
|
|
87
|
-
*
|
|
187
|
+
* @throws {ModelResolutionError} for an owned selector: `unknown_selector` when
|
|
188
|
+
* the entry is disabled or not an `llm` entry, `policy_refused` when the
|
|
189
|
+
* policy refuses it (checked before its credential is read), `unresolved`
|
|
190
|
+
* when its credential, endpoint or SDK does not resolve. No message carries a
|
|
191
|
+
* secret.
|
|
88
192
|
*/
|
|
89
|
-
export function editorLanguageModel(
|
|
193
|
+
export async function editorLanguageModel(
|
|
90
194
|
registry: AIProviderRegistryView,
|
|
91
195
|
env: Record<string, string | undefined>,
|
|
92
196
|
ref: ModelRef,
|
|
93
197
|
options: EditorModelPolicyOptions = {},
|
|
94
|
-
) {
|
|
198
|
+
): Promise<LanguageModelObject | null> {
|
|
95
199
|
if (!("preset" in ref)) return null;
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
env,
|
|
102
|
-
...(options.policy ? { policy: options.policy } : {}),
|
|
200
|
+
const merged = asMergedRegistry(registry);
|
|
201
|
+
const head = selectorProviderId(ref.preset);
|
|
202
|
+
if (head === undefined || merged.providers[head] === undefined) return null;
|
|
203
|
+
const resolved = await resolveRegistryLanguageModel(ref.preset, {
|
|
204
|
+
registry: merged,
|
|
205
|
+
...credentialStoreOptions(env, options),
|
|
103
206
|
});
|
|
104
|
-
|
|
105
|
-
throw new ModelResolutionError(
|
|
106
|
-
"unresolved",
|
|
107
|
-
`provider '${model.providerId}': ${credential.error}`,
|
|
108
|
-
{ providerId: model.providerId },
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
return createOpenAICompatible({
|
|
112
|
-
name: model.providerId,
|
|
113
|
-
baseURL: entry.base_url!,
|
|
114
|
-
...(credential.value ? { apiKey: credential.value } : {}),
|
|
115
|
-
...(entry.headers ? { headers: entry.headers } : {}),
|
|
116
|
-
}).chatModel(model.providerModelId);
|
|
207
|
+
return resolved.model;
|
|
117
208
|
}
|
|
118
209
|
|
|
119
210
|
/** Options for {@link createEditorModelProvider}. */
|
|
@@ -131,7 +222,12 @@ export function createEditorModelProvider(options: EditorModelProviderOptions):
|
|
|
131
222
|
return {
|
|
132
223
|
async languageModel(ref) {
|
|
133
224
|
try {
|
|
134
|
-
return
|
|
225
|
+
return await editorLanguageModel(
|
|
226
|
+
await options.registry(),
|
|
227
|
+
await options.env(),
|
|
228
|
+
ref,
|
|
229
|
+
options,
|
|
230
|
+
);
|
|
135
231
|
} catch (error) {
|
|
136
232
|
if (error instanceof ModelResolutionError && options.onFailure)
|
|
137
233
|
throw options.onFailure(error);
|
|
@@ -139,8 +235,7 @@ export function createEditorModelProvider(options: EditorModelProviderOptions):
|
|
|
139
235
|
}
|
|
140
236
|
},
|
|
141
237
|
async listModels() {
|
|
142
|
-
|
|
143
|
-
return profiles.length > 0 ? profiles : null;
|
|
238
|
+
return listEditorModelProfiles(await options.registry(), await options.env(), options);
|
|
144
239
|
},
|
|
145
240
|
};
|
|
146
241
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small shared pieces of registry model resolution: the provider a selector
|
|
3
|
+
* names and the credential-store options a resolution carries.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { CredentialStoreOptions } from "../server/credential-store";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The provider id a `<providerId>/<modelId>` selector names: the segment before
|
|
10
|
+
* the first `/`. `undefined` for an `@role` selector or a bare model id.
|
|
11
|
+
*/
|
|
12
|
+
export function selectorProviderId(selector: string): string | undefined {
|
|
13
|
+
const slash = selector.indexOf("/");
|
|
14
|
+
return !selector.startsWith("@") && slash > 0 ? selector.slice(0, slash) : undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The policy and local store a credential resolution uses. */
|
|
18
|
+
export type CredentialAccessOptions = Pick<CredentialStoreOptions, "policy" | "path" | "key">;
|
|
19
|
+
|
|
20
|
+
/** `env` plus whichever of `policy`, `path` and `key` are set, without `undefined` keys. */
|
|
21
|
+
export function credentialStoreOptions(
|
|
22
|
+
env: Record<string, string | undefined>,
|
|
23
|
+
{ policy, path, key }: CredentialAccessOptions,
|
|
24
|
+
): CredentialStoreOptions & { env: Record<string, string | undefined> } {
|
|
25
|
+
return {
|
|
26
|
+
env,
|
|
27
|
+
...(policy ? { policy } : {}),
|
|
28
|
+
...(path ? { path } : {}),
|
|
29
|
+
...(key ? { key } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -24,13 +24,16 @@
|
|
|
24
24
|
*
|
|
25
25
|
* Blob format: `enc:<base64(iv[16] + tag[16] + ciphertext)>`.
|
|
26
26
|
*
|
|
27
|
+
* Store path: no implicit home. The caller passes `path` or sets
|
|
28
|
+
* `FORGE_CREDENTIAL_STORE` in the environment it hands over; see
|
|
29
|
+
* {@link credentialStorePath}.
|
|
30
|
+
*
|
|
27
31
|
* Key format: `FORGE_CREDENTIAL_KEY` must be a 64-char hex string (32 bytes).
|
|
28
32
|
*/
|
|
29
33
|
|
|
30
34
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
31
35
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
32
|
-
import { dirname
|
|
33
|
-
import { globalSettingsPath } from "@skaile/workspaces/sdk/core";
|
|
36
|
+
import { dirname } from "node:path";
|
|
34
37
|
import type { AIProviderEntry, AIProviderRegistryView } from "../shared/provider-registry";
|
|
35
38
|
import { isEntryEnabled, parseCredentialRef, registryEntries } from "../shared/provider-registry";
|
|
36
39
|
import {
|
|
@@ -41,9 +44,6 @@ import {
|
|
|
41
44
|
|
|
42
45
|
export const CREDENTIAL_PREFIX = "enc:" as const;
|
|
43
46
|
|
|
44
|
-
/** Filename of the local keyring, next to the user's `settings.json`. */
|
|
45
|
-
const STORE_FILENAME = "forge-credentials.json";
|
|
46
|
-
|
|
47
47
|
const ALGORITHM = "aes-256-gcm" as const;
|
|
48
48
|
const IV_LENGTH = 16; // bytes
|
|
49
49
|
const TAG_LENGTH = 16; // bytes
|
|
@@ -162,7 +162,7 @@ export function decryptCredential(ciphertext: string, hexKey: string, ref: strin
|
|
|
162
162
|
export interface CredentialStoreOptions {
|
|
163
163
|
/** 64-char hex key; defaults to `FORGE_CREDENTIAL_KEY` of the environment. */
|
|
164
164
|
key?: string;
|
|
165
|
-
/** Store file; defaults to `FORGE_CREDENTIAL_STORE
|
|
165
|
+
/** Store file; defaults to the environment's `FORGE_CREDENTIAL_STORE`. Required for keyring operations. */
|
|
166
166
|
path?: string;
|
|
167
167
|
/**
|
|
168
168
|
* Environment `env:` references (and the key and store path variables) read
|
|
@@ -189,17 +189,27 @@ interface StoreFile {
|
|
|
189
189
|
credentials: Record<string, string>;
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
/** What a keyring operation throws when neither a path nor `FORGE_CREDENTIAL_STORE` names the store. */
|
|
193
|
+
export const CREDENTIAL_STORE_PATH_REQUIRED =
|
|
194
|
+
"FORGE_CREDENTIAL_STORE or credential store path is required" as const;
|
|
195
|
+
|
|
192
196
|
/**
|
|
193
|
-
* Path of the local keyring file.
|
|
197
|
+
* Path of the local keyring file: `options.path`, else the selected
|
|
198
|
+
* environment's `FORGE_CREDENTIAL_STORE`.
|
|
199
|
+
*
|
|
200
|
+
* There is no implicit home: the host decides where its secrets live. A
|
|
201
|
+
* Workspaces host passes `<skaile home>/forge-credentials.json` (see
|
|
202
|
+
* `@skaile/forge-settings/workspaces`). Only keyring operations ask for the
|
|
203
|
+
* path, so env-only and keyless providers need none.
|
|
194
204
|
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
205
|
+
* @throws Error `FORGE_CREDENTIAL_STORE or credential store path is required`
|
|
206
|
+
* when neither is given.
|
|
197
207
|
*/
|
|
198
208
|
export function credentialStorePath(options: CredentialStoreOptions = {}): string {
|
|
199
209
|
if (options.path) return options.path;
|
|
200
210
|
const fromEnv = envOf(options).FORGE_CREDENTIAL_STORE;
|
|
201
211
|
if (fromEnv) return fromEnv;
|
|
202
|
-
|
|
212
|
+
throw new Error(CREDENTIAL_STORE_PATH_REQUIRED);
|
|
203
213
|
}
|
|
204
214
|
|
|
205
215
|
function requireKey(options: CredentialStoreOptions): string {
|
|
@@ -16,10 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
18
18
|
import { dirname, join } from "node:path";
|
|
19
|
-
import { AI_PROVIDER_PRESETS } from "@skaile/
|
|
20
|
-
import { isPlainObject } from "../shared/guards";
|
|
21
|
-
import type { AIProviderRegistryView } from "../shared/provider-registry";
|
|
22
|
-
import { parseCredentialRef, registryEntries } from "../shared/provider-registry";
|
|
19
|
+
import { AI_PROVIDER_PRESETS } from "@skaile/provider-registry/catalog";
|
|
23
20
|
import {
|
|
24
21
|
ENV_LAYER_ORDER,
|
|
25
22
|
type EnvLayerId,
|
|
@@ -29,6 +26,9 @@ import {
|
|
|
29
26
|
type EnvVarView,
|
|
30
27
|
validateEnvVarName,
|
|
31
28
|
} from "../shared/env-vars";
|
|
29
|
+
import { isPlainObject } from "../shared/guards";
|
|
30
|
+
import type { AIProviderRegistryView } from "../shared/provider-registry";
|
|
31
|
+
import { parseCredentialRef, registryEntries } from "../shared/provider-registry";
|
|
32
32
|
|
|
33
33
|
export * from "../shared/env-vars";
|
|
34
34
|
|
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@skaile/forge-settings/server` — provider
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* `@skaile/forge-settings/server` — provider operations for a host server: the
|
|
3
|
+
* registry vocabulary and layer editing, the credential store, env layers,
|
|
4
|
+
* model discovery and voice-entry selection.
|
|
5
5
|
*
|
|
6
|
-
* Imports no Vue, no editor, no AI SDK
|
|
6
|
+
* Imports no Vue, no editor, no AI SDK, no agent session code and no Skaile
|
|
7
|
+
* Workspaces: the Workspaces settings document, harness settings and project
|
|
8
|
+
* registry loading live in `@skaile/forge-settings/workspaces`. Storage,
|
|
7
9
|
* authentication and workspace permissions stay with the host.
|
|
8
10
|
*/
|
|
9
11
|
|
|
12
|
+
export * from "../shared/data-zone";
|
|
10
13
|
export * from "../shared/harness-providers";
|
|
11
14
|
export * from "../shared/harnesses";
|
|
12
15
|
export * from "../shared/model-roles";
|
|
16
|
+
export * from "../shared/provider-accounts";
|
|
13
17
|
export * from "../shared/provider-registry";
|
|
14
|
-
export type { DomainConfig, SkaileSettings } from "../shared/settings-types";
|
|
15
18
|
export * from "./credential-policy";
|
|
16
19
|
export * from "./credential-store";
|
|
17
20
|
export * from "./env";
|
|
18
21
|
export * from "./env-layers";
|
|
19
|
-
export * from "./harness-settings";
|
|
20
22
|
export * from "./provider-discovery";
|
|
21
23
|
export * from "./provider-layers";
|
|
22
24
|
export * from "./provider-service";
|
|
23
|
-
export * from "./provider-settings";
|
|
24
25
|
export * from "./provider-write";
|
|
25
|
-
export * from "./
|
|
26
|
+
export * from "./voice-provider";
|
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
* `model_map`; this module asks the provider's REST API. The two are different
|
|
6
6
|
* facts and the result says which one it is:
|
|
7
7
|
*
|
|
8
|
-
* - `live` — the provider answered; `models` is its list.
|
|
9
|
-
* - `static` — the live request failed or the entry
|
|
10
|
-
* the offline catalog of
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* - `live` — the provider answered; `models` is its list (possibly empty).
|
|
9
|
+
* - `static` — the live request failed or the entry speaks no models list, and
|
|
10
|
+
* `models` is the offline catalog of the entry's own preset. It is a
|
|
11
|
+
* labelled fallback, never a live answer and never an inference destination.
|
|
12
|
+
* - `unsupported` — the entry speaks no models list and has no catalog; a host
|
|
13
|
+
* lets the admin type a model id.
|
|
13
14
|
* - `error` — nothing usable. The message is scrubbed.
|
|
14
15
|
*
|
|
15
16
|
* The error text is built here, never forwarded: a remote response body is
|
|
@@ -21,9 +22,15 @@
|
|
|
21
22
|
* explicit `env` only.
|
|
22
23
|
*/
|
|
23
24
|
|
|
24
|
-
import { resolveEntryBaseUrl } from "@skaile/
|
|
25
|
-
import {
|
|
26
|
-
import
|
|
25
|
+
import { resolveEntryBaseUrl } from "@skaile/provider-registry";
|
|
26
|
+
import { entrySurface, getStaticModels } from "@skaile/provider-registry/catalog";
|
|
27
|
+
import {
|
|
28
|
+
type FetchModelsResult,
|
|
29
|
+
fetchEntryModels,
|
|
30
|
+
fetchProviderModels,
|
|
31
|
+
} from "@skaile/provider-registry/discovery";
|
|
32
|
+
import { credentialStoreOptions } from "../model/selector";
|
|
33
|
+
import type { AIProviderLlmEntry, AIProviderRegistryView } from "../shared/provider-registry";
|
|
27
34
|
import { isEntryEnabled, registryEntries } from "../shared/provider-registry";
|
|
28
35
|
import { type CredentialPolicy, entryPolicyRefusal } from "./credential-policy";
|
|
29
36
|
import { resolveEntryCredential } from "./credential-store";
|
|
@@ -55,9 +62,12 @@ export interface DiscoverProviderModelsOptions {
|
|
|
55
62
|
policy?: CredentialPolicy;
|
|
56
63
|
/** Extra strings to remove from error text, besides the secrets this call resolves. */
|
|
57
64
|
redact?: readonly string[];
|
|
58
|
-
/** Local encrypted store for `keyring:` references
|
|
59
|
-
key?: string;
|
|
65
|
+
/** Local encrypted store for `keyring:` references; else `FORGE_CREDENTIAL_STORE` of `env`. */
|
|
60
66
|
path?: string;
|
|
67
|
+
/** 64-char hex key of the local store; else `FORGE_CREDENTIAL_KEY` of `env`. */
|
|
68
|
+
key?: string;
|
|
69
|
+
/** Cancels a live request; the result is then an `error` / `static` one. */
|
|
70
|
+
signal?: AbortSignal;
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
/** Longest error message returned. */
|
|
@@ -83,11 +93,72 @@ export function scrubSecrets(text: string, secrets: readonly (string | undefined
|
|
|
83
93
|
return out.length > MAX_ERROR_LENGTH ? `${out.slice(0, MAX_ERROR_LENGTH)}…` : out;
|
|
84
94
|
}
|
|
85
95
|
|
|
96
|
+
/** Whether `fetchEntryModels` speaks a models-list dialect for `entry`, or why not. */
|
|
97
|
+
function entryListing(entry: AIProviderLlmEntry): { ok: true } | { ok: false; reason: string } {
|
|
98
|
+
const type = entry.discovery?.type;
|
|
99
|
+
if (type === "none") return { ok: false, reason: "this entry declares no model discovery" };
|
|
100
|
+
if (type === "openai-models-list" || type === "anthropic-models-list") return { ok: true };
|
|
101
|
+
if (type !== undefined) return { ok: false, reason: `discovery type '${type}' is not supported` };
|
|
102
|
+
const surface = entrySurface(entry);
|
|
103
|
+
if (
|
|
104
|
+
surface === "openai-completions" ||
|
|
105
|
+
surface === "openai-responses" ||
|
|
106
|
+
surface === "anthropic-messages"
|
|
107
|
+
) {
|
|
108
|
+
return { ok: true };
|
|
109
|
+
}
|
|
110
|
+
return { ok: false, reason: `surface '${surface ?? "unknown"}' has no models list` };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** What a cancelled models-list request reports (the wording `fetchEntryModels` uses). */
|
|
114
|
+
const CANCELLED: FetchModelsResult = {
|
|
115
|
+
ok: false,
|
|
116
|
+
models: [],
|
|
117
|
+
error: "models list request was cancelled",
|
|
118
|
+
source: "api",
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Run `start` unless `signal` already aborted, and settle as cancelled the
|
|
123
|
+
* moment it aborts. `fetchProviderModels` takes no signal: its request then
|
|
124
|
+
* runs on to its own deadline, but nobody waits for it.
|
|
125
|
+
*/
|
|
126
|
+
function untilAborted(
|
|
127
|
+
start: () => Promise<FetchModelsResult>,
|
|
128
|
+
signal: AbortSignal | undefined,
|
|
129
|
+
): Promise<FetchModelsResult> {
|
|
130
|
+
if (!signal) return start();
|
|
131
|
+
if (signal.aborted) return Promise.resolve(CANCELLED);
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const onAbort = () => resolve(CANCELLED);
|
|
134
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
135
|
+
start()
|
|
136
|
+
.finally(() => signal.removeEventListener("abort", onAbort))
|
|
137
|
+
.then(resolve, reject);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
86
141
|
/**
|
|
87
142
|
* Ask one registry entry's provider for its models.
|
|
88
143
|
*
|
|
144
|
+
* Two ways to ask, both with the credential resolved here under the policy:
|
|
145
|
+
*
|
|
146
|
+
* - A catalog-preset entry with a resolved key and no `discovery` block of its
|
|
147
|
+
* own uses the preset's list parser (`fetchProviderModels`), which filters
|
|
148
|
+
* the vendor's list the way that vendor needs.
|
|
149
|
+
* - Every other entry — a custom endpoint, a keyless local server, an entry
|
|
150
|
+
* that declares `discovery` — asks its own `base_url` with its own headers
|
|
151
|
+
* and credential carrier (`fetchEntryModels`), exactly as inference would.
|
|
152
|
+
* No preset and no secret are required: `credential.kind: none` queries
|
|
153
|
+
* without `Authorization`.
|
|
154
|
+
*
|
|
155
|
+
* A successful empty list is `live` and empty. Only a preset entry has a
|
|
156
|
+
* `static` fallback: its own catalog, labelled as such, never another
|
|
157
|
+
* provider and never an inference destination.
|
|
158
|
+
*
|
|
89
159
|
* Never throws for a provider-side failure: that is the `error` / `static`
|
|
90
|
-
* result. A host maps `error` to its own status.
|
|
160
|
+
* result. A host maps `error` to its own status. A refused or unresolvable
|
|
161
|
+
* credential sends no request at all.
|
|
91
162
|
*/
|
|
92
163
|
export async function discoverProviderModels(
|
|
93
164
|
providerId: string,
|
|
@@ -107,50 +178,45 @@ export async function discoverProviderModels(
|
|
|
107
178
|
const refusal = entryPolicyRefusal(entry, options.policy);
|
|
108
179
|
if (refusal) return { status: "error", providerId, error: refusal };
|
|
109
180
|
|
|
110
|
-
const credential = resolveEntryCredential(entry,
|
|
111
|
-
env: options.env,
|
|
112
|
-
...(options.policy ? { policy: options.policy } : {}),
|
|
113
|
-
...(options.key ? { key: options.key } : {}),
|
|
114
|
-
...(options.path ? { path: options.path } : {}),
|
|
115
|
-
});
|
|
181
|
+
const credential = resolveEntryCredential(entry, credentialStoreOptions(options.env, options));
|
|
116
182
|
const secrets = [credential.value, ...(options.redact ?? [])];
|
|
117
183
|
const scrub = (text: string) => scrubSecrets(text, secrets);
|
|
118
|
-
|
|
119
|
-
if (!entry.preset) {
|
|
120
|
-
return {
|
|
121
|
-
status: "unsupported",
|
|
122
|
-
providerId,
|
|
123
|
-
reason:
|
|
124
|
-
"this entry names no catalog preset, so its models cannot be listed; enter a model id",
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
184
|
if (credential.error && !credential.value) {
|
|
128
185
|
return { status: "error", providerId, error: scrub(credential.error) };
|
|
129
186
|
}
|
|
130
187
|
|
|
131
|
-
const staticList =
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
return staticList.length > 0
|
|
135
|
-
? {
|
|
136
|
-
status: "static",
|
|
137
|
-
providerId,
|
|
138
|
-
models: staticList,
|
|
139
|
-
reason: "no credential to query the provider with",
|
|
140
|
-
}
|
|
141
|
-
: {
|
|
142
|
-
status: "unsupported",
|
|
143
|
-
providerId,
|
|
144
|
-
reason: "no credential, and no offline catalog for this preset",
|
|
145
|
-
};
|
|
146
|
-
}
|
|
188
|
+
const staticList = entry.preset
|
|
189
|
+
? getStaticModels(entry.preset).map(({ id, name }) => ({ id, name }))
|
|
190
|
+
: [];
|
|
147
191
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
192
|
+
let live: FetchModelsResult;
|
|
193
|
+
if (entry.preset && !entry.discovery && credential.value) {
|
|
194
|
+
const baseUrl = resolveEntryBaseUrl(entry);
|
|
195
|
+
const apiKey = credential.value;
|
|
196
|
+
live = await untilAborted(
|
|
197
|
+
() =>
|
|
198
|
+
fetchProviderModels(entry.preset!, apiKey, {
|
|
199
|
+
...(baseUrl ? { baseUrl } : {}),
|
|
200
|
+
env: options.env,
|
|
201
|
+
}),
|
|
202
|
+
options.signal,
|
|
203
|
+
);
|
|
204
|
+
} else {
|
|
205
|
+
const listing = entryListing(entry);
|
|
206
|
+
if (!listing.ok) {
|
|
207
|
+
return staticList.length > 0
|
|
208
|
+
? { status: "static", providerId, models: staticList, reason: listing.reason }
|
|
209
|
+
: { status: "unsupported", providerId, reason: `${listing.reason}; enter a model id` };
|
|
210
|
+
}
|
|
211
|
+
// `env: {}`: the only credential this request may carry is the one resolved
|
|
212
|
+
// above under the policy, never a second lookup of the entry's reference.
|
|
213
|
+
live = await fetchEntryModels(entry, {
|
|
214
|
+
env: {},
|
|
215
|
+
...(credential.value !== undefined ? { secret: credential.value } : {}),
|
|
216
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
if (live.ok) {
|
|
154
220
|
return {
|
|
155
221
|
status: "live",
|
|
156
222
|
providerId,
|