@skaile/forge-settings 1.0.0 → 1.1.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 (36) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/module.json +1 -1
  3. package/dist/runtime/ai-sdk/index.d.ts +3 -2
  4. package/dist/runtime/ai-sdk/index.js +3 -11
  5. package/dist/runtime/editor/index.js +4 -15
  6. package/dist/runtime/model/errors.d.ts +8 -0
  7. package/dist/runtime/model/errors.js +9 -0
  8. package/dist/runtime/server/credential-policy.d.ts +2 -1
  9. package/dist/runtime/server/credential-policy.js +1 -0
  10. package/dist/runtime/server/credential-store.js +1 -1
  11. package/dist/runtime/server/env-layers.js +1 -3
  12. package/dist/runtime/server/harness-settings.d.ts +6 -1
  13. package/dist/runtime/server/harness-settings.js +3 -2
  14. package/dist/runtime/server/provider-discovery.js +1 -1
  15. package/dist/runtime/server/provider-layers.js +1 -3
  16. package/dist/runtime/server/provider-write.js +2 -4
  17. package/dist/runtime/shared/guards.d.ts +2 -0
  18. package/dist/runtime/shared/guards.js +3 -0
  19. package/dist/runtime/vue/providers-panel-model.d.ts +147 -0
  20. package/dist/runtime/vue/providers-panel-model.js +37 -0
  21. package/dist/runtime/vue/useProvidersPanel.d.ts +5 -122
  22. package/dist/runtime/vue/useProvidersPanel.js +15 -37
  23. package/package.json +2 -2
  24. package/src/runtime/ai-sdk/index.ts +9 -12
  25. package/src/runtime/editor/index.ts +4 -15
  26. package/src/runtime/model/errors.ts +21 -0
  27. package/src/runtime/server/credential-policy.ts +3 -1
  28. package/src/runtime/server/credential-store.ts +1 -1
  29. package/src/runtime/server/env-layers.ts +1 -4
  30. package/src/runtime/server/harness-settings.ts +7 -0
  31. package/src/runtime/server/provider-discovery.ts +1 -1
  32. package/src/runtime/server/provider-layers.ts +1 -4
  33. package/src/runtime/server/provider-write.ts +2 -7
  34. package/src/runtime/shared/guards.ts +4 -0
  35. package/src/runtime/vue/providers-panel-model.ts +203 -0
  36. package/src/runtime/vue/useProvidersPanel.ts +35 -192
@@ -13,43 +13,21 @@ import {
13
13
  AI_PROVIDER_SURFACES,
14
14
  validateCredentialRef
15
15
  } from "../shared/provider-registry.js";
