@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.
Files changed (75) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +34 -11
  3. package/dist/module.json +1 -1
  4. package/dist/runtime/ai-sdk/index.d.ts +33 -26
  5. package/dist/runtime/ai-sdk/index.js +34 -16
  6. package/dist/runtime/components/ForgeProvidersPanel.d.vue.ts +2 -2
  7. package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +2 -2
  8. package/dist/runtime/editor/index.d.ts +55 -16
  9. package/dist/runtime/editor/index.js +82 -40
  10. package/dist/runtime/model/selector.d.ts +16 -0
  11. package/dist/runtime/model/selector.js +12 -0
  12. package/dist/runtime/server/credential-store.d.ts +16 -4
  13. package/dist/runtime/server/credential-store.js +3 -4
  14. package/dist/runtime/server/env-layers.d.ts +1 -1
  15. package/dist/runtime/server/env-layers.js +3 -3
  16. package/dist/runtime/server/index.d.ts +7 -8
  17. package/dist/runtime/server/index.js +1 -3
  18. package/dist/runtime/server/provider-discovery.d.ts +28 -8
  19. package/dist/runtime/server/provider-discovery.js +57 -33
  20. package/dist/runtime/server/provider-layers.d.ts +1 -1
  21. package/dist/runtime/server/provider-layers.js +3 -5
  22. package/dist/runtime/server/provider-service.d.ts +2 -2
  23. package/dist/runtime/server/provider-write.d.ts +1 -1
  24. package/dist/runtime/server/provider-write.js +3 -3
  25. package/dist/runtime/server/voice-provider.d.ts +61 -0
  26. package/dist/runtime/server/voice-provider.js +31 -0
  27. package/dist/runtime/shared/data-zone.d.ts +28 -26
  28. package/dist/runtime/shared/data-zone.js +4 -4
  29. package/dist/runtime/shared/harness-providers.d.ts +7 -7
  30. package/dist/runtime/shared/provider-accounts.d.ts +1 -1
  31. package/dist/runtime/shared/provider-registry.d.ts +19 -220
  32. package/dist/runtime/shared/provider-registry.js +25 -81
  33. package/dist/runtime/vue/providers-panel-model.d.ts +4 -4
  34. package/dist/runtime/{server → workspaces}/harness-settings.d.ts +3 -3
  35. package/dist/runtime/workspaces/index.d.ts +16 -0
  36. package/dist/runtime/workspaces/index.js +5 -0
  37. package/dist/runtime/workspaces/language-model.d.ts +45 -0
  38. package/dist/runtime/workspaces/language-model.js +26 -0
  39. package/dist/runtime/workspaces/model-endpoint.d.ts +38 -0
  40. package/dist/runtime/workspaces/model-endpoint.js +54 -0
  41. package/dist/runtime/workspaces/provider-settings.d.ts +35 -0
  42. package/dist/runtime/{server → workspaces}/provider-settings.js +0 -31
  43. package/dist/runtime/{server → workspaces}/settings-defaults.d.ts +1 -1
  44. package/dist/runtime/{shared → workspaces}/settings-types.d.ts +4 -4
  45. package/package.json +11 -2
  46. package/src/runtime/ai-sdk/index.ts +88 -47
  47. package/src/runtime/components/ForgeProvidersPanel.vue +2 -2
  48. package/src/runtime/editor/index.ts +158 -63
  49. package/src/runtime/model/selector.ts +31 -0
  50. package/src/runtime/server/credential-store.ts +20 -10
  51. package/src/runtime/server/env-layers.ts +4 -4
  52. package/src/runtime/server/index.ts +7 -8
  53. package/src/runtime/server/provider-discovery.ts +114 -48
  54. package/src/runtime/server/provider-layers.ts +6 -6
  55. package/src/runtime/server/provider-service.ts +2 -2
  56. package/src/runtime/server/provider-write.ts +3 -3
  57. package/src/runtime/server/voice-provider.ts +110 -0
  58. package/src/runtime/shared/data-zone.ts +40 -33
  59. package/src/runtime/shared/harness-providers.ts +7 -7
  60. package/src/runtime/shared/provider-accounts.ts +1 -1
  61. package/src/runtime/shared/provider-registry.ts +82 -314
  62. package/src/runtime/vue/providers-panel-model.ts +5 -5
  63. package/src/runtime/vue/useProvidersPanel.ts +5 -2
  64. package/src/runtime/{server → workspaces}/harness-settings.ts +3 -3
  65. package/src/runtime/workspaces/index.ts +17 -0
  66. package/src/runtime/workspaces/language-model.ts +82 -0
  67. package/src/runtime/workspaces/model-endpoint.ts +100 -0
  68. package/src/runtime/workspaces/provider-settings.ts +96 -0
  69. package/src/runtime/{server → workspaces}/settings-defaults.ts +1 -1
  70. package/src/runtime/{shared → workspaces}/settings-types.ts +4 -4
  71. package/dist/runtime/server/provider-settings.d.ts +0 -78
  72. package/src/runtime/server/provider-settings.ts +0 -192
  73. /package/dist/runtime/{server → workspaces}/harness-settings.js +0 -0
  74. /package/dist/runtime/{server → workspaces}/settings-defaults.js +0 -0
  75. /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: only `llm` entries on the
