@skaile/forge-settings 0.0.0-stage → 1.0.1

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 (89) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +94 -2
  4. package/dist/module.d.mts +17 -0
  5. package/dist/module.json +9 -0
  6. package/dist/module.mjs +23 -0
  7. package/dist/runtime/ai-sdk/index.d.ts +46 -0
  8. package/dist/runtime/ai-sdk/index.js +31 -0
  9. package/dist/runtime/components/ForgeProvidersPanel.d.vue.ts +21 -0
  10. package/dist/runtime/components/ForgeProvidersPanel.vue +435 -0
  11. package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +21 -0
  12. package/dist/runtime/editor/index.d.ts +58 -0
  13. package/dist/runtime/editor/index.js +72 -0
  14. package/dist/runtime/model/errors.d.ts +32 -0
  15. package/dist/runtime/model/errors.js +19 -0
  16. package/dist/runtime/server/credential-policy.d.ts +61 -0
  17. package/dist/runtime/server/credential-policy.js +65 -0
  18. package/dist/runtime/server/credential-store.d.ts +184 -0
  19. package/dist/runtime/server/credential-store.js +234 -0
  20. package/dist/runtime/server/env-layers.d.ts +62 -0
  21. package/dist/runtime/server/env-layers.js +183 -0
  22. package/dist/runtime/server/env.d.ts +30 -0
  23. package/dist/runtime/server/env.js +15 -0
  24. package/dist/runtime/server/harness-settings.d.ts +150 -0
  25. package/dist/runtime/server/harness-settings.js +166 -0
  26. package/dist/runtime/server/index.d.ts +23 -0
  27. package/dist/runtime/server/index.js +14 -0
  28. package/dist/runtime/server/provider-discovery.d.ts +73 -0
  29. package/dist/runtime/server/provider-discovery.js +77 -0
  30. package/dist/runtime/server/provider-layers.d.ts +27 -0
  31. package/dist/runtime/server/provider-layers.js +35 -0
  32. package/dist/runtime/server/provider-service.d.ts +126 -0
  33. package/dist/runtime/server/provider-service.js +87 -0
  34. package/dist/runtime/server/provider-settings.d.ts +78 -0
  35. package/dist/runtime/server/provider-settings.js +81 -0
  36. package/dist/runtime/server/provider-write.d.ts +154 -0
  37. package/dist/runtime/server/provider-write.js +221 -0
  38. package/dist/runtime/server/settings-defaults.d.ts +26 -0
  39. package/dist/runtime/server/settings-defaults.js +46 -0
  40. package/dist/runtime/shared/env-vars.d.ts +55 -0
  41. package/dist/runtime/shared/env-vars.js +22 -0
  42. package/dist/runtime/shared/guards.d.ts +2 -0
  43. package/dist/runtime/shared/guards.js +3 -0
  44. package/dist/runtime/shared/harness-providers.d.ts +62 -0
  45. package/dist/runtime/shared/harness-providers.js +38 -0
  46. package/dist/runtime/shared/harnesses.d.ts +19 -0
  47. package/dist/runtime/shared/harnesses.js +15 -0
  48. package/dist/runtime/shared/provider-registry.d.ts +255 -0
  49. package/dist/runtime/shared/provider-registry.js +112 -0
  50. package/dist/runtime/shared/settings-types.d.ts +84 -0
  51. package/dist/runtime/shared/settings-types.js +0 -0
  52. package/dist/runtime/shims-vue.d.ts +8 -0
  53. package/dist/runtime/vue/index.d.ts +14 -0
  54. package/dist/runtime/vue/index.js +6 -0
  55. package/dist/runtime/vue/providers-panel-model.d.ts +147 -0
  56. package/dist/runtime/vue/providers-panel-model.js +37 -0
  57. package/dist/runtime/vue/settings-modal-scope.d.ts +62 -0
  58. package/dist/runtime/vue/settings-modal-scope.js +2 -0
  59. package/dist/runtime/vue/useProvidersPanel.d.ts +115 -0
  60. package/dist/runtime/vue/useProvidersPanel.js +633 -0
  61. package/dist/types.d.mts +9 -0
  62. package/package.json +117 -4
  63. package/src/runtime/ai-sdk/index.ts +88 -0
  64. package/src/runtime/components/ForgeProvidersPanel.vue +449 -0
  65. package/src/runtime/editor/index.ts +146 -0
  66. package/src/runtime/model/errors.ts +52 -0
  67. package/src/runtime/server/credential-policy.ts +139 -0
  68. package/src/runtime/server/credential-store.ts +473 -0
  69. package/src/runtime/server/env-layers.ts +287 -0
  70. package/src/runtime/server/env.ts +46 -0
  71. package/src/runtime/server/harness-settings.ts +373 -0
  72. package/src/runtime/server/index.ts +24 -0
  73. package/src/runtime/server/provider-discovery.ts +164 -0
  74. package/src/runtime/server/provider-layers.ts +67 -0
  75. package/src/runtime/server/provider-service.ts +244 -0
  76. package/src/runtime/server/provider-settings.ts +192 -0
  77. package/src/runtime/server/provider-write.ts +433 -0
  78. package/src/runtime/server/settings-defaults.ts +87 -0
  79. package/src/runtime/shared/env-vars.ts +82 -0
  80. package/src/runtime/shared/guards.ts +4 -0
  81. package/src/runtime/shared/harness-providers.ts +118 -0
  82. package/src/runtime/shared/harnesses.ts +34 -0
  83. package/src/runtime/shared/provider-registry.ts +391 -0
  84. package/src/runtime/shared/settings-types.ts +87 -0
  85. package/src/runtime/shims-vue.d.ts +8 -0
  86. package/src/runtime/vue/index.ts +15 -0
  87. package/src/runtime/vue/providers-panel-model.ts +203 -0
  88. package/src/runtime/vue/settings-modal-scope.ts +66 -0
  89. package/src/runtime/vue/useProvidersPanel.ts +930 -0