16
- export const CUSTOM_PRESET = "__custom__";
17
- export const CREDENTIAL_ENTER_KEY = "__enter_key__";
18
- export const CREDENTIAL_OTHER_REF = "__other_ref__";
19
- const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
20
- const DEFAULT_PRESETS_URL = "/api/providers/presets";
21
- const ENV_REF_PATTERN = /^env:(.+)$/;
22
- const FALLBACK_CREDENTIAL_KIND = "api_key_header";
23
- const ENV_SOURCE_LABEL = {
24
- instance: "instance .env",
25
- workspace: "workspace .env",
26
- process: "boot environment"
27
- };
28
- const OVERRIDES_INSTANCE_NOTICE = "Entering a key here stores it in this workspace's .env and overrides that value for this workspace.";
29
- function requestErrorMessage(err, fallback) {
30
- const e = err;
31
- const details = (e?.data?.data?.diagnostics ?? []).map((d) => d.message).filter((m) => typeof m === "string" && m !== "");
32
- if (details.length > 0) return details.join("; ");
33
- const message = e?.data?.statusMessage ?? e?.statusMessage;
34
- return typeof message === "string" && message ? message : fallback;
35
- }
36
- function envNameOfRef(ref2) {
37
- return ref2 ? ENV_REF_PATTERN.exec(ref2)?.[1] : void 0;
38
- }
39
- function nonEnvSourceLabel(ref2) {
40
- if (ref2.startsWith("keyring:")) return "keyring";
41
- if (ref2.startsWith("cmd:")) return "runtime-owned (cmd)";
42
- if (ref2.startsWith("platform:")) return "control-plane-owned (platform)";
43
- return void 0;
44
- }
45
- const EMPTY_FORM = {
46
- preset: "",
47
- id: "",
48
- surface: "",
49
- baseUrl: "",
50
- envName: "",
51
- secret: ""
52
- };
16
+ import {
17
+ CREDENTIAL_ENTER_KEY,
18
+ CREDENTIAL_OTHER_REF,
19
+ CUSTOM_PRESET,
20
+ DEFAULT_PRESETS_URL,
21
+ EMPTY_FORM,
22
+ ENV_SOURCE_LABEL,
23
+ envNameOfRef,
24
+ FALLBACK_CREDENTIAL_KIND,
25
+ nonEnvSourceLabel,
26
+ OVERRIDES_INSTANCE_NOTICE,
27
+ PROVIDER_ID_PATTERN,
28
+ requestErrorMessage
29
+ } from "./providers-panel-model.js";
30
+ export { CREDENTIAL_ENTER_KEY, CREDENTIAL_OTHER_REF, CUSTOM_PRESET } from "./providers-panel-model.js";
53
31
  export function useProvidersPanel(props, emit) {
54
32
  const providers = ref([]);
55
33
  const presets = ref([]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skaile/forge-settings",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Provider and settings operations for Skaile forge apps: provider registry layers, credential store, env layers, model discovery, the providers panel, and editor / AI SDK model adapters.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.9",
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "dependencies": {
57
57
  "@nuxt/kit": "^4.0.0",
58
- "@skaile/workspaces": "^6.10.0",
58
+ "@skaile/workspaces": "^6.14.0",
59
59
  "ofetch": "^1.4.0",
60
60
  "yaml": "^2.9.0"
61
61
  },
@@ -17,8 +17,8 @@
17
17
  import { parseModelRef } from "@skaile/workspaces/core";
18
18
  import { type ResolvedLanguageModel, resolveLanguageModel } from "@skaile/workspaces/ai-sdk";
19
19
  import { loadMergedProviderRegistry } from "@skaile/workspaces/sdk/bridge";
20
- import { type CredentialPolicy, entryPolicyRefusal } from "../server/credential-policy";
21
- import { ModelResolutionError } from "../model/errors";
20
+ import type { CredentialPolicy } from "../server/credential-policy";
21
+ import { ModelResolutionError, assertEntryAllowed } from "../model/errors";
22
22
 
23
23
  export { ModelResolutionError } from "../model/errors";
24
24
  export type { ModelResolutionCode } from "../model/errors";
@@ -47,7 +47,8 @@ export interface ResolveRegistryLanguageModelOptions {
47
47
  * `@role`), resolved by Workspaces against the registry on disk.
48
48
  *
49
49
  * @throws {ModelResolutionError} `registry_invalid` · `unknown_selector` ·
50
- * `policy_refused` · `unresolved` (credential, endpoint or provider SDK).
50
+ * `policy_refused` · `unresolved` (credential, endpoint or provider SDK; the
51
+ * message is fixed and never carries Workspaces' text, which stays on `cause`).
51
52
  */
52
53
  export async function resolveRegistryLanguageModel(
53
54
  selector: string,
@@ -66,14 +67,7 @@ export async function resolveRegistryLanguageModel(
66
67
  throw new ModelResolutionError("unknown_selector", `no provider serves '${selector}'`);
67
68
  }
68
69
  const entry = merged.providers[ref.providerId];
69
- if (entry && options.policy) {
70
- const refusal = entryPolicyRefusal(entry, options.policy);
71
- if (refusal) {
72
- throw new ModelResolutionError("policy_refused", `provider '${ref.providerId}': ${refusal}`, {
73
- providerId: ref.providerId,
74
- });
75
- }
76
- }
70
+ if (entry) assertEntryAllowed(entry, ref.providerId, options.policy);
77
71
 
78
72
  try {
79
73
  return await resolveLanguageModel(selector, {
@@ -82,9 +76,12 @@ export async function resolveRegistryLanguageModel(
82
76
  ...(options.roles ? { roles: options.roles } : {}),
83
77
  });
84
78
  } catch (cause) {
79
+ // Workspaces' message is not ours to vouch for: it can echo a resolved
80
+ // credential or an endpoint with a key in its query. The message is fixed;
81
+ // the original error stays on `cause` for a host that logs it server-side.
85
82
  throw new ModelResolutionError(
86
83
  "unresolved",
87
- cause instanceof Error ? cause.message : String(cause),
84
+ `provider '${ref.providerId}': credential, endpoint or provider SDK could not be resolved`,
88
85
  { providerId: ref.providerId, cause },
89
86
  );
90
87
  }
@@ -22,7 +22,7 @@
22
22
  import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
23
23
  import type { ModelProfile, ModelRef } from "@skaile/forge-editor/ai";
24
24
  import type { ModelProvider } from "@skaile/forge-editor/server";
25
- import { ModelResolutionError } from "../model/errors";
25
+ import { ModelResolutionError, assertEntryAllowed } from "../model/errors";
26
26
  import { type CredentialPolicy, entryPolicyRefusal } from "../server/credential-policy";
27
27
  import { resolveEntryCredential } from "../server/credential-store";
28
28
  import { listAIProviderModels } from "../server/provider-service";
@@ -64,7 +64,7 @@ export function editorModelProfiles(
64
64
  const entries = editorEntries(registry);
65
65
  const allowed = (providerId: string) => {
66
66
  const entry = entries.get(providerId);
67
- return entry !== undefined && !(options.policy && entryPolicyRefusal(entry, options.policy));
67
+ return entry !== undefined && !entryPolicyRefusal(entry, options.policy);
68
68
  };
69
69
  return listAIProviderModels(registry)
70
70
  .filter((model) => allowed(model.providerId))
@@ -96,18 +96,7 @@ export function editorLanguageModel(
96
96
  const model = listAIProviderModels(registry).find((candidate) => candidate.id === ref.preset);
97
97
  const entry = model && editorEntries(registry).get(model.providerId);
98
98
  if (!model || !entry) return null;
99
- if (options.policy) {
100
- const refusal = entryPolicyRefusal(entry, options.policy);
101
- if (refusal) {
102
- throw new ModelResolutionError(
103
- "policy_refused",
104
- `AI provider "${model.providerId}": ${refusal}`,
105
- {
106
- providerId: model.providerId,
107
- },
108
- );
109
- }
110
- }
99
+ assertEntryAllowed(entry, model.providerId, options.policy);
111
100
  const credential = resolveEntryCredential(entry, {
112
101
  env,
113
102
  ...(options.policy ? { policy: options.policy } : {}),
@@ -115,7 +104,7 @@ export function editorLanguageModel(
115
104
  if (credential.error && !credential.value) {
116
105
  throw new ModelResolutionError(
117
106
  "unresolved",
118
- `AI provider "${model.providerId}": ${credential.error}`,
107
+ `provider '${model.providerId}': ${credential.error}`,
119
108
  { providerId: model.providerId },
120
109
  );
121
110
  }
@@ -1,3 +1,6 @@
1
+ import { type CredentialPolicy, entryPolicyRefusal } from "../server/credential-policy";
2
+ import type { AIProviderEntry } from "../shared/provider-registry";
3
+
1
4
  /**
2
5
  * A model could not be resolved. Framework-free: the host decides which HTTP
3
6
  * status (or tool error) each `code` becomes.
@@ -29,3 +32,21 @@ export class ModelResolutionError extends Error {
29
32
  if (options.providerId !== undefined) this.providerId = options.providerId;
30
33
  }
31
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
+ }
@@ -90,11 +90,13 @@ function interpolationRefusal(
90
90
  /**
91
91
  * The reason an entry is refused, or `undefined`: its credential reference and
92
92
  * every environment name it interpolates are checked, nothing is resolved.
93
+ * Without a policy nothing is refused.
93
94
  */
94
95
  export function entryPolicyRefusal(
95
96
  entry: AIProviderEntry,
96
- policy: CredentialPolicy,
97
+ policy?: CredentialPolicy,
97
98
  ): string | undefined {
99
+ if (!policy) return undefined;
98
100
  const ref = entry.credential?.ref;
99
101
  if (ref !== undefined) {
100
102
  const refusal = credentialRefRefusal(ref, policy);
@@ -366,7 +366,7 @@ export function resolveEntryCredential(
366
366
  entry: AIProviderEntry,
367
367
  options: CredentialStoreOptions = {},
368
368
  ): ResolvedCredential {
369
- const refusal = options.policy ? entryPolicyRefusal(entry, options.policy) : undefined;
369
+ const refusal = entryPolicyRefusal(entry, options.policy);
370
370
  if (refusal) return { source: "none", error: refusal };
371
371
  const credential = entry.credential;
372
372
  if (!credential || credential.kind === "none") return { source: "none" };
@@ -17,6 +17,7 @@
17
17
  import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
18
18
  import { dirname, join } from "node:path";
19
19
  import { AI_PROVIDER_PRESETS } from "@skaile/workspaces/sdk/bridge";
20
+ import { isPlainObject } from "../shared/guards";
20
21
  import type { AIProviderRegistryView } from "../shared/provider-registry";
21
22
  import { parseCredentialRef, registryEntries } from "../shared/provider-registry";
22
23
  import {
@@ -250,10 +251,6 @@ export function buildEnvSettingsDocument(input: EnvSettingsDocumentInput): EnvSe
250
251
 
251
252
  // ── Update validation ──────────────────────────────────────────────────────
252
253
 
253
- function isPlainObject(value: unknown): value is Record<string, unknown> {
254
- return typeof value === "object" && value !== null && !Array.isArray(value);
255
- }
256
-
257
254
  /**
258
255
  * Validate a `PUT <environmentUrl>` body. Returns the normalized update, or a
259
256
  * refusal string suitable as an HTTP 400 status message.
@@ -20,6 +20,7 @@
20
20
  * Pure functions over plain values: no Node built-ins, no environment, no file access.
21
21
  */
22
22
 
23
+ import type { HarnessDetection } from "@skaile/workspaces/sdk/bridge";
23
24
  import type { HarnessInfo } from "@skaile/workspaces/types";
24
25
  import {
25
26
  DEFAULT_ENABLED_HARNESSES,
@@ -96,6 +97,8 @@ export type HarnessRow = HarnessInfo & {
96
97
  experimental: boolean;
97
98
  /** Whether the instance's `enabledHarnesses` offers it. */
98
99
  enabled: boolean;
100
+ /** What `detectHarnesses()` found on this host: installed, version, login. */
101
+ detection: HarnessDetection;
99
102
  };
100
103
 
101
104
  /**
@@ -103,10 +106,13 @@ export type HarnessRow = HarnessInfo & {
103
106
  * filter to `enabled` unless the request asked for all (`?all=1`).
104
107
  * `surfaces` and `providerPlurality` ride along from {@link HarnessInfo} so the
105
108
  * Harness tab can offer each harness only the providers it can use.
109
+ * `detections` is the host's `detectHarnesses()` answer; a harness it does not
110
+ * mention (or every harness, when it is omitted) reports `{ status: "unknown" }`.
106
111
  */
107
112
  export function buildHarnessRows(
108
113
  harnesses: HarnessInfo[],
109
114
  s: Pick<HarnessSettings, "enabledHarnesses">,
115
+ detections: readonly HarnessDetection[] = [],
110
116
  ): HarnessRow[] {
111
117
  const enabled = new Set(enabledHarnessIds(s));
112
118
  return harnesses.map((info) => ({
@@ -114,6 +120,7 @@ export function buildHarnessRows(
114
120
  label: HARNESS_LABELS[info.id] ?? info.name,
115
121
  experimental: isExperimentalHarness(info.id),
116
122
  enabled: enabled.has(info.id),
123
+ detection: detections.find((d) => d.id === info.id) ?? { id: info.id, status: "unknown" },
117
124
  }));
118
125
  }
119
126
 
@@ -104,7 +104,7 @@ export async function discoverProviderModels(
104
104
  if (!isEntryEnabled(entry)) return { status: "error", providerId, error: "entry is disabled" };
105
105
 
106
106
  // The policy decides before anything is resolved or requested.
107
- const refusal = options.policy ? entryPolicyRefusal(entry, options.policy) : undefined;
107
+ const refusal = entryPolicyRefusal(entry, options.policy);
108
108
  if (refusal) return { status: "error", providerId, error: refusal };
109
109
 
110
110
  const credential = resolveEntryCredential(entry, {
@@ -16,14 +16,11 @@ import {
16
16
  } from "@skaile/workspaces/core";
17
17
  import { AI_PROVIDER_PRESETS, type AIProviderPreset } from "@skaile/workspaces/sdk/bridge";
18
18
  import { parse as parseYaml } from "yaml";
19
+ import { isPlainObject } from "../shared/guards";
19
20
 
20
21
  /** The `.yaml` spelling of {@link PROVIDERS_FILENAME}. */
21
22
  const PROVIDERS_FILENAME_ALIAS = "providers.yaml";
22
23
 
23
- function isPlainObject(value: unknown): value is Record<string, unknown> {
24
- return typeof value === "object" && value !== null && !Array.isArray(value);
25
- }
26
-
27
24
  /**
28
25
  * The file a write to the layer at `path` goes to: the `.yaml` spelling when
29
26
  * only that one exists, so an edit never shadows a hand-written document.
@@ -39,6 +39,7 @@ import {
39
39
  type AIProviderPreset,
40
40
  expandPreset,
41
41
  } from "@skaile/workspaces/sdk/bridge";
42
+ import { isPlainObject } from "../shared/guards";
42
43
  import type { AIProviderEntry, AIProviderRegistry } from "../shared/provider-registry";
43
44
  import { type CredentialPolicy, entryPolicyRefusal } from "./credential-policy";
44
45
  import { catalogPreset, readProviderLayer, writeProviderLayer } from "./provider-layers";
@@ -130,10 +131,6 @@ export function providerScopeWritable(
130
131
  return scope.name !== "instance" && !scope.lock;
131
132
  }
132
133
 
133
- function isPlainObject(value: unknown): value is Record<string, unknown> {
134
- return typeof value === "object" && value !== null && !Array.isArray(value);
135
- }
136
-
137
134
  /** The merge layer a scope's own document feeds. */
138
135
  function mergeLayerOf(scope: ProviderWriteScope): "user" | "workspace" {
139
136
  return scope.name === "instance" ? "user" : "workspace";
@@ -300,9 +297,7 @@ export function upsertProviderEntry(
300
297
  }
301
298
  const next = { ...providers, [id]: entry };
302
299
 
303
- const refusal = scope.policy
304
- ? entryPolicyRefusal(entry as unknown as AIProviderEntry, scope.policy)
305
- : undefined;
300
+ const refusal = entryPolicyRefusal(entry as unknown as AIProviderEntry, scope.policy);
306
301
  if (refusal) throw createError({ statusCode: 400, statusMessage: refusal });
307
302
 
308
303
  const diagnostics = entryErrors(scope, next, id);
@@ -0,0 +1,4 @@
1
+ /** A non-null, non-array object. */
2
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
3
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The providers panel's vocabulary: the types the panel and its composable
3
+ * share, the constants of its form, and the pure reference / error helpers.
4
+ * No Vue, no requests — testable on their own. The public types and constants
5
+ * are re-exported by `useProvidersPanel`; the rest is internal to the package.
6
+ */
7
+
8
+ import type { EnvLayerId } from "../shared/env-vars";
9
+ import type {
10
+ AIProviderCredentialKind,
11
+ AIProviderKind,
12
+ AIProviderSurface,
13
+ } from "../shared/provider-registry";
14
+
15
+ /** Registry document a provider entry is defined in. */
16
+ export type ProvidersPanelLayer = "instance" | "workspace";
17
+
18
+ /** Props both `ForgeProvidersPanel` variants declare. */
19
+ export interface ProvidersPanelProps {
20
+ /** `GET` merged view for this scope (`/api/providers` | `/api/workspaces/:id/providers`). */
21
+ providersUrl: string;
22
+ /** `PUT|DELETE ${providersWriteUrl}/:id`; `GET ${providersWriteUrl}/detected`. */
23
+ providersWriteUrl: string;
24
+ /** Catalog preset summaries. Default `/api/providers/presets`. */
25
+ presetsUrl?: string;
26
+ /**
27
+ * The scope's `.env` route. `GET` on load and after every write; keys typed
28
+ * into the panel write through it. Omitted, no key can be entered.
29
+ */
30
+ environmentUrl?: string;
31
+ /** The scope's own registry document. */
32
+ layer: ProvidersPanelLayer;
33
+ /** Disables every write (non-admin at instance scope; provider lock). */
34
+ readOnly?: boolean;
35
+ }
36
+
37
+ /** Emits both variants declare. */
38
+ export type ProvidersPanelEmit = (event: "changed") => void;
39
+
40
+ /** One entry of `GET ${providersUrl}?all=1`. */
41
+ export interface ProvidersPanelEntry {
42
+ id: string;
43
+ /** The entry's label, else its id. */
44
+ name: string;
45
+ kind: AIProviderKind;
46
+ /** Whether the credential reference resolved on the server. */
47
+ configured: boolean;
48
+ /** Why it did not resolve. Secret-free. */
49
+ error?: string;
50
+ credentialRef?: string;
51
+ credentialKind?: AIProviderCredentialKind;
52
+ hasEnvKey: boolean;
53
+ envKeyName?: string;
54
+ baseUrl?: string;
55
+ preset?: string;
56
+ surface?: AIProviderSurface;
57
+ enabled: boolean;
58
+ locked: boolean;
59
+ /** Registry documents holding the id, lowest first: `["instance"]`, `["workspace"]` or both. */
60
+ layers: ProvidersPanelLayer[];
61
+ /** The scope's own document's values for the editable fields the dialog does not show. */
62
+ layerFields?: Record<string, unknown>;
63
+ }
64
+
65
+ /** One catalog preset, as `GET /api/providers/presets` and `.../detected` list it. */
66
+ export interface ProvidersPanelPreset {
67
+ id: string;
68
+ label: string;
69
+ kind: AIProviderKind;
70
+ surface?: AIProviderSurface;
71
+ base_url?: string;
72
+ base_url_env?: string;
73
+ credential: { kind: AIProviderCredentialKind; env?: string };
74
+ }
75
+
76
+ /** Where a row's entry lives relative to the scope's own document. */
77
+ export type ProvidersPanelRowState = "own" | "inherited" | "patched";
78
+
79
+ /** One entry of a row's credential select. */
80
+ export interface ProvidersPanelCredentialOption {
81
+ /** An env var NAME, a literal non-env ref, {@link CREDENTIAL_ENTER_KEY} or {@link CREDENTIAL_OTHER_REF}. */
82
+ value: string;
83
+ /** `NAME ••••hint` | `NAME (not set)` | the literal ref | `Enter a key…` | `Other reference…`. */
84
+ label: string;
85
+ defined?: boolean;
86
+ hint?: string;
87
+ source?: EnvLayerId;
88
+ action?: "enter-key" | "other-ref";
89
+ }
90
+
91
+ /** A table row: the entry plus what the panel derives for it. */
92
+ export interface ProvidersPanelRow extends ProvidersPanelEntry {
93
+ /** Own entry, an inherited one, or an inherited one this scope patched. */
94
+ state: ProvidersPanelRowState;
95
+ /** `instance` | `workspace` | `instance · overridden here`. */
96
+ badge: string;
97
+ /** Set on inherited rows. */
98
+ inheritedNote?: string;
99
+ /** Edit/Delete offered: the scope's document defines it and the panel is writable. */
100
+ editable: boolean;
101
+ /** Reset offered: a patched row of a writable panel. */
102
+ canReset: boolean;
103
+ /** The enabled switch is live. */
104
+ canToggle: boolean;
105
+ /** The entry takes a credential (its kind is not `none`). */
106
+ needsCredential: boolean;
107
+ /** The credential picker is live. */
108
+ canEditCredential: boolean;
109
+ /** The select's model: the env var NAME of an `env:` ref, else the literal ref, else `""`. */
110
+ credentialValue: string;
111
+ credentialOptions: ProvidersPanelCredentialOption[];
112
+ /** The preset it inherits, else its surface, else its kind. */
113
+ origin: string;
114
+ /** `configured · <source>`, `missing <VAR>`, the resolver's reason, or `No credential needed.`. */
115
+ status: string;
116
+ /** Which other layer defines or overrides the credential; see the panel contract. */
117
+ overrideNotices: string[];
118
+ }
119
+
120
+ /** The add/edit dialog's fields. */
121
+ export interface ProvidersPanelForm {
122
+ /** A preset id, or {@link CUSTOM_PRESET}. */
123
+ preset: string;
124
+ id: string;
125
+ /** Custom entries only. */
126
+ surface: string;
127
+ /** Empty: the preset's endpoint. */
128
+ baseUrl: string;
129
+ /** Adding only: the `env:` variable the credential references. */
130
+ envName: string;
131
+ /** Adding only, "Set value now": written to the scope's `.env`, never to the provider route. */
132
+ secret: string;
133
+ }
134
+
135
+ // ── Constants ─────────────────────────────────────────────────────────────────
136
+
137
+ /** The preset select's value for an entry that names no preset. */
138
+ export const CUSTOM_PRESET = "__custom__";
139
+
140
+ /** Credential select value: open the inline key editor. */
141
+ export const CREDENTIAL_ENTER_KEY = "__enter_key__";
142
+
143
+ /** Credential select value: open the inline reference editor. */
144
+ export const CREDENTIAL_OTHER_REF = "__other_ref__";
145
+
146
+ /** Registry ids the write routes accept (`isSafeProviderId` upstream). */
147
+ export const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
148
+
149
+ export const DEFAULT_PRESETS_URL = "/api/providers/presets";
150
+
151
+ /** An `env:NAME` credential reference; group 1 is the variable name. */
152
+ export const ENV_REF_PATTERN = /^env:(.+)$/;
153
+
154
+ /** The credential kind a written reference gets when the row states none. */
155
+ export const FALLBACK_CREDENTIAL_KIND: AIProviderCredentialKind = "api_key_header";
156
+
157
+ export const ENV_SOURCE_LABEL: Record<EnvLayerId, string> = {
158
+ instance: "instance .env",
159
+ workspace: "workspace .env",
160
+ process: "boot environment",
161
+ };
162
+
163
+ export const OVERRIDES_INSTANCE_NOTICE =
164
+ "Entering a key here stores it in this workspace's .env and overrides that value for this workspace.";
165
+
166
+ /**
167
+ * The server's refusal for a failed request, else `fallback`. A registry
168
+ * rejection (`400` with `data.diagnostics`) reads as its diagnostics' messages.
169
+ */
170
+ export function requestErrorMessage(err: unknown, fallback: string): string {
171
+ const e = err as {
172
+ data?: { statusMessage?: unknown; data?: { diagnostics?: Array<{ message?: unknown }> } };
173
+ statusMessage?: unknown;
174
+ } | null;
175
+ const details = (e?.data?.data?.diagnostics ?? [])
176
+ .map((d) => d.message)
177
+ .filter((m): m is string => typeof m === "string" && m !== "");
178
+ if (details.length > 0) return details.join("; ");
179
+ const message = e?.data?.statusMessage ?? e?.statusMessage;
180
+ return typeof message === "string" && message ? message : fallback;
181
+ }
182
+
183
+ /** The variable name of an `env:NAME` reference. */
184
+ export function envNameOfRef(ref: string | undefined): string | undefined {
185
+ return ref ? ENV_REF_PATTERN.exec(ref)?.[1] : undefined;
186
+ }
187
+
188
+ /** `configured · …` for a reference that is not an env var. */
189
+ export function nonEnvSourceLabel(ref: string): string | undefined {
190
+ if (ref.startsWith("keyring:")) return "keyring";
191
+ if (ref.startsWith("cmd:")) return "runtime-owned (cmd)";
192
+ if (ref.startsWith("platform:")) return "control-plane-owned (platform)";
193
+ return undefined;
194
+ }
195
+
196
+ export const EMPTY_FORM: Readonly<ProvidersPanelForm> = {
197
+ preset: "",
198
+ id: "",
199
+ surface: "",
200
+ baseUrl: "",
201
+ envName: "",
202
+ secret: "",
203
+ };