8
- * `openai-completions` surface are offered, because the editor's transport is a
9
- * chat-completions client (`@ai-sdk/openai-compatible`). A profile's id is the
10
- * registry selector `<providerId>/<modelId>`, which `languageModel` receives
11
- * back as `{ preset: id }`.
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). `@ai-sdk/openai-compatible` builds the model.
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 { ModelResolutionError, assertEntryAllowed } from "../model/errors";
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 { resolveEntryCredential } from "../server/credential-store";
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
- /** The editor can only speak chat completions. */
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 chat-completions entries (with an endpoint) of `registry`, by id. */
48
- function editorEntries(registry: AIProviderRegistryView): Map<string, AIProviderLlmEntry> {
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
- if (entry.surface === EDITOR_SURFACE && entry.base_url) entries.set(id, entry);
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 registry's chat-completions models as editor model profiles, in registry
58
- * order. With a policy, entries it refuses are left out.
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
- const entries = editorEntries(registry);
65
- const allowed = (providerId: string) => {
66
- const entry = entries.get(providerId);
67
- return entry !== undefined && !entryPolicyRefusal(entry, options.policy);
68
- };
69
- return listAIProviderModels(registry)
70
- .filter((model) => allowed(model.providerId))
71
- .map((model) => ({
72
- id: model.id,
73
- label: `${model.providerId} · ${model.modelId}`,
74
- endpoint: entries.get(model.providerId)!.base_url!,
75
- model: model.providerModelId,
76
- source: "preset" as const,
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 serve it (a browser-defined `{ endpoint, model, apiKey }` reference is the
83
- * editor's own to resolve).
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} `policy_refused` when the policy refuses the
86
- * entry (checked before its credential is read); `unresolved` when its
87
- * credential reference does not resolve. Neither message carries a secret.
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 model = listAIProviderModels(registry).find((candidate) => candidate.id === ref.preset);
97
- const entry = model && editorEntries(registry).get(model.providerId);
98
- if (!model || !entry) return null;
99
- assertEntryAllowed(entry, model.providerId, options.policy);
100
- const credential = resolveEntryCredential(entry, {
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
- if (credential.error && !credential.value) {
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 editorLanguageModel(await options.registry(), await options.env(), ref, options);
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
- const profiles = editorModelProfiles(await options.registry(), options);
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, join } from "node:path";
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` or `<skaile home>/forge-credentials.json`. */
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
- * Lives beside the user's `settings.json` so it follows `$SKAILE_HOME`, and is
196
- * overridable per app with `FORGE_CREDENTIAL_STORE`.
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
- return join(dirname(globalSettingsPath()), STORE_FILENAME);
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/workspaces/sdk/bridge";
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,9 +1,11 @@
1
1
  /**
2
- * `@skaile/forge-settings/server` — provider and settings operations for a
3
- * host server: the registry vocabulary and layer editing, the credential
4
- * store, env layers, harness settings, model discovery.
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 and no agent session code. Storage,
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
 
@@ -13,15 +15,12 @@ export * from "../shared/harnesses";
13
15
  export * from "../shared/model-roles";
14
16
  export * from "../shared/provider-accounts";
15
17
  export * from "../shared/provider-registry";
16
- export type { DomainConfig, SkaileSettings } from "../shared/settings-types";
17
18
  export * from "./credential-policy";
18
19
  export * from "./credential-store";
19
20
  export * from "./env";
20
21
  export * from "./env-layers";
21
- export * from "./harness-settings";
22
22
  export * from "./provider-discovery";
23
23
  export * from "./provider-layers";
24
24
  export * from "./provider-service";
25
- export * from "./provider-settings";
26
25
  export * from "./provider-write";
27
- export * from "./settings-defaults";
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 has no key, and `models` is
10
- * the offline catalog of its preset. It is a fallback, never a live answer.
11
- * - `unsupported` — the entry has no catalog preset, so there is nothing to ask;
12
- * a host lets the admin type a model id.
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/workspaces/core";
25
- import { fetchProviderModels, getStaticModels } from "@skaile/workspaces/sdk/bridge";
26
- import type { AIProviderRegistryView } from "../shared/provider-registry";
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 = getStaticModels(entry.preset).map(({ id, name }) => ({ id, name }));
132
- if (!credential.value) {
133
- // Without a key there is nothing to ask with.
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
- const baseUrl = resolveEntryBaseUrl(entry);
149
- const live = await fetchProviderModels(entry.preset, credential.value, {
150
- ...(baseUrl ? { baseUrl } : {}),
151
- env: options.env,
152
- });
153
- if (live.ok && live.models.length > 0) {
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,