@skaile/forge-settings 1.3.2 → 1.4.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 (46) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +20 -0
  3. package/dist/module.json +1 -1
  4. package/dist/runtime/components/ForgeProvidersPanel.d.vue.ts +13 -0
  5. package/dist/runtime/components/ForgeProvidersPanel.vue +461 -78
  6. package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +13 -0
  7. package/dist/runtime/server/harness-settings.d.ts +50 -12
  8. package/dist/runtime/server/harness-settings.js +93 -9
  9. package/dist/runtime/server/index.d.ts +2 -0
  10. package/dist/runtime/server/index.js +2 -0
  11. package/dist/runtime/server/provider-service.d.ts +26 -2
  12. package/dist/runtime/server/provider-service.js +16 -1
  13. package/dist/runtime/server/provider-write.d.ts +25 -9
  14. package/dist/runtime/server/provider-write.js +72 -4
  15. package/dist/runtime/shared/data-zone.d.ts +66 -0
  16. package/dist/runtime/shared/data-zone.js +79 -0
  17. package/dist/runtime/shared/harness-providers.d.ts +41 -2
  18. package/dist/runtime/shared/harness-providers.js +31 -8
  19. package/dist/runtime/shared/harnesses.d.ts +7 -0
  20. package/dist/runtime/shared/harnesses.js +18 -0
  21. package/dist/runtime/shared/provider-accounts.d.ts +215 -0
  22. package/dist/runtime/shared/provider-accounts.js +255 -0
  23. package/dist/runtime/shared/provider-registry.d.ts +38 -0
  24. package/dist/runtime/shared/provider-registry.js +16 -0
  25. package/dist/runtime/shared/settings-types.d.ts +7 -0
  26. package/dist/runtime/vue/index.d.ts +2 -0
  27. package/dist/runtime/vue/index.js +2 -0
  28. package/dist/runtime/vue/providers-panel-model.d.ts +98 -5
  29. package/dist/runtime/vue/providers-panel-model.js +13 -1
  30. package/dist/runtime/vue/useProvidersPanel.d.ts +66 -5
  31. package/dist/runtime/vue/useProvidersPanel.js +417 -29
  32. package/package.json +2 -2
  33. package/src/runtime/components/ForgeProvidersPanel.vue +477 -78
  34. package/src/runtime/server/harness-settings.ts +165 -21
  35. package/src/runtime/server/index.ts +2 -0
  36. package/src/runtime/server/provider-service.ts +53 -1
  37. package/src/runtime/server/provider-write.ts +129 -15
  38. package/src/runtime/shared/data-zone.ts +172 -0
  39. package/src/runtime/shared/harness-providers.ts +77 -6
  40. package/src/runtime/shared/harnesses.ts +25 -0
  41. package/src/runtime/shared/provider-accounts.ts +526 -0
  42. package/src/runtime/shared/provider-registry.ts +58 -0
  43. package/src/runtime/shared/settings-types.ts +7 -0
  44. package/src/runtime/vue/index.ts +2 -0
  45. package/src/runtime/vue/providers-panel-model.ts +123 -5
  46. package/src/runtime/vue/useProvidersPanel.ts +586 -37
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Data zones: where a provider entry processes data, and which entries a
3
+ * host's data-zone rule ("Where may AI process data?") keeps.
4
+ *
5
+ * `@skaile/workspaces` 9.1 owns this (`entryDataZone`,
6
+ * `entryAllowedByDataZone`, `filterRegistryByDataZone` in
7
+ * `bridge/src/provider-catalog.ts`, the `data_zone` of each catalog preset).
8
+ * forge-settings mirrors it here so the settings panel and a host on an older
9
+ * SDK apply the same rule in the browser and on the server. Semantics match
10
+ * upstream exactly; {@link PRESET_DATA_ZONES} is a copy of the catalog's
11
+ * column, and the SDK catalog is its source.
12
+ *
13
+ * Browser-safe on purpose — no `node:` imports.
14
+ */
15
+
16
+ import {
17
+ type AIProviderRegistry,
18
+ type AIProviderRegistryView,
19
+ DATA_ZONE_RULES,
20
+ DATA_ZONES,
21
+ type DataZone,
22
+ type DataZoneRule,
23
+ isMergedRegistry,
24
+ type MergedAIProviderRegistry,
25
+ } from "./provider-registry";
26
+
27
+ /**
28
+ * The `data_zone` of each catalog preset, by preset id. Copied from
29
+ * `@skaile/workspaces/bridge`'s `AI_PROVIDER_PRESETS` (9.1); change it there
30
+ * first. A preset this table does not name is `transfer`.
31
+ */
32
+ export const PRESET_DATA_ZONES: Readonly<Record<string, DataZone>> = {
33
+ anthropic: "transfer",
34
+ openai: "transfer",
35
+ google: "transfer",
36
+ groq: "transfer",
37
+ deepseek: "transfer",
38
+ openrouter: "transfer",
39
+ mistral: "eu",
40
+ xai: "transfer",
41
+ together: "transfer",
42
+ fireworks: "transfer",
43
+ bedrock: "eu_option",
44
+ vertex: "eu_option",
45
+ "azure-openai": "eu_option",
46
+ foundry: "eu_option",
47
+ ollama: "local",
48
+ lmstudio: "local",
49
+ vllm: "local",
50
+ litellm: "transfer",
51
+ deepgram: "transfer",
52
+ elevenlabs: "transfer",
53
+ };
54
+
55
+ /** What a provider row's zone chip says. */
56
+ export const DATA_ZONE_LABELS: Readonly<Record<DataZone, string>> = {
57
+ eu: "EU",
58
+ eu_option: "EU option",
59
+ transfer: "Transfer",
60
+ local: "Local",
61
+ };
62
+
63
+ /** What a host calls each rule when it names the rule that keeps an entry out. */
64
+ export const DATA_ZONE_RULE_LABELS: Readonly<Record<DataZoneRule, string>> = {
65
+ any: "Anywhere",
66
+ eu_options: "EU, or EU option with EU processing on",
67
+ eu: "EU only",
68
+ };
69
+
70
+ /** Whether `value` is one of {@link DATA_ZONES}. */
71
+ export function isDataZone(value: unknown): value is DataZone {
72
+ return typeof value === "string" && (DATA_ZONES as readonly string[]).includes(value);
73
+ }
74
+
75
+ /** Whether `value` is one of {@link DATA_ZONE_RULES}. */
76
+ export function isDataZoneRule(value: unknown): value is DataZoneRule {
77
+ return typeof value === "string" && (DATA_ZONE_RULES as readonly string[]).includes(value);
78
+ }
79
+
80
+ /** The zone of catalog preset `presetId`, or `undefined` for an id the catalog does not name. */
81
+ export function presetDataZone(presetId: string | undefined): DataZone | undefined {
82
+ return presetId !== undefined && Object.hasOwn(PRESET_DATA_ZONES, presetId)
83
+ ? PRESET_DATA_ZONES[presetId]
84
+ : undefined;
85
+ }
86
+
87
+ /**
88
+ * Where an entry processes data: its own `data_zone`, else the zone of the
89
+ * catalog preset it names, else `transfer` (an unknown endpoint is assumed to
90
+ * leave the EU).
91
+ */
92
+ export function entryDataZone(entry: { data_zone?: DataZone; preset?: string }): DataZone {
93
+ return entry.data_zone ?? presetDataZone(entry.preset) ?? "transfer";
94
+ }
95
+
96
+ /**
97
+ * Whether `rule` allows an entry in `zone`: `any` allows all; `eu` allows
98
+ * `eu` and `local`; `eu_options` additionally allows `eu_option` when
99
+ * `euProcessing` is `true`.
100
+ */
101
+ export function dataZoneAllows(
102
+ rule: DataZoneRule,
103
+ zone: DataZone,
104
+ euProcessing: boolean | undefined,
105
+ ): boolean {
106
+ if (rule === "any") return true;
107
+ if (zone === "eu" || zone === "local") return true;
108
+ return rule === "eu_options" && zone === "eu_option" && euProcessing === true;
109
+ }
110
+
111
+ /** Whether `rule` allows `entry` ({@link dataZoneAllows} over {@link entryDataZone}). */
112
+ export function entryAllowedByDataZone(
113
+ entry: { data_zone?: DataZone; preset?: string; eu_processing?: boolean },
114
+ rule: DataZoneRule,
115
+ ): boolean {
116
+ return dataZoneAllows(rule, entryDataZone(entry), entry.eu_processing);
117
+ }
118
+
119
+ /**
120
+ * Why `rule` keeps an entry in `zone` out, naming the rule, or `undefined`
121
+ * when the rule allows it. Secret-free, safe to render.
122
+ */
123
+ export function dataZoneRefusal(
124
+ rule: DataZoneRule,
125
+ zone: DataZone,
126
+ euProcessing: boolean | undefined,
127
+ ): string | undefined {
128
+ if (dataZoneAllows(rule, zone, euProcessing)) return undefined;
129
+ const why =
130
+ zone === "eu_option" && rule === "eu_options"
131
+ ? "EU processing is not on for this account"
132
+ : `this provider is ${DATA_ZONE_LABELS[zone]}`;
133
+ return `Blocked by the data-zone rule "${DATA_ZONE_RULE_LABELS[rule]}": ${why}.`;
134
+ }
135
+
136
+ /**
137
+ * The registry with only the entries `rule` allows, every kind alike
138
+ * (`llm`, `embed`, `stt`, `tts`). Order is kept; the input is not mutated. A
139
+ * merged registry comes back merged, its `order`, `origin` and `locked`
140
+ * narrowed to the kept entries.
141
+ */
142
+ export function filterRegistryByDataZone(
143
+ registry: AIProviderRegistry,
144
+ rule: DataZoneRule,
145
+ ): AIProviderRegistry;
146
+ export function filterRegistryByDataZone(
147
+ registry: MergedAIProviderRegistry,
148
+ rule: DataZoneRule,
149
+ ): MergedAIProviderRegistry;
150
+ export function filterRegistryByDataZone(
151
+ registry: AIProviderRegistryView,
152
+ rule: DataZoneRule,
153
+ ): AIProviderRegistryView;
154
+ export function filterRegistryByDataZone(
155
+ registry: AIProviderRegistryView,
156
+ rule: DataZoneRule,
157
+ ): AIProviderRegistryView {
158
+ const entries = isMergedRegistry(registry) ? registry.providers : registry;
159
+ const providers: AIProviderRegistry = {};
160
+ for (const [id, entry] of Object.entries(entries)) {
161
+ if (entryAllowedByDataZone(entry, rule)) providers[id] = entry;
162
+ }
163
+ if (!isMergedRegistry(registry)) return providers;
164
+ const kept = (id: string) => Object.hasOwn(providers, id);
165
+ return {
166
+ ...registry,
167
+ providers,
168
+ order: registry.order.filter(kept),
169
+ origin: Object.fromEntries(Object.entries(registry.origin).filter(([id]) => kept(id))),
170
+ locked: registry.locked.filter(kept),
171
+ };
172
+ }
@@ -8,10 +8,15 @@
8
8
  * Eligibility is by wire surface, never by vendor name: an entry is eligible