@@ -0,0 +1,146 @@
1
+ /**
2
+ * `@skaile/forge-settings/editor` — the editor's `models` port over a provider
3
+ * registry.
4
+ *
5
+ * `@skaile/forge-editor`'s AI routes ask the host's `ModelProvider`
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 }`.
12
+ *
13
+ * The registry and the credential environment are supplied by the host on every
14
+ * call, so provider edits apply without a restart and nothing reads the ambient
15
+ * process. Failures are typed ({@link ModelResolutionError}); the host maps
16
+ * them to HTTP in `onFailure`.
17
+ *
18
+ * `@skaile/forge-editor` is an optional peer of this entry only, for its
19
+ * `ModelProvider` contract (types). `@ai-sdk/openai-compatible` builds the model.
20
+ */
21
+
22
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
23
+ import type { ModelProfile, ModelRef } from "@skaile/forge-editor/ai";
24
+ import type { ModelProvider } from "@skaile/forge-editor/server";
25
+ import { ModelResolutionError, assertEntryAllowed } from "../model/errors";
26
+ import { type CredentialPolicy, entryPolicyRefusal } from "../server/credential-policy";
27
+ import { resolveEntryCredential } from "../server/credential-store";
28
+ import { listAIProviderModels } from "../server/provider-service";
29
+ import {
30
+ type AIProviderLlmEntry,
31
+ type AIProviderRegistryView,
32
+ entriesOfKind,
33
+ } from "../shared/provider-registry";
34
+
35
+ export { ModelResolutionError } from "../model/errors";
36
+ export type { ModelResolutionCode } from "../model/errors";
37
+
38
+ /** The editor can only speak chat completions. */
39
+ const EDITOR_SURFACE = "openai-completions";
40
+
41
+ /** Policy for an editor model call; see `CredentialPolicy`. */
42
+ export interface EditorModelPolicyOptions {
43
+ /** Which credential references may be resolved; refused entries are not offered. */
44
+ policy?: CredentialPolicy;
45
+ }
46
+
47
+ /** The chat-completions entries (with an endpoint) of `registry`, by id. */
48
+ function editorEntries(registry: AIProviderRegistryView): Map<string, AIProviderLlmEntry> {
49
+ const entries = new Map<string, AIProviderLlmEntry>();
50
+ for (const [id, entry] of entriesOfKind(registry, "llm")) {
51
+ if (entry.surface === EDITOR_SURFACE && entry.base_url) entries.set(id, entry);
52
+ }
53
+ return entries;
54
+ }
55
+
56
+ /**
57
+ * The registry's chat-completions models as editor model profiles, in registry
58
+ * order. With a policy, entries it refuses are left out.
59
+ */
60
+ export function editorModelProfiles(
61
+ registry: AIProviderRegistryView,
62
+ options: EditorModelPolicyOptions = {},
63
+ ): 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
+ }));
78
+ }
79
+
80
+ /**
81
+ * 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).
84
+ *
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.
88
+ */
89
+ export function editorLanguageModel(
90
+ registry: AIProviderRegistryView,
91
+ env: Record<string, string | undefined>,
92
+ ref: ModelRef,
93
+ options: EditorModelPolicyOptions = {},
94
+ ) {
95
+ 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 } : {}),
103
+ });
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);
117
+ }
118
+
119
+ /** Options for {@link createEditorModelProvider}. */
120
+ export interface EditorModelProviderOptions extends EditorModelPolicyOptions {
121
+ /** The registry to serve, read on every call. */
122
+ registry: () => AIProviderRegistryView | Promise<AIProviderRegistryView>;
123
+ /** The environment `env:` references resolve against, read on every call. */
124
+ env: () => Record<string, string | undefined> | Promise<Record<string, string | undefined>>;
125
+ /** Turn a typed failure into the host's own error (e.g. its HTTP error). Default: rethrow as is. */
126
+ onFailure?: (error: ModelResolutionError) => Error;
127
+ }
128
+
129
+ /** The editor's `models` port over the host's registry. */
130
+ export function createEditorModelProvider(options: EditorModelProviderOptions): ModelProvider {
131
+ return {
132
+ async languageModel(ref) {
133
+ try {
134
+ return editorLanguageModel(await options.registry(), await options.env(), ref, options);
135
+ } catch (error) {
136
+ if (error instanceof ModelResolutionError && options.onFailure)
137
+ throw options.onFailure(error);
138
+ throw error;
139
+ }
140
+ },
141
+ async listModels() {
142
+ const profiles = editorModelProfiles(await options.registry(), options);
143
+ return profiles.length > 0 ? profiles : null;
144
+ },
145
+ };
146
+ }
@@ -0,0 +1,52 @@
1
+ import { type CredentialPolicy, entryPolicyRefusal } from "../server/credential-policy";
2
+ import type { AIProviderEntry } from "../shared/provider-registry";
3
+
4
+ /**
5
+ * A model could not be resolved. Framework-free: the host decides which HTTP
6
+ * status (or tool error) each `code` becomes.
7
+ *
8
+ * `message` never carries a secret. `providerId` names the registry entry the
9
+ * selector routed to, when it got that far.
10
+ */
11
+ export type ModelResolutionCode =
12
+ /** The registry has error diagnostics, so no selector can be trusted. */
13
+ | "registry_invalid"
14
+ /** No registry entry serves the selector. */
15
+ | "unknown_selector"
16
+ /** The host's credential policy refuses the entry; nothing was resolved. */
17
+ | "policy_refused"
18
+ /** The entry's credential, endpoint or provider SDK could not be resolved. */
19
+ | "unresolved";
20
+
21
+ export class ModelResolutionError extends Error {
22
+ readonly code: ModelResolutionCode;
23
+ readonly providerId?: string;
24
+ constructor(
25
+ code: ModelResolutionCode,
26
+ message: string,
27
+ options: { providerId?: string; cause?: unknown } = {},
28
+ ) {
29
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
30
+ this.name = "ModelResolutionError";
31
+ this.code = code;
32
+ if (options.providerId !== undefined) this.providerId = options.providerId;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Throw `policy_refused` when `policy` refuses `entry`; no policy refuses
38
+ * nothing. The one place a model adapter turns a policy refusal into an error,
39
+ * so every adapter words it the same way. Nothing is resolved.
40
+ */
41
+ export function assertEntryAllowed(
42
+ entry: AIProviderEntry,
43
+ providerId: string,
44
+ policy?: CredentialPolicy,
45
+ ): void {
46
+ const refusal = entryPolicyRefusal(entry, policy);
47
+ if (refusal) {
48
+ throw new ModelResolutionError("policy_refused", `provider '${providerId}': ${refusal}`, {
49
+ providerId,
50
+ });
51
+ }
52
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Credential policy — which references a host lets the credential layer touch.
3
+ *
4
+ * A reference names where a secret lives (`env:NAME`, `keyring:s/a`, `cmd:…`,
5
+ * `platform:…`). A host that stores provider entries an administrator typed,
6
+ * and runs next to its own secrets (a Directus token, an encryption key), must
7
+ * decide up front which references may be resolved at all. The decision is
8
+ * made *before* a reference is read: a refused reference is never resolved,
9
+ * never looked up in an environment and never passed on to a child process.
10
+ *
11
+ * The policy covers every place an entry names an environment variable:
12
+ *
13
+ * - `credential.ref` — by form, and for `env:` by variable name;
14
+ * - `${NAME}` interpolation tokens in `base_url`, `headers` and `query_params`,
15
+ * which a spawned runtime expands from its environment. Only bare variable
16
+ * names the `envNames` allow-list accepts pass; any other token is refused.
17
+ *
18
+ * No policy means no restriction: existing hosts keep their current reference
19
+ * handling. A policy is opt-in per host and states the host's rule explicitly.
20
+ */
21
+
22
+ import type { AIProviderEntry, AIProviderRegistryView } from "../shared/provider-registry";
23
+ import { parseCredentialRef, registryEntries } from "../shared/provider-registry";
24
+
25
+ /** The four credential reference forms. */
26
+ export type CredentialRefForm = "env" | "cmd" | "platform" | "keyring";
27
+
28
+ /** A host's rule for which credential references may be resolved. */
29
+ export interface CredentialPolicy {
30
+ /** Reference forms that may be resolved. Absent: every form. */
31
+ forms?: readonly CredentialRefForm[];
32
+ /**
33
+ * Environment variable names a reference or interpolation token may name:
34
+ * exact strings, or expressions that must match the whole name. Absent:
35
+ * any name.
36
+ */
37
+ envNames?: ReadonlyArray<string | RegExp>;
38
+ }
39
+
40
+ /** An entry the policy refused, and why. The reason is secret-free. */
41
+ export interface PolicyRefusal {
42
+ providerId: string;
43
+ error: string;
44
+ }
45
+
46
+ const BARE_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
47
+ const INTERPOLATION_TOKEN = /\$\{([^}]*)\}/g;
48
+
49
+ /** Whether `name` passes the policy's `envNames` allow-list. */
50
+ export function isEnvNameAllowed(name: string, policy: CredentialPolicy): boolean {
51
+ if (!policy.envNames) return true;
52
+ return policy.envNames.some((rule) => {
53
+ if (typeof rule === "string") return rule === name;
54
+ const match = new RegExp(rule.source, rule.flags.replace(/[gy]/g, "")).exec(name);
55
+ return match !== null && match.index === 0 && match[0] === name;
56
+ });
57
+ }
58
+
59
+ /**
60
+ * The reason a credential reference is refused, or `undefined` when the policy
61
+ * lets it through. A string that is not a reference is not this function's
62
+ * concern: it resolves to nothing anyway.
63
+ */
64
+ export function credentialRefRefusal(ref: string, policy: CredentialPolicy): string | undefined {
65
+ const parsed = parseCredentialRef(ref);
66
+ if (!parsed) return undefined;
67
+ if (policy.forms && !policy.forms.includes(parsed.form)) {
68
+ return `credential references of the form '${parsed.form}:' are not allowed here`;
69
+ }
70
+ if (parsed.form === "env" && !isEnvNameAllowed(parsed.name, policy)) {
71
+ return `environment variable ${parsed.name} is not allowed here`;
72
+ }
73
+ return undefined;
74
+ }
75
+
76
+ function interpolationRefusal(
77
+ field: string,
78
+ value: string,
79
+ policy: CredentialPolicy,
80
+ ): string | undefined {
81
+ for (const match of value.matchAll(INTERPOLATION_TOKEN)) {
82
+ const name = match[1]!.trim();
83
+ if (!BARE_ENV_NAME.test(name) || !isEnvNameAllowed(name, policy)) {
84
+ return `${field} interpolates '\${${name}}', which is not allowed here`;
85
+ }
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ /**
91
+ * The reason an entry is refused, or `undefined`: its credential reference and
92
+ * every environment name it interpolates are checked, nothing is resolved.
93
+ * Without a policy nothing is refused.
94
+ */
95
+ export function entryPolicyRefusal(
96
+ entry: AIProviderEntry,
97
+ policy?: CredentialPolicy,
98
+ ): string | undefined {
99
+ if (!policy) return undefined;
100
+ const ref = entry.credential?.ref;
101
+ if (ref !== undefined) {
102
+ const refusal = credentialRefRefusal(ref, policy);
103
+ if (refusal) return refusal;
104
+ }
105
+ const fields: Array<[string, unknown]> = [
106
+ ["base_url", entry.base_url],
107
+ ...Object.entries(entry.headers ?? {}).map(([name, value]): [string, unknown] => [
108
+ `headers.${name}`,
109
+ value,
110
+ ]),
111
+ ...Object.entries((entry as { query_params?: Record<string, string> }).query_params ?? {}).map(
112
+ ([name, value]): [string, unknown] => [`query_params.${name}`, value],
113
+ ),
114
+ ];
115
+ for (const [field, value] of fields) {
116
+ if (typeof value !== "string") continue;
117
+ const refusal = interpolationRefusal(field, value, policy);
118
+ if (refusal) return refusal;
119
+ }
120
+ return undefined;
121
+ }
122
+
123
+ /**
124
+ * The registry without the entries the policy refuses, as a plain entry map in
125
+ * registry order, plus what was refused. Nothing is resolved.
126
+ */
127
+ export function applyCredentialPolicy(
128
+ registry: AIProviderRegistryView,
129
+ policy: CredentialPolicy,
130
+ ): { registry: Record<string, AIProviderEntry>; refused: PolicyRefusal[] } {
131
+ const allowed: Record<string, AIProviderEntry> = {};
132
+ const refused: PolicyRefusal[] = [];
133
+ for (const [providerId, entry] of registryEntries(registry)) {
134
+ const error = entryPolicyRefusal(entry, policy);
135
+ if (error) refused.push({ providerId, error });
136
+ else allowed[providerId] = entry;
137
+ }
138
+ return { registry: allowed, refused };
139
+ }