@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.
- package/CHANGELOG.md +12 -0
- package/dist/module.json +1 -1
- package/dist/runtime/ai-sdk/index.d.ts +3 -2
- package/dist/runtime/ai-sdk/index.js +3 -11
- package/dist/runtime/editor/index.js +4 -15
- package/dist/runtime/model/errors.d.ts +8 -0
- package/dist/runtime/model/errors.js +9 -0
- package/dist/runtime/server/credential-policy.d.ts +2 -1
- package/dist/runtime/server/credential-policy.js +1 -0
- package/dist/runtime/server/credential-store.js +1 -1
- package/dist/runtime/server/env-layers.js +1 -3
- package/dist/runtime/server/harness-settings.d.ts +6 -1
- package/dist/runtime/server/harness-settings.js +3 -2
- package/dist/runtime/server/provider-discovery.js +1 -1
- package/dist/runtime/server/provider-layers.js +1 -3
- package/dist/runtime/server/provider-write.js +2 -4
- package/dist/runtime/shared/guards.d.ts +2 -0
- package/dist/runtime/shared/guards.js +3 -0
- package/dist/runtime/vue/providers-panel-model.d.ts +147 -0
- package/dist/runtime/vue/providers-panel-model.js +37 -0
- package/dist/runtime/vue/useProvidersPanel.d.ts +5 -122
- package/dist/runtime/vue/useProvidersPanel.js +15 -37
- package/package.json +2 -2
- package/src/runtime/ai-sdk/index.ts +9 -12
- package/src/runtime/editor/index.ts +4 -15
- package/src/runtime/model/errors.ts +21 -0
- package/src/runtime/server/credential-policy.ts +3 -1
- package/src/runtime/server/credential-store.ts +1 -1
- package/src/runtime/server/env-layers.ts +1 -4
- package/src/runtime/server/harness-settings.ts +7 -0
- package/src/runtime/server/provider-discovery.ts +1 -1
- package/src/runtime/server/provider-layers.ts +1 -4
- package/src/runtime/server/provider-write.ts +2 -7
- package/src/runtime/shared/guards.ts +4 -0
- package/src/runtime/vue/providers-panel-model.ts +203 -0
- 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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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 && !
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
|
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 =
|
|
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 =
|
|
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,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
|
+
};
|