9
9
  * for a harness when it is an enabled `llm` entry whose `surface` the harness
10
10
  * declares. A harness that declares no surfaces accepts every `llm` entry.
11
+ * A host's data-zone rule then keeps out the entries outside it: such an
12
+ * entry is reported as `blocked-by-rule`, never eligible and never chosen.
11
13
  *
12
14
  * Browser-safe on purpose — no `node:` imports.
13
15
  */
14
16
 
17
+ import { DATA_ZONE_RULE_LABELS, dataZoneRefusal, presetDataZone } from "./data-zone";
18
+ import type { DataZone, DataZoneRule } from "./provider-registry";
19
+
15
20
  /** How many registry entries a harness can run at once (`@skaile/workspaces` `HarnessInfo.providerPlurality`). */
16
21
  export type HarnessProviderPlurality = "registry" | "credentials-n" | "single";
17
22
 
@@ -33,6 +38,35 @@ export interface HarnessProviderCandidate {
33
38
  surface?: string;
34
39
  /** Absent means enabled. */
35
40
  enabled?: boolean;
41
+ /** Catalog preset; gives the zone when {@link dataZone} is absent. */
42
+ preset?: string;
43
+ /** Effective data zone (`GET /api/providers` rows carry it). Absent: the preset's, else `transfer`. */
44
+ dataZone?: DataZone;
45
+ /** The account's EU-processing setting is on. */
46
+ euProcessing?: boolean;
47
+ }
48
+
49
+ /** Why an entry on a surface the harness speaks still cannot be used. */
50
+ export type HarnessProviderBlockReason = "blocked-by-rule";
51
+
52
+ /** One entry the harness could speak to that the host's rule keeps out. */
53
+ export interface HarnessProviderBlock {
54
+ id: string;
55
+ reason: HarnessProviderBlockReason;
56
+ /** The rule in force. */
57
+ rule: DataZoneRule;
58
+ /** The rule's label ("EU only"). */
59
+ ruleLabel: string;
60
+ /** The entry's effective zone. */
61
+ zone: DataZone;
62
+ /** What a settings table shows on the box it cannot tick. Secret-free. */
63
+ message: string;
64
+ }
65
+
66
+ /** What {@link resolveHarnessProviders} applies beyond the surface rule. */
67
+ export interface HarnessProviderOptions {
68
+ /** The host's data-zone rule. Absent: `any`. */
69
+ dataZoneRule?: DataZoneRule;
36
70
  }
37
71
 
38
72
  /**
@@ -55,8 +89,10 @@ export interface HarnessProviderResolution<
55
89
  state: HarnessProviderState;
56
90
  /** The harness can run several providers at once. */
57
91
  multiple: boolean;
58
- /** Stored pick ids that are no longer eligible, deduplicated, in stored order. */
92
+ /** Stored pick ids that are no longer eligible (blocked ones included), deduplicated, in stored order. */
59
93
  stale: string[];
94
+ /** Candidates on a surface the harness speaks that the data-zone rule keeps out, in registry order. */
95
+ blocked: HarnessProviderBlock[];
60
96
  }
61
97
 
62
98
  /** Whether `candidate` is an enabled `llm` entry on a surface `harness` speaks. */
@@ -70,22 +106,55 @@ export function isProviderEligibleForHarness(
70
106
  return candidate.surface !== undefined && harness.surfaces.includes(candidate.surface);
71
107
  }
72
108
 
109
+ /**
110
+ * Why the data-zone rule keeps `candidate` out of `harness`, or `undefined`
111
+ * when it does not: the candidate must be otherwise eligible
112
+ * ({@link isProviderEligibleForHarness}) and outside `rule`.
113
+ */
114
+ export function harnessProviderBlock(
115
+ harness: HarnessProviderInfo,
116
+ candidate: HarnessProviderCandidate,
117
+ rule: DataZoneRule | undefined,
118
+ ): HarnessProviderBlock | undefined {
119
+ if (!rule || rule === "any" || !isProviderEligibleForHarness(harness, candidate)) {
120
+ return undefined;
121
+ }
122
+ const zone = candidate.dataZone ?? presetDataZone(candidate.preset) ?? "transfer";
123
+ const message = dataZoneRefusal(rule, zone, candidate.euProcessing);
124
+ if (!message) return undefined;
125
+ return {
126
+ id: candidate.id,
127
+ reason: "blocked-by-rule",
128
+ rule,
129
+ ruleLabel: DATA_ZONE_RULE_LABELS[rule],
130
+ zone,
131
+ message,
132
+ };
133
+ }
134
+
73
135
  /**
74
136
  * Resolve the providers a harness runs against.
75
137
  *
76
138
  * @param candidates Registry entries in registry order.
77
139
  * @param selection The stored per-harness pick (`SkaileSettings.harnessProviders[harness.id]`).
140
+ * @param options.dataZoneRule The host's data-zone rule: entries outside it are
141
+ * `blocked`, never eligible, never auto-chosen; a stored pick of one is `stale`.
78
142
  */
79
143
  export function resolveHarnessProviders<C extends HarnessProviderCandidate>(
80
144
  harness: HarnessProviderInfo,
81
145
  candidates: readonly C[],
82
146
  selection: readonly string[] | undefined,
147
+ options: HarnessProviderOptions = {},
83
148
  ): HarnessProviderResolution<C> {
84
149
  const multiple =
85
150
  harness.providerPlurality === "registry" || harness.providerPlurality === "credentials-n";
86
- const eligible = candidates.filter((candidate) =>
87
- isProviderEligibleForHarness(harness, candidate),
88
- );
151
+ const blocked: HarnessProviderBlock[] = [];
152
+ const eligible = candidates.filter((candidate) => {
153
+ if (!isProviderEligibleForHarness(harness, candidate)) return false;
154
+ const block = harnessProviderBlock(harness, candidate, options.dataZoneRule);
155
+ if (block) blocked.push(block);
156
+ return !block;
157
+ });
89
158
  const eligibleIds = new Set(eligible.map((candidate) => candidate.id));
90
159
  const picked = new Set(selection ?? []);
91
160
  const stale = [...picked].filter((id) => !eligibleIds.has(id));
@@ -100,10 +169,11 @@ export function resolveHarnessProviders<C extends HarnessProviderCandidate>(
100
169
  state: "chosen",
101
170
  multiple,
102
171
  stale,
172
+ blocked,
103
173
  };
104
174
  }
105
175
  if (eligible.length === 0) {
106
- return { eligible, selected: [], state: "none-eligible", multiple, stale };
176
+ return { eligible, selected: [], state: "none-eligible", multiple, stale, blocked };
107
177
  }
108
178
  if (eligible.length === 1 || multiple) {
109
179
  return {
@@ -112,7 +182,8 @@ export function resolveHarnessProviders<C extends HarnessProviderCandidate>(
112
182
  state: "auto",
113
183
  multiple,
114
184
  stale,
185
+ blocked,
115
186
  };
116
187
  }
117
- return { eligible, selected: [], state: "needs-choice", multiple, stale };
188
+ return { eligible, selected: [], state: "needs-choice", multiple, stale, blocked };
118
189
  }
@@ -32,3 +32,28 @@ export const DEFAULT_ENABLED_HARNESSES = ["omp", "claude-sdk", "codex"] as const
32
32
  export function isExperimentalHarness(id: string): boolean {
33
33
  return !(DEFAULT_ENABLED_HARNESSES as readonly string[]).includes(id);
34
34
  }
35
+
36
+ /**
37
+ * The model roles ("models per task") each multi-model harness renders, keyed by
38
+ * harness id. A harness absent here runs one model and takes no roles.
39
+ */
40
+ export const HARNESS_ROLES: Readonly<Record<string, readonly string[]>> = {
41
+ omp: [
42
+ "default",
43
+ "smol",
44
+ "slow",
45
+ "vision",
46
+ "plan",
47
+ "designer",
48
+ "commit",
49
+ "tiny",
50
+ "task",
51
+ "advisor",
52
+ ],
53
+ continue: ["chat", "edit", "apply", "autocomplete", "embed", "rerank", "summarize"],
54
+ };
55
+
56
+ /** The role ids `harnessId` supports ({@link HARNESS_ROLES}); empty for a one-model harness. */
57
+ export function harnessRoleIds(harnessId: string): string[] {
58
+ return [...(HARNESS_ROLES[harnessId] ?? [])];
59
+ }