@skaile/forge-settings 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +94 -2
- package/dist/module.d.mts +17 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +23 -0
- package/dist/runtime/ai-sdk/index.d.ts +45 -0
- package/dist/runtime/ai-sdk/index.js +39 -0
- package/dist/runtime/components/ForgeProvidersPanel.d.vue.ts +21 -0
- package/dist/runtime/components/ForgeProvidersPanel.vue +435 -0
- package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +21 -0
- package/dist/runtime/editor/index.d.ts +58 -0
- package/dist/runtime/editor/index.js +83 -0
- package/dist/runtime/model/errors.d.ts +24 -0
- package/dist/runtime/model/errors.js +10 -0
- package/dist/runtime/server/credential-policy.d.ts +60 -0
- package/dist/runtime/server/credential-policy.js +64 -0
- package/dist/runtime/server/credential-store.d.ts +184 -0
- package/dist/runtime/server/credential-store.js +234 -0
- package/dist/runtime/server/env-layers.d.ts +62 -0
- package/dist/runtime/server/env-layers.js +185 -0
- package/dist/runtime/server/env.d.ts +30 -0
- package/dist/runtime/server/env.js +15 -0
- package/dist/runtime/server/harness-settings.d.ts +150 -0
- package/dist/runtime/server/harness-settings.js +166 -0
- package/dist/runtime/server/index.d.ts +23 -0
- package/dist/runtime/server/index.js +14 -0
- package/dist/runtime/server/provider-discovery.d.ts +73 -0
- package/dist/runtime/server/provider-discovery.js +77 -0
- package/dist/runtime/server/provider-layers.d.ts +27 -0
- package/dist/runtime/server/provider-layers.js +37 -0
- package/dist/runtime/server/provider-service.d.ts +126 -0
- package/dist/runtime/server/provider-service.js +87 -0
- package/dist/runtime/server/provider-settings.d.ts +78 -0
- package/dist/runtime/server/provider-settings.js +81 -0
- package/dist/runtime/server/provider-write.d.ts +154 -0
- package/dist/runtime/server/provider-write.js +223 -0
- package/dist/runtime/server/settings-defaults.d.ts +26 -0
- package/dist/runtime/server/settings-defaults.js +46 -0
- package/dist/runtime/shared/env-vars.d.ts +55 -0
- package/dist/runtime/shared/env-vars.js +22 -0
- package/dist/runtime/shared/harness-providers.d.ts +62 -0
- package/dist/runtime/shared/harness-providers.js +38 -0
- package/dist/runtime/shared/harnesses.d.ts +19 -0
- package/dist/runtime/shared/harnesses.js +15 -0
- package/dist/runtime/shared/provider-registry.d.ts +255 -0
- package/dist/runtime/shared/provider-registry.js +112 -0
- package/dist/runtime/shared/settings-types.d.ts +84 -0
- package/dist/runtime/shared/settings-types.js +0 -0
- package/dist/runtime/shims-vue.d.ts +8 -0
- package/dist/runtime/vue/index.d.ts +14 -0
- package/dist/runtime/vue/index.js +6 -0
- package/dist/runtime/vue/settings-modal-scope.d.ts +62 -0
- package/dist/runtime/vue/settings-modal-scope.js +2 -0
- package/dist/runtime/vue/useProvidersPanel.d.ts +232 -0
- package/dist/runtime/vue/useProvidersPanel.js +655 -0
- package/dist/types.d.mts +9 -0
- package/package.json +117 -4
- package/src/runtime/ai-sdk/index.ts +91 -0
- package/src/runtime/components/ForgeProvidersPanel.vue +449 -0
- package/src/runtime/editor/index.ts +157 -0
- package/src/runtime/model/errors.ts +31 -0
- package/src/runtime/server/credential-policy.ts +137 -0
- package/src/runtime/server/credential-store.ts +473 -0
- package/src/runtime/server/env-layers.ts +290 -0
- package/src/runtime/server/env.ts +46 -0
- package/src/runtime/server/harness-settings.ts +373 -0
- package/src/runtime/server/index.ts +24 -0
- package/src/runtime/server/provider-discovery.ts +164 -0
- package/src/runtime/server/provider-layers.ts +70 -0
- package/src/runtime/server/provider-service.ts +244 -0
- package/src/runtime/server/provider-settings.ts +192 -0
- package/src/runtime/server/provider-write.ts +438 -0
- package/src/runtime/server/settings-defaults.ts +87 -0
- package/src/runtime/shared/env-vars.ts +82 -0
- package/src/runtime/shared/harness-providers.ts +118 -0
- package/src/runtime/shared/harnesses.ts +34 -0
- package/src/runtime/shared/provider-registry.ts +391 -0
- package/src/runtime/shared/settings-types.ts +87 -0
- package/src/runtime/shims-vue.d.ts +8 -0
- package/src/runtime/vue/index.ts +15 -0
- package/src/runtime/vue/settings-modal-scope.ts +66 -0
- package/src/runtime/vue/useProvidersPanel.ts +1087 -0
|
@@ -0,0 +1,1087 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared brain for `components-nuxt-ui/ForgeProvidersPanel.vue` — the settings
|
|
3
|
+
* modal's Providers tab.
|
|
4
|
+
*
|
|
5
|
+
* It drives the registry editor: loading, the add/edit form, the delete confirmation,
|
|
6
|
+
* the "detected in environment" one-click adds, the per-row credential picker,
|
|
7
|
+
* the enabled toggle and every request live here.
|
|
8
|
+
*
|
|
9
|
+
* The panel writes one registry document — the scope's own `layer` — through
|
|
10
|
+
* `PUT|DELETE ${providersWriteUrl}/:id`. An entry a lower layer defines can be
|
|
11
|
+
* patched at workspace scope (`credential` / `enabled` only) and reset by
|
|
12
|
+
* deleting the patch. A key typed into the panel goes to the scope's `.env`
|
|
13
|
+
* route only; the provider route receives an `env:NAME` reference, never a
|
|
14
|
+
* value.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { $fetch } from "ofetch";
|
|
18
|
+
import {
|
|
19
|
+
type ComputedRef,
|
|
20
|
+
computed,
|
|
21
|
+
onMounted,
|
|
22
|
+
type Ref,
|
|
23
|
+
reactive,
|
|
24
|
+
ref,
|
|
25
|
+
type WritableComputedRef,
|
|
26
|
+
watch,
|
|
27
|
+
} from "vue";
|
|
28
|
+
import {
|
|
29
|
+
type EnvLayerId,
|
|
30
|
+
type EnvSettingsDocument,
|
|
31
|
+
type EnvSettingsUpdate,
|
|
32
|
+
type EnvVarView,
|
|
33
|
+
validateEnvVarName,
|
|
34
|
+
} from "../shared/env-vars";
|
|
35
|
+
import {
|
|
36
|
+
AI_PROVIDER_SURFACES,
|
|
37
|
+
type AIProviderCredentialKind,
|
|
38
|
+
type AIProviderKind,
|
|
39
|
+
type AIProviderSurface,
|
|
40
|
+
validateCredentialRef,
|
|
41
|
+
} from "../shared/provider-registry";
|
|
42
|
+
|
|
43
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/** Registry document a provider entry is defined in. */
|
|
46
|
+
export type ProvidersPanelLayer = "instance" | "workspace";
|
|
47
|
+
|
|
48
|
+
/** Props both `ForgeProvidersPanel` variants declare. */
|
|
49
|
+
export interface ProvidersPanelProps {
|
|
50
|
+
/** `GET` merged view for this scope (`/api/providers` | `/api/workspaces/:id/providers`). */
|
|
51
|
+
providersUrl: string;
|
|
52
|
+
/** `PUT|DELETE ${providersWriteUrl}/:id`; `GET ${providersWriteUrl}/detected`. */
|
|
53
|
+
providersWriteUrl: string;
|
|
54
|
+
/** Catalog preset summaries. Default `/api/providers/presets`. */
|
|
55
|
+
presetsUrl?: string;
|
|
56
|
+
/**
|
|
57
|
+
* The scope's `.env` route. `GET` on load and after every write; keys typed
|
|
58
|
+
* into the panel write through it. Omitted, no key can be entered.
|
|
59
|
+
*/
|
|
60
|
+
environmentUrl?: string;
|
|
61
|
+
/** The scope's own registry document. */
|
|
62
|
+
layer: ProvidersPanelLayer;
|
|
63
|
+
/** Disables every write (non-admin at instance scope; provider lock). */
|
|
64
|
+
readOnly?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Emits both variants declare. */
|
|
68
|
+
export type ProvidersPanelEmit = (event: "changed") => void;
|
|
69
|
+
|
|
70
|
+
/** One entry of `GET ${providersUrl}?all=1`. */
|
|
71
|
+
export interface ProvidersPanelEntry {
|
|
72
|
+
id: string;
|
|
73
|
+
/** The entry's label, else its id. */
|
|
74
|
+
name: string;
|
|
75
|
+
kind: AIProviderKind;
|
|
76
|
+
/** Whether the credential reference resolved on the server. */
|
|
77
|
+
configured: boolean;
|
|
78
|
+
/** Why it did not resolve. Secret-free. */
|
|
79
|
+
error?: string;
|
|
80
|
+
credentialRef?: string;
|
|
81
|
+
credentialKind?: AIProviderCredentialKind;
|
|
82
|
+
hasEnvKey: boolean;
|
|
83
|
+
envKeyName?: string;
|
|
84
|
+
baseUrl?: string;
|
|
85
|
+
preset?: string;
|
|
86
|
+
surface?: AIProviderSurface;
|
|
87
|
+
enabled: boolean;
|
|
88
|
+
locked: boolean;
|
|
89
|
+
/** Registry documents holding the id, lowest first: `["instance"]`, `["workspace"]` or both. */
|
|
90
|
+
layers: ProvidersPanelLayer[];
|
|
91
|
+
/** The scope's own document's values for the editable fields the dialog does not show. */
|
|
92
|
+
layerFields?: Record<string, unknown>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One catalog preset, as `GET /api/providers/presets` and `.../detected` list it. */
|
|
96
|
+
export interface ProvidersPanelPreset {
|
|
97
|
+
id: string;
|
|
98
|
+
label: string;
|
|
99
|
+
kind: AIProviderKind;
|
|
100
|
+
surface?: AIProviderSurface;
|
|
101
|
+
base_url?: string;
|
|
102
|
+
base_url_env?: string;
|
|
103
|
+
credential: { kind: AIProviderCredentialKind; env?: string };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Where a row's entry lives relative to the scope's own document. */
|
|
107
|
+
export type ProvidersPanelRowState = "own" | "inherited" | "patched";
|
|
108
|
+
|
|
109
|
+
/** One entry of a row's credential select. */
|
|
110
|
+
export interface ProvidersPanelCredentialOption {
|
|
111
|
+
/** An env var NAME, a literal non-env ref, {@link CREDENTIAL_ENTER_KEY} or {@link CREDENTIAL_OTHER_REF}. */
|
|
112
|
+
value: string;
|
|
113
|
+
/** `NAME ••••hint` | `NAME (not set)` | the literal ref | `Enter a key…` | `Other reference…`. */
|
|
114
|
+
label: string;
|
|
115
|
+
defined?: boolean;
|
|
116
|
+
hint?: string;
|
|
117
|
+
source?: EnvLayerId;
|
|
118
|
+
action?: "enter-key" | "other-ref";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A table row: the entry plus what the panel derives for it. */
|
|
122
|
+
export interface ProvidersPanelRow extends ProvidersPanelEntry {
|
|
123
|
+
/** Own entry, an inherited one, or an inherited one this scope patched. */
|
|
124
|
+
state: ProvidersPanelRowState;
|
|
125
|
+
/** `instance` | `workspace` | `instance · overridden here`. */
|
|
126
|
+
badge: string;
|
|
127
|
+
/** Set on inherited rows. */
|
|
128
|
+
inheritedNote?: string;
|
|
129
|
+
/** Edit/Delete offered: the scope's document defines it and the panel is writable. */
|
|
130
|
+
editable: boolean;
|
|
131
|
+
/** Reset offered: a patched row of a writable panel. */
|
|
132
|
+
canReset: boolean;
|
|
133
|
+
/** The enabled switch is live. */
|
|
134
|
+
canToggle: boolean;
|
|
135
|
+
/** The entry takes a credential (its kind is not `none`). */
|
|
136
|
+
needsCredential: boolean;
|
|
137
|
+
/** The credential picker is live. */
|
|
138
|
+
canEditCredential: boolean;
|
|
139
|
+
/** The select's model: the env var NAME of an `env:` ref, else the literal ref, else `""`. */
|
|
140
|
+
credentialValue: string;
|
|
141
|
+
credentialOptions: ProvidersPanelCredentialOption[];
|
|
142
|
+
/** The preset it inherits, else its surface, else its kind. */
|
|
143
|
+
origin: string;
|
|
144
|
+
/** `configured · <source>`, `missing <VAR>`, the resolver's reason, or `No credential needed.`. */
|
|
145
|
+
status: string;
|
|
146
|
+
/** Which other layer defines or overrides the credential; see the panel contract. */
|
|
147
|
+
overrideNotices: string[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The add/edit dialog's fields. */
|
|
151
|
+
export interface ProvidersPanelForm {
|
|
152
|
+
/** A preset id, or {@link CUSTOM_PRESET}. */
|
|
153
|
+
preset: string;
|
|
154
|
+
id: string;
|
|
155
|
+
/** Custom entries only. */
|
|
156
|
+
surface: string;
|
|
157
|
+
/** Empty: the preset's endpoint. */
|
|
158
|
+
baseUrl: string;
|
|
159
|
+
/** Adding only: the `env:` variable the credential references. */
|
|
160
|
+
envName: string;
|
|
161
|
+
/** Adding only, "Set value now": written to the scope's `.env`, never to the provider route. */
|
|
162
|
+
secret: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
/** The preset select's value for an entry that names no preset. */
|
|
168
|
+
export const CUSTOM_PRESET = "__custom__";
|
|
169
|
+
|
|
170
|
+
/** Credential select value: open the inline key editor. */
|
|
171
|
+
export const CREDENTIAL_ENTER_KEY = "__enter_key__";
|
|
172
|
+
|
|
173
|
+
/** Credential select value: open the inline reference editor. */
|
|
174
|
+
export const CREDENTIAL_OTHER_REF = "__other_ref__";
|
|
175
|
+
|
|
176
|
+
/** Registry ids the write routes accept (`isSafeProviderId` upstream). */
|
|
177
|
+
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
|
|
178
|
+
|
|
179
|
+
const DEFAULT_PRESETS_URL = "/api/providers/presets";
|
|
180
|
+
|
|
181
|
+
/** An `env:NAME` credential reference; group 1 is the variable name. */
|
|
182
|
+
const ENV_REF_PATTERN = /^env:(.+)$/;
|
|
183
|
+
|
|
184
|
+
/** The credential kind a written reference gets when the row states none. */
|
|
185
|
+
const FALLBACK_CREDENTIAL_KIND: AIProviderCredentialKind = "api_key_header";
|
|
186
|
+
|
|
187
|
+
const ENV_SOURCE_LABEL: Record<EnvLayerId, string> = {
|
|
188
|
+
instance: "instance .env",
|
|
189
|
+
workspace: "workspace .env",
|
|
190
|
+
process: "boot environment",
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const OVERRIDES_INSTANCE_NOTICE =
|
|
194
|
+
"Entering a key here stores it in this workspace's .env and overrides that value for this workspace.";
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The server's refusal for a failed request, else `fallback`. A registry
|
|
198
|
+
* rejection (`400` with `data.diagnostics`) reads as its diagnostics' messages.
|
|
199
|
+
*/
|
|
200
|
+
function requestErrorMessage(err: unknown, fallback: string): string {
|
|
201
|
+
const e = err as {
|
|
202
|
+
data?: { statusMessage?: unknown; data?: { diagnostics?: Array<{ message?: unknown }> } };
|
|
203
|
+
statusMessage?: unknown;
|
|
204
|
+
} | null;
|
|
205
|
+
const details = (e?.data?.data?.diagnostics ?? [])
|
|
206
|
+
.map((d) => d.message)
|
|
207
|
+
.filter((m): m is string => typeof m === "string" && m !== "");
|
|
208
|
+
if (details.length > 0) return details.join("; ");
|
|
209
|
+
const message = e?.data?.statusMessage ?? e?.statusMessage;
|
|
210
|
+
return typeof message === "string" && message ? message : fallback;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The variable name of an `env:NAME` reference. */
|
|
214
|
+
function envNameOfRef(ref: string | undefined): string | undefined {
|
|
215
|
+
return ref ? ENV_REF_PATTERN.exec(ref)?.[1] : undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** `configured · …` for a reference that is not an env var. */
|
|
219
|
+
function nonEnvSourceLabel(ref: string): string | undefined {
|
|
220
|
+
if (ref.startsWith("keyring:")) return "keyring";
|
|
221
|
+
if (ref.startsWith("cmd:")) return "runtime-owned (cmd)";
|
|
222
|
+
if (ref.startsWith("platform:")) return "control-plane-owned (platform)";
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const EMPTY_FORM: Readonly<ProvidersPanelForm> = {
|
|
227
|
+
preset: "",
|
|
228
|
+
id: "",
|
|
229
|
+
surface: "",
|
|
230
|
+
baseUrl: "",
|
|
231
|
+
envName: "",
|
|
232
|
+
secret: "",
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/** What both `ForgeProvidersPanel` variants bind to. */
|
|
236
|
+
export interface ProvidersPanelState {
|
|
237
|
+
/** The scope's entries, disabled ones included, in registry order. */
|
|
238
|
+
rows: ComputedRef<ProvidersPanelRow[]>;
|
|
239
|
+
loading: Ref<boolean>;
|
|
240
|
+
loadError: Ref<string | null>;
|
|
241
|
+
/** Re-read the entries, the detected presets and the environment document. */
|
|
242
|
+
refresh: () => Promise<void>;
|
|
243
|
+
|
|
244
|
+
dialogVisible: Ref<boolean>;
|
|
245
|
+
dialogTitle: ComputedRef<string>;
|
|
246
|
+
/** The row being edited; `null` while adding. */
|
|
247
|
+
editing: Ref<ProvidersPanelEntry | null>;
|
|
248
|
+
form: ProvidersPanelForm;
|
|
249
|
+
/** A refusal from the form check or either request, shown in the dialog. */
|
|
250
|
+
dialogError: Ref<string | null>;
|
|
251
|
+
saving: Ref<boolean>;
|
|
252
|
+
/** Catalog presets plus "Custom" ({@link CUSTOM_PRESET}). */
|
|
253
|
+
presetOptions: ComputedRef<Array<{ label: string; value: string }>>;
|
|
254
|
+
surfaceOptions: Array<{ label: string; value: AIProviderSurface }>;
|
|
255
|
+
isCustom: ComputedRef<boolean>;
|
|
256
|
+
/** The surface select is shown: a custom `llm`/`embed` entry. */
|
|
257
|
+
showSurface: ComputedRef<boolean>;
|
|
258
|
+
baseUrlPlaceholder: ComputedRef<string>;
|
|
259
|
+
/** "Set value now" is offered: the scope has an `.env` route. */
|
|
260
|
+
canSetValue: ComputedRef<boolean>;
|
|
261
|
+
/** The dialog shows the env name and secret fields: adding, not editing. */
|
|
262
|
+
showCredentialFields: ComputedRef<boolean>;
|
|
263
|
+
idError: ComputedRef<string | null>;
|
|
264
|
+
openAdd: (presetId?: string) => void;
|
|
265
|
+
openEdit: (row: ProvidersPanelEntry) => void;
|
|
266
|
+
selectPreset: (value: string) => void;
|
|
267
|
+
saveProvider: () => Promise<void>;
|
|
268
|
+
|
|
269
|
+
deleteTarget: Ref<ProvidersPanelEntry | null>;
|
|
270
|
+
/** The confirmation dialog's `v-model`; closing it cancels. */
|
|
271
|
+
deleteOpen: WritableComputedRef<boolean>;
|
|
272
|
+
deleteError: Ref<string | null>;
|
|
273
|
+
deleting: Ref<boolean>;
|
|
274
|
+
requestDelete: (row: ProvidersPanelEntry) => void;
|
|
275
|
+
confirmDelete: () => Promise<void>;
|
|
276
|
+
|
|
277
|
+
/** Presets whose key is set in the scope's env and that no entry names. */
|
|
278
|
+
detected: Ref<ProvidersPanelPreset[]>;
|
|
279
|
+
/** Preset id whose one-click add is in flight. */
|
|
280
|
+
detectedBusy: Ref<string | null>;
|
|
281
|
+
detectedError: Ref<string | null>;
|
|
282
|
+
addDetected: (preset: ProvidersPanelPreset) => Promise<void>;
|
|
283
|
+
|
|
284
|
+
// Row actions
|
|
285
|
+
/** The credential select's `@update`: a NAME writes, the two actions open an editor. */
|
|
286
|
+
onCredentialSelect: (row: ProvidersPanelRow, value: string) => void;
|
|
287
|
+
setCredentialEnv: (row: ProvidersPanelRow, name: string) => Promise<void>;
|
|
288
|
+
setEnabled: (row: ProvidersPanelRow, enabled: boolean) => Promise<void>;
|
|
289
|
+
/** Delete the workspace patch of an inherited entry. */
|
|
290
|
+
resetPatch: (row: ProvidersPanelRow) => Promise<void>;
|
|
291
|
+
/** Row id with a write in flight. */
|
|
292
|
+
rowBusy: Ref<string | null>;
|
|
293
|
+
rowError: Ref<{ id: string; message: string } | null>;
|
|
294
|
+
|
|
295
|
+
// Inline key editor
|
|
296
|
+
/** "Enter a key…" is offered: the scope has an editable `.env` route. */
|
|
297
|
+
canEnterKey: ComputedRef<boolean>;
|
|
298
|
+
/** Row id whose key editor is open. */
|
|
299
|
+
keyEditing: Ref<string | null>;
|
|
300
|
+
keyForm: { name: string; value: string };
|
|
301
|
+
keyError: Ref<string | null>;
|
|
302
|
+
openKeyEditor: (row: ProvidersPanelRow) => void;
|
|
303
|
+
cancelKeyEditor: () => void;
|
|
304
|
+
saveKeyEditor: () => Promise<void>;
|
|
305
|
+
|
|
306
|
+
// Inline reference editor
|
|
307
|
+
/** Row id whose reference editor is open. */
|
|
308
|
+
refEditing: Ref<string | null>;
|
|
309
|
+
refForm: { ref: string };
|
|
310
|
+
refError: Ref<string | null>;
|
|
311
|
+
openRefEditor: (row: ProvidersPanelRow) => void;
|
|
312
|
+
cancelRefEditor: () => void;
|
|
313
|
+
saveRefEditor: () => Promise<void>;
|
|
314
|
+
|
|
315
|
+
// Environment
|
|
316
|
+
envDocument: Ref<EnvSettingsDocument | null>;
|
|
317
|
+
/** The environment document says the caller may write its layer. */
|
|
318
|
+
envEditable: ComputedRef<boolean>;
|
|
319
|
+
/** Draft new values per variable name. */
|
|
320
|
+
envValues: Record<string, string>;
|
|
321
|
+
envError: Ref<string | null>;
|
|
322
|
+
envBusy: Ref<boolean>;
|
|
323
|
+
setEnvValue: (name: string) => Promise<void>;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Composable ───────────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
export function useProvidersPanel(
|
|
329
|
+
props: ProvidersPanelProps,
|
|
330
|
+
emit: ProvidersPanelEmit,
|
|
331
|
+
): ProvidersPanelState {
|
|
332
|
+
const providers = ref<ProvidersPanelEntry[]>([]);
|
|
333
|
+
const presets = ref<ProvidersPanelPreset[]>([]);
|
|
334
|
+
const detected = ref<ProvidersPanelPreset[]>([]);
|
|
335
|
+
const envDocument = ref<EnvSettingsDocument | null>(null);
|
|
336
|
+
const loading = ref(false);
|
|
337
|
+
const loadError = ref<string | null>(null);
|
|
338
|
+
const envError = ref<string | null>(null);
|
|
339
|
+
|
|
340
|
+
// ── Loading ────────────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
/** Read the scope's `.env` document. A failure shows in the disclosure and blocks nothing else. */
|
|
343
|
+
async function loadEnvironment() {
|
|
344
|
+
if (!props.environmentUrl) {
|
|
345
|
+
envDocument.value = null;
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
envDocument.value = (await $fetch(props.environmentUrl)) as EnvSettingsDocument;
|
|
350
|
+
envError.value = null;
|
|
351
|
+
} catch (err) {
|
|
352
|
+
envError.value = requestErrorMessage(err, "Could not load the environment variables.");
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Re-read the entries (disabled ones included), the detected presets and the environment. */
|
|
357
|
+
async function refresh() {
|
|
358
|
+
loading.value = true;
|
|
359
|
+
loadError.value = null;
|
|
360
|
+
try {
|
|
361
|
+
const [list, found] = await Promise.all([
|
|
362
|
+
$fetch(props.providersUrl, { query: { all: "1" } }) as Promise<ProvidersPanelEntry[]>,
|
|
363
|
+
$fetch(`${props.providersWriteUrl}/detected`) as Promise<ProvidersPanelPreset[]>,
|
|
364
|
+
loadEnvironment(),
|
|
365
|
+
]);
|
|
366
|
+
providers.value = Array.isArray(list) ? list : [];
|
|
367
|
+
detected.value = Array.isArray(found) ? found : [];
|
|
368
|
+
} catch (err) {
|
|
369
|
+
loadError.value = requestErrorMessage(err, "Could not load the providers.");
|
|
370
|
+
} finally {
|
|
371
|
+
loading.value = false;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function loadPresets() {
|
|
376
|
+
try {
|
|
377
|
+
const list = (await $fetch(
|
|
378
|
+
props.presetsUrl ?? DEFAULT_PRESETS_URL,
|
|
379
|
+
)) as ProvidersPanelPreset[];
|
|
380
|
+
presets.value = Array.isArray(list) ? list : [];
|
|
381
|
+
} catch (err) {
|
|
382
|
+
loadError.value = requestErrorMessage(err, "Could not load the provider presets.");
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** After every successful write: reload list, banner and environment, tell the host. */
|
|
387
|
+
async function afterWrite() {
|
|
388
|
+
await refresh();
|
|
389
|
+
emit("changed");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
onMounted(() => {
|
|
393
|
+
void loadPresets();
|
|
394
|
+
void refresh();
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
watch(
|
|
398
|
+
() => [props.providersUrl, props.providersWriteUrl, props.environmentUrl],
|
|
399
|
+
() => void refresh(),
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
// ── Table ──────────────────────────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
const envVars = computed(() => {
|
|
405
|
+
const byName = new Map<string, EnvVarView>();
|
|
406
|
+
for (const v of envDocument.value?.vars ?? []) byName.set(v.name, v);
|
|
407
|
+
return byName;
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const envEditable = computed(() => envDocument.value?.editable === true);
|
|
411
|
+
|
|
412
|
+
const canEnterKey = computed(
|
|
413
|
+
() => Boolean(props.environmentUrl) && envDocument.value?.editable !== false,
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
/** Where an entry lives relative to this scope's own document. */
|
|
417
|
+
function stateOf(entry: Pick<ProvidersPanelEntry, "layers">): ProvidersPanelRowState {
|
|
418
|
+
if (props.layer !== "workspace" || !entry.layers.includes("instance")) return "own";
|
|
419
|
+
return entry.layers.includes("workspace") ? "patched" : "inherited";
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Whether the scope's own document defines the entry and the panel may write it. */
|
|
423
|
+
function isEditable(entry: ProvidersPanelEntry): boolean {
|
|
424
|
+
return !props.readOnly && !entry.locked && stateOf(entry) === "own";
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** The select's options: every env var name worth offering, then the literal ref and the two actions. */
|
|
428
|
+
function credentialOptionsOf(entry: ProvidersPanelEntry): ProvidersPanelCredentialOption[] {
|
|
429
|
+
const doc = envDocument.value;
|
|
430
|
+
const currentEnv = envNameOfRef(entry.credentialRef);
|
|
431
|
+
const names = new Set<string>();
|
|
432
|
+
for (const v of doc?.vars ?? []) names.add(v.name);
|
|
433
|
+
for (const m of doc?.missing ?? []) names.add(m.name);
|
|
434
|
+
const rowPresetEnv = presets.value.find((p) => p.id === entry.preset)?.credential.env;
|
|
435
|
+
if (rowPresetEnv) names.add(rowPresetEnv);
|
|
436
|
+
for (const p of detected.value) if (p.credential.env) names.add(p.credential.env);
|
|
437
|
+
if (currentEnv) names.add(currentEnv);
|
|
438
|
+
|
|
439
|
+
const options: ProvidersPanelCredentialOption[] = [...names].sort().map((name) => {
|
|
440
|
+
const v = envVars.value.get(name);
|
|
441
|
+
if (!doc) return { value: name, label: name };
|
|
442
|
+
if (!v) return { value: name, label: `${name} (not set)`, defined: false };
|
|
443
|
+
return {
|
|
444
|
+
value: name,
|
|
445
|
+
label: v.hint ? `${name} ••••${v.hint}` : name,
|
|
446
|
+
defined: true,
|
|
447
|
+
hint: v.hint,
|
|
448
|
+
source: v.source,
|
|
449
|
+
};
|
|
450
|
+
});
|
|
451
|
+
if (entry.credentialRef && !currentEnv) {
|
|
452
|
+
options.push({ value: entry.credentialRef, label: entry.credentialRef });
|
|
453
|
+
}
|
|
454
|
+
if (canEnterKey.value) {
|
|
455
|
+
options.push({ value: CREDENTIAL_ENTER_KEY, label: "Enter a key…", action: "enter-key" });
|
|
456
|
+
}
|
|
457
|
+
options.push({ value: CREDENTIAL_OTHER_REF, label: "Other reference…", action: "other-ref" });
|
|
458
|
+
return options;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function statusOf(entry: ProvidersPanelEntry, needsCredential: boolean): string {
|
|
462
|
+
if (!needsCredential) return "No credential needed.";
|
|
463
|
+
const ref = entry.credentialRef;
|
|
464
|
+
const envName = envNameOfRef(ref);
|
|
465
|
+
if (entry.configured) {
|
|
466
|
+
const source = envName
|
|
467
|
+
? (() => {
|
|
468
|
+
const v = envVars.value.get(envName);
|
|
469
|
+
return v ? ENV_SOURCE_LABEL[v.source] : undefined;
|
|
470
|
+
})()
|
|
471
|
+
: ref
|
|
472
|
+
? nonEnvSourceLabel(ref)
|
|
473
|
+
: undefined;
|
|
474
|
+
return source ? `configured · ${source}` : "configured";
|
|
475
|
+
}
|
|
476
|
+
const missing = envName ?? entry.envKeyName;
|
|
477
|
+
return missing ? `missing ${missing}` : (entry.error ?? "not configured");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function overrideNoticesOf(entry: ProvidersPanelEntry): string[] {
|
|
481
|
+
const notices: string[] = [];
|
|
482
|
+
const envName = envNameOfRef(entry.credentialRef);
|
|
483
|
+
const v = envName ? envVars.value.get(envName) : undefined;
|
|
484
|
+
if (v && v.shadows.length > 0) {
|
|
485
|
+
notices.push(`Also defined in ${v.shadows.join(", ")}; the ${v.source} value is used.`);
|
|
486
|
+
}
|
|
487
|
+
if (v && props.layer === "workspace" && (v.source === "instance" || v.source === "process")) {
|
|
488
|
+
notices.push(OVERRIDES_INSTANCE_NOTICE);
|
|
489
|
+
}
|
|
490
|
+
if (entry.locked) notices.push("Locked by the control plane.");
|
|
491
|
+
return notices;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function buildRow(entry: ProvidersPanelEntry): ProvidersPanelRow {
|
|
495
|
+
const state = stateOf(entry);
|
|
496
|
+
const writable = !props.readOnly && !entry.locked;
|
|
497
|
+
const needsCredential = entry.credentialKind !== "none";
|
|
498
|
+
const envName = envNameOfRef(entry.credentialRef);
|
|
499
|
+
return {
|
|
500
|
+
...entry,
|
|
501
|
+
state,
|
|
502
|
+
badge:
|
|
503
|
+
state === "patched"
|
|
504
|
+
? "instance · overridden here"
|
|
505
|
+
: state === "inherited"
|
|
506
|
+
? "instance"
|
|
507
|
+
: props.layer,
|
|
508
|
+
inheritedNote: state === "inherited" ? "Inherited from the instance." : undefined,
|
|
509
|
+
editable: writable && state === "own",
|
|
510
|
+
canReset: writable && state === "patched",
|
|
511
|
+
canToggle: writable,
|
|
512
|
+
needsCredential,
|
|
513
|
+
canEditCredential: needsCredential && writable,
|
|
514
|
+
credentialValue: envName ?? entry.credentialRef ?? "",
|
|
515
|
+
credentialOptions: needsCredential ? credentialOptionsOf(entry) : [],
|
|
516
|
+
origin: entry.preset ?? entry.surface ?? entry.kind,
|
|
517
|
+
status: statusOf(entry, needsCredential),
|
|
518
|
+
overrideNotices: overrideNoticesOf(entry),
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const rows = computed<ProvidersPanelRow[]>(() => providers.value.map(buildRow));
|
|
523
|
+
|
|
524
|
+
// ── Add / edit dialog ──────────────────────────────────────────────────────
|
|
525
|
+
|
|
526
|
+
const dialogVisible = ref(false);
|
|
527
|
+
/** The row being edited; `null` while adding. */
|
|
528
|
+
const editing = ref<ProvidersPanelEntry | null>(null);
|
|
529
|
+
const form = reactive<ProvidersPanelForm>({ ...EMPTY_FORM });
|
|
530
|
+
const dialogError = ref<string | null>(null);
|
|
531
|
+
const saving = ref(false);
|
|
532
|
+
|
|
533
|
+
const presetOptions = computed(() => [
|
|
534
|
+
...presets.value.map((preset) => ({ label: preset.label, value: preset.id })),
|
|
535
|
+
{ label: "Custom", value: CUSTOM_PRESET },
|
|
536
|
+
]);
|
|
537
|
+
|
|
538
|
+
const surfaceOptions = AI_PROVIDER_SURFACES.map((surface) => ({
|
|
539
|
+
label: surface,
|
|
540
|
+
value: surface,
|
|
541
|
+
}));
|
|
542
|
+
|
|
543
|
+
const selectedPreset = computed(() => presets.value.find((preset) => preset.id === form.preset));
|
|
544
|
+
|
|
545
|
+
const isCustom = computed(() => form.preset === CUSTOM_PRESET);
|
|
546
|
+
|
|
547
|
+
/** Kind of a custom entry: the edited one's, else `llm`. */
|
|
548
|
+
const customKind = computed<AIProviderKind>(() => editing.value?.kind ?? "llm");
|
|
549
|
+
|
|
550
|
+
/** Custom `llm`/`embed` entries name their wire protocol; voice entries have none. */
|
|
551
|
+
const showSurface = computed(
|
|
552
|
+
() => isCustom.value && (customKind.value === "llm" || customKind.value === "embed"),
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
const baseUrlPlaceholder = computed(
|
|
556
|
+
() => selectedPreset.value?.base_url ?? "https://api.example.com/v1",
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
/** Whether "Set value now" is offered: the scope has an `.env` route. */
|
|
560
|
+
const canSetValue = computed(() => Boolean(props.environmentUrl));
|
|
561
|
+
|
|
562
|
+
/** The dialog collects a credential for a new entry only. */
|
|
563
|
+
const showCredentialFields = computed(() => editing.value === null);
|
|
564
|
+
|
|
565
|
+
const idError = computed(() => {
|
|
566
|
+
const id = form.id.trim();
|
|
567
|
+
return id && !PROVIDER_ID_PATTERN.test(id)
|
|
568
|
+
? "Use lower-case letters, digits, '-' and '_', starting with a letter or digit."
|
|
569
|
+
: null;
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
const dialogTitle = computed(() =>
|
|
573
|
+
editing.value ? `Edit provider — ${editing.value.id}` : "Add provider",
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
function resetDialog(values: Partial<ProvidersPanelForm>, row: ProvidersPanelEntry | null) {
|
|
577
|
+
Object.assign(form, EMPTY_FORM, values);
|
|
578
|
+
editing.value = row;
|
|
579
|
+
dialogError.value = null;
|
|
580
|
+
dialogVisible.value = true;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Open the add dialog, optionally on a preset. */
|
|
584
|
+
function openAdd(presetId?: string) {
|
|
585
|
+
if (props.readOnly) return;
|
|
586
|
+
resetDialog({}, null);
|
|
587
|
+
if (presetId) selectPreset(presetId);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Open the dialog on an entry this scope's document defines; its id stays fixed. */
|
|
591
|
+
function openEdit(row: ProvidersPanelEntry) {
|
|
592
|
+
if (!isEditable(row)) return;
|
|
593
|
+
const preset = presets.value.find((p) => p.id === row.preset);
|
|
594
|
+
resetDialog(
|
|
595
|
+
{
|
|
596
|
+
preset: preset ? preset.id : CUSTOM_PRESET,
|
|
597
|
+
id: row.id,
|
|
598
|
+
surface: row.surface ?? "",
|
|
599
|
+
// The preset's own endpoint shows as the placeholder, not as a pinned value.
|
|
600
|
+
baseUrl: row.baseUrl && row.baseUrl !== preset?.base_url ? row.baseUrl : "",
|
|
601
|
+
},
|
|
602
|
+
row,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Switch the preset. Fields still holding the previous preset's defaults
|
|
608
|
+
* (the id while adding, the env var, a custom surface) follow the new one;
|
|
609
|
+
* anything the user typed is kept.
|
|
610
|
+
*/
|
|
611
|
+
function selectPreset(value: string) {
|
|
612
|
+
const previous = selectedPreset.value;
|
|
613
|
+
const next = presets.value.find((preset) => preset.id === value);
|
|
614
|
+
form.preset = value;
|
|
615
|
+
if (!editing.value && (form.id === "" || form.id === previous?.id)) form.id = next?.id ?? "";
|
|
616
|
+
if (form.envName === "" || form.envName === previous?.credential.env)
|
|
617
|
+
form.envName = next?.credential.env ?? "";
|
|
618
|
+
if (value === CUSTOM_PRESET && form.surface === "") form.surface = previous?.surface ?? "";
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Why the form cannot be saved, or `null`. */
|
|
622
|
+
function formRefusal(): string | null {
|
|
623
|
+
if (!form.preset) return "Choose a preset or Custom.";
|
|
624
|
+
if (!form.id.trim()) return "Enter a provider id.";
|
|
625
|
+
if (idError.value) return idError.value;
|
|
626
|
+
if (showSurface.value && !form.surface) return "Choose the surface a custom provider speaks.";
|
|
627
|
+
if (isCustom.value && !form.baseUrl.trim()) return "A custom provider needs a base URL.";
|
|
628
|
+
if (showCredentialFields.value) {
|
|
629
|
+
const envName = form.envName.trim();
|
|
630
|
+
if (envName) {
|
|
631
|
+
const refusal = validateEnvVarName(envName);
|
|
632
|
+
if (refusal) return refusal;
|
|
633
|
+
}
|
|
634
|
+
if (form.secret && !envName) return "Name the environment variable the value is stored in.";
|
|
635
|
+
}
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* The `PUT ${providersWriteUrl}/:id` body. The write replaces every editable
|
|
641
|
+
* field, so an edit starts from the defining document's values the dialog
|
|
642
|
+
* does not show (`layerFields`) and keeps the stored credential.
|
|
643
|
+
*/
|
|
644
|
+
function buildBody(): Record<string, unknown> {
|
|
645
|
+
const row = editing.value;
|
|
646
|
+
const body: Record<string, unknown> = { ...(row?.layerFields ?? {}) };
|
|
647
|
+
const preset = selectedPreset.value;
|
|
648
|
+
// Facts of the edited entry only carry over while it keeps its preset (or stays custom).
|
|
649
|
+
const kept = row && form.preset === (row.preset ?? CUSTOM_PRESET) ? row : null;
|
|
650
|
+
if (isCustom.value) {
|
|
651
|
+
body.kind = customKind.value;
|
|
652
|
+
if (showSurface.value) body.surface = form.surface;
|
|
653
|
+
} else {
|
|
654
|
+
body.preset = form.preset;
|
|
655
|
+
// A surface the entry overrode over its preset's survives the edit.
|
|
656
|
+
if (kept?.surface && kept.surface !== preset?.surface) body.surface = kept.surface;
|
|
657
|
+
}
|
|
658
|
+
const baseUrl = form.baseUrl.trim();
|
|
659
|
+
if (baseUrl) body.base_url = baseUrl;
|
|
660
|
+
const credentialKind: AIProviderCredentialKind =
|
|
661
|
+
kept?.credentialKind ?? (isCustom.value ? "bearer" : (preset?.credential.kind ?? "bearer"));
|
|
662
|
+
const envName = row ? "" : form.envName.trim();
|
|
663
|
+
if (envName) body.credential = { kind: credentialKind, ref: `env:${envName}` };
|
|
664
|
+
else if (row?.credentialRef) body.credential = { kind: credentialKind, ref: row.credentialRef };
|
|
665
|
+
return body;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Save the dialog. A "Set value now" secret is written first, through the
|
|
670
|
+
* scope's `.env` route as `NAME=value`; only then is the entry — carrying
|
|
671
|
+
* the `env:NAME` reference — written. Refusals from either request stay
|
|
672
|
+
* inline in the dialog.
|
|
673
|
+
*/
|
|
674
|
+
async function saveProvider() {
|
|
675
|
+
if (props.readOnly) return;
|
|
676
|
+
const refusal = formRefusal();
|
|
677
|
+
if (refusal) {
|
|
678
|
+
dialogError.value = refusal;
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
const id = form.id.trim();
|
|
682
|
+
saving.value = true;
|
|
683
|
+
dialogError.value = null;
|
|
684
|
+
try {
|
|
685
|
+
if (showCredentialFields.value && form.secret && props.environmentUrl) {
|
|
686
|
+
const update: EnvSettingsUpdate = {
|
|
687
|
+
set: { [form.envName.trim()]: form.secret },
|
|
688
|
+
unset: [],
|
|
689
|
+
};
|
|
690
|
+
await $fetch(props.environmentUrl, { method: "PUT", body: update });
|
|
691
|
+
form.secret = "";
|
|
692
|
+
}
|
|
693
|
+
await $fetch(`${props.providersWriteUrl}/${encodeURIComponent(id)}`, {
|
|
694
|
+
method: "PUT",
|
|
695
|
+
body: buildBody(),
|
|
696
|
+
});
|
|
697
|
+
dialogVisible.value = false;
|
|
698
|
+
editing.value = null;
|
|
699
|
+
await afterWrite();
|
|
700
|
+
} catch (err) {
|
|
701
|
+
dialogError.value = requestErrorMessage(err, "Could not save the provider.");
|
|
702
|
+
} finally {
|
|
703
|
+
saving.value = false;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// ── Delete ─────────────────────────────────────────────────────────────────
|
|
708
|
+
|
|
709
|
+
const deleteTarget = ref<ProvidersPanelEntry | null>(null);
|
|
710
|
+
const deleteError = ref<string | null>(null);
|
|
711
|
+
const deleting = ref(false);
|
|
712
|
+
|
|
713
|
+
/** The confirm dialog's `v-model`. Closing it cancels the delete. */
|
|
714
|
+
const deleteOpen = computed({
|
|
715
|
+
get: () => deleteTarget.value !== null,
|
|
716
|
+
set: (open: boolean) => {
|
|
717
|
+
if (!open) deleteTarget.value = null;
|
|
718
|
+
},
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
function requestDelete(row: ProvidersPanelEntry) {
|
|
722
|
+
if (!isEditable(row)) return;
|
|
723
|
+
deleteError.value = null;
|
|
724
|
+
deleteTarget.value = row;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async function confirmDelete() {
|
|
728
|
+
const target = deleteTarget.value;
|
|
729
|
+
if (!target || props.readOnly) return;
|
|
730
|
+
deleting.value = true;
|
|
731
|
+
deleteError.value = null;
|
|
732
|
+
try {
|
|
733
|
+
await $fetch(`${props.providersWriteUrl}/${encodeURIComponent(target.id)}`, {
|
|
734
|
+
method: "DELETE",
|
|
735
|
+
});
|
|
736
|
+
deleteTarget.value = null;
|
|
737
|
+
await afterWrite();
|
|
738
|
+
} catch (err) {
|
|
739
|
+
deleteError.value = requestErrorMessage(err, `Could not delete ${target.id}.`);
|
|
740
|
+
} finally {
|
|
741
|
+
deleting.value = false;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// ── Detected in environment ────────────────────────────────────────────────
|
|
746
|
+
|
|
747
|
+
/** Preset id whose one-click add is in flight. */
|
|
748
|
+
const detectedBusy = ref<string | null>(null);
|
|
749
|
+
const detectedError = ref<string | null>(null);
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Add a detected preset the way boot seeding would:
|
|
753
|
+
* `{ preset, credential: { kind, ref: "env:<VAR>" } }` under the preset id,
|
|
754
|
+
* or `<preset>-env` when an entry already holds that id. With both taken the
|
|
755
|
+
* add dialog opens on the preset instead.
|
|
756
|
+
*/
|
|
757
|
+
async function addDetected(preset: ProvidersPanelPreset) {
|
|
758
|
+
if (props.readOnly) return;
|
|
759
|
+
const taken = new Set(providers.value.map((entry) => entry.id));
|
|
760
|
+
const id = [preset.id, `${preset.id}-env`].find((candidate) => !taken.has(candidate));
|
|
761
|
+
const envName = preset.credential.env;
|
|
762
|
+
if (!id || !envName) {
|
|
763
|
+
openAdd(preset.id);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
detectedBusy.value = preset.id;
|
|
767
|
+
detectedError.value = null;
|
|
768
|
+
try {
|
|
769
|
+
await $fetch(`${props.providersWriteUrl}/${encodeURIComponent(id)}`, {
|
|
770
|
+
method: "PUT",
|
|
771
|
+
body: {
|
|
772
|
+
preset: preset.id,
|
|
773
|
+
credential: { kind: preset.credential.kind, ref: `env:${envName}` },
|
|
774
|
+
},
|
|
775
|
+
});
|
|
776
|
+
await afterWrite();
|
|
777
|
+
} catch (err) {
|
|
778
|
+
detectedError.value = requestErrorMessage(err, `Could not add ${preset.label}.`);
|
|
779
|
+
} finally {
|
|
780
|
+
detectedBusy.value = null;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// ── Row actions ────────────────────────────────────────────────────────────
|
|
785
|
+
|
|
786
|
+
const rowBusy = ref<string | null>(null);
|
|
787
|
+
const rowError = ref<{ id: string; message: string } | null>(null);
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* The full body of an own entry, rebuilt from its row the way the dialog
|
|
791
|
+
* builds one: an own write replaces every editable field, so everything the
|
|
792
|
+
* row shows is sent back.
|
|
793
|
+
*/
|
|
794
|
+
function ownBody(row: ProvidersPanelEntry): Record<string, unknown> {
|
|
795
|
+
const body: Record<string, unknown> = { ...(row.layerFields ?? {}) };
|
|
796
|
+
const preset = presets.value.find((p) => p.id === row.preset);
|
|
797
|
+
if (row.preset) {
|
|
798
|
+
body.preset = row.preset;
|
|
799
|
+
if (row.surface && row.surface !== preset?.surface) body.surface = row.surface;
|
|
800
|
+
} else {
|
|
801
|
+
body.kind = row.kind;
|
|
802
|
+
if (row.surface) body.surface = row.surface;
|
|
803
|
+
}
|
|
804
|
+
if (row.baseUrl && row.baseUrl !== preset?.base_url) body.base_url = row.baseUrl;
|
|
805
|
+
if (row.credentialRef) {
|
|
806
|
+
body.credential = {
|
|
807
|
+
kind: row.credentialKind ?? FALLBACK_CREDENTIAL_KIND,
|
|
808
|
+
ref: row.credentialRef,
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
return body;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* `PUT` a credential and/or enabled change. An own row sends its full body
|
|
816
|
+
* with the change applied; an inherited or patched row sends only the
|
|
817
|
+
* change, which the server merges into the workspace patch.
|
|
818
|
+
*/
|
|
819
|
+
async function putRowChange(
|
|
820
|
+
row: ProvidersPanelRow,
|
|
821
|
+
change: { credential?: { kind: AIProviderCredentialKind; ref: string }; enabled?: boolean },
|
|
822
|
+
) {
|
|
823
|
+
const body = row.state === "own" ? { ...ownBody(row), ...change } : change;
|
|
824
|
+
await $fetch(`${props.providersWriteUrl}/${encodeURIComponent(row.id)}`, {
|
|
825
|
+
method: "PUT",
|
|
826
|
+
body,
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function credentialFor(row: ProvidersPanelRow, ref: string) {
|
|
831
|
+
return { kind: row.credentialKind ?? FALLBACK_CREDENTIAL_KIND, ref };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** Run one row's write, then reload and tell the host; a refusal lands in {@link rowError}. */
|
|
835
|
+
async function runRowWrite(id: string, work: () => Promise<void>, fallback: string) {
|
|
836
|
+
rowBusy.value = id;
|
|
837
|
+
rowError.value = null;
|
|
838
|
+
try {
|
|
839
|
+
await work();
|
|
840
|
+
await afterWrite();
|
|
841
|
+
} catch (err) {
|
|
842
|
+
rowError.value = { id, message: requestErrorMessage(err, fallback) };
|
|
843
|
+
} finally {
|
|
844
|
+
rowBusy.value = null;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function setCredentialEnv(row: ProvidersPanelRow, name: string) {
|
|
849
|
+
if (!row.canEditCredential) return;
|
|
850
|
+
const ref = `env:${name}`;
|
|
851
|
+
if (row.credentialRef === ref) return;
|
|
852
|
+
await runRowWrite(
|
|
853
|
+
row.id,
|
|
854
|
+
() => putRowChange(row, { credential: credentialFor(row, ref) }),
|
|
855
|
+
`Could not change the credential of ${row.id}.`,
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async function setEnabled(row: ProvidersPanelRow, enabled: boolean) {
|
|
860
|
+
if (!row.canToggle) return;
|
|
861
|
+
await runRowWrite(
|
|
862
|
+
row.id,
|
|
863
|
+
() => putRowChange(row, { enabled }),
|
|
864
|
+
`Could not ${enabled ? "enable" : "disable"} ${row.id}.`,
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
async function resetPatch(row: ProvidersPanelRow) {
|
|
869
|
+
if (!row.canReset) return;
|
|
870
|
+
await runRowWrite(
|
|
871
|
+
row.id,
|
|
872
|
+
async () => {
|
|
873
|
+
await $fetch(`${props.providersWriteUrl}/${encodeURIComponent(row.id)}`, {
|
|
874
|
+
method: "DELETE",
|
|
875
|
+
});
|
|
876
|
+
},
|
|
877
|
+
`Could not reset ${row.id}.`,
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// ── Inline key editor ──────────────────────────────────────────────────────
|
|
882
|
+
|
|
883
|
+
const keyEditing = ref<string | null>(null);
|
|
884
|
+
const keyForm = reactive({ name: "", value: "" });
|
|
885
|
+
const keyError = ref<string | null>(null);
|
|
886
|
+
|
|
887
|
+
const refEditing = ref<string | null>(null);
|
|
888
|
+
const refForm = reactive({ ref: "" });
|
|
889
|
+
const refError = ref<string | null>(null);
|
|
890
|
+
|
|
891
|
+
function cancelKeyEditor() {
|
|
892
|
+
keyEditing.value = null;
|
|
893
|
+
keyForm.name = "";
|
|
894
|
+
keyForm.value = "";
|
|
895
|
+
keyError.value = null;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function cancelRefEditor() {
|
|
899
|
+
refEditing.value = null;
|
|
900
|
+
refForm.ref = "";
|
|
901
|
+
refError.value = null;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** Open the key editor, name prefilled: the row's env var, else its preset's conventional one. */
|
|
905
|
+
function openKeyEditor(row: ProvidersPanelRow) {
|
|
906
|
+
if (!row.canEditCredential || !canEnterKey.value) return;
|
|
907
|
+
cancelRefEditor();
|
|
908
|
+
cancelKeyEditor();
|
|
909
|
+
const presetEnv = presets.value.find((p) => p.id === row.preset)?.credential.env;
|
|
910
|
+
keyForm.name = envNameOfRef(row.credentialRef) ?? presetEnv ?? "";
|
|
911
|
+
keyEditing.value = row.id;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Save the key: `NAME=value` into the scope's `.env` first, then the row's
|
|
916
|
+
* reference when it is not `env:NAME` already. The value never reaches the
|
|
917
|
+
* provider route.
|
|
918
|
+
*/
|
|
919
|
+
async function saveKeyEditor() {
|
|
920
|
+
const row = rows.value.find((r) => r.id === keyEditing.value);
|
|
921
|
+
if (!row?.canEditCredential || !props.environmentUrl) return;
|
|
922
|
+
const name = keyForm.name.trim();
|
|
923
|
+
const refusal = validateEnvVarName(name);
|
|
924
|
+
if (refusal) {
|
|
925
|
+
keyError.value = refusal;
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
if (!keyForm.value) {
|
|
929
|
+
keyError.value = "Enter the key.";
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
rowBusy.value = row.id;
|
|
933
|
+
keyError.value = null;
|
|
934
|
+
let wroteEnv = false;
|
|
935
|
+
try {
|
|
936
|
+
const update: EnvSettingsUpdate = { set: { [name]: keyForm.value }, unset: [] };
|
|
937
|
+
await $fetch(props.environmentUrl, { method: "PUT", body: update });
|
|
938
|
+
wroteEnv = true;
|
|
939
|
+
const ref = `env:${name}`;
|
|
940
|
+
if (row.credentialRef !== ref) {
|
|
941
|
+
await putRowChange(row, { credential: credentialFor(row, ref) });
|
|
942
|
+
}
|
|
943
|
+
cancelKeyEditor();
|
|
944
|
+
} catch (err) {
|
|
945
|
+
keyError.value = requestErrorMessage(err, "Could not save the key.");
|
|
946
|
+
} finally {
|
|
947
|
+
rowBusy.value = null;
|
|
948
|
+
}
|
|
949
|
+
if (wroteEnv) await afterWrite();
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// ── Inline reference editor ────────────────────────────────────────────────
|
|
953
|
+
|
|
954
|
+
function openRefEditor(row: ProvidersPanelRow) {
|
|
955
|
+
if (!row.canEditCredential) return;
|
|
956
|
+
cancelKeyEditor();
|
|
957
|
+
cancelRefEditor();
|
|
958
|
+
refForm.ref = row.credentialRef && !envNameOfRef(row.credentialRef) ? row.credentialRef : "";
|
|
959
|
+
refEditing.value = row.id;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
async function saveRefEditor() {
|
|
963
|
+
const row = rows.value.find((r) => r.id === refEditing.value);
|
|
964
|
+
if (!row?.canEditCredential) return;
|
|
965
|
+
const ref = refForm.ref.trim();
|
|
966
|
+
const refusal = ref ? validateCredentialRef(ref) : "Enter a reference.";
|
|
967
|
+
if (refusal) {
|
|
968
|
+
refError.value = refusal;
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
rowBusy.value = row.id;
|
|
972
|
+
refError.value = null;
|
|
973
|
+
try {
|
|
974
|
+
await putRowChange(row, { credential: credentialFor(row, ref) });
|
|
975
|
+
cancelRefEditor();
|
|
976
|
+
await afterWrite();
|
|
977
|
+
} catch (err) {
|
|
978
|
+
refError.value = requestErrorMessage(err, "Could not save the reference.");
|
|
979
|
+
} finally {
|
|
980
|
+
rowBusy.value = null;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function onCredentialSelect(row: ProvidersPanelRow, value: string) {
|
|
985
|
+
if (value === CREDENTIAL_ENTER_KEY) openKeyEditor(row);
|
|
986
|
+
else if (value === CREDENTIAL_OTHER_REF) openRefEditor(row);
|
|
987
|
+
else if (value !== row.credentialValue) void setCredentialEnv(row, value);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// ── Environment writes ─────────────────────────────────────────────────────
|
|
991
|
+
|
|
992
|
+
const envValues = reactive<Record<string, string>>({});
|
|
993
|
+
const envBusy = ref(false);
|
|
994
|
+
|
|
995
|
+
/** Write one change to the scope's `.env`, then reload and tell the host. */
|
|
996
|
+
async function writeEnvironment(update: EnvSettingsUpdate, fallback: string): Promise<boolean> {
|
|
997
|
+
if (!props.environmentUrl || !envEditable.value) return false;
|
|
998
|
+
envBusy.value = true;
|
|
999
|
+
envError.value = null;
|
|
1000
|
+
try {
|
|
1001
|
+
await $fetch(props.environmentUrl, { method: "PUT", body: update });
|
|
1002
|
+
} catch (err) {
|
|
1003
|
+
envError.value = requestErrorMessage(err, fallback);
|
|
1004
|
+
return false;
|
|
1005
|
+
} finally {
|
|
1006
|
+
envBusy.value = false;
|
|
1007
|
+
}
|
|
1008
|
+
await afterWrite();
|
|
1009
|
+
return true;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
async function setEnvValue(name: string) {
|
|
1013
|
+
const value = envValues[name];
|
|
1014
|
+
if (!value) return;
|
|
1015
|
+
if (await writeEnvironment({ set: { [name]: value }, unset: [] }, `Could not save ${name}.`)) {
|
|
1016
|
+
delete envValues[name];
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
return {
|
|
1021
|
+
// table
|
|
1022
|
+
rows,
|
|
1023
|
+
loading,
|
|
1024
|
+
loadError,
|
|
1025
|
+
refresh,
|
|
1026
|
+
// dialog
|
|
1027
|
+
dialogVisible,
|
|
1028
|
+
dialogTitle,
|
|
1029
|
+
editing,
|
|
1030
|
+
form,
|
|
1031
|
+
dialogError,
|
|
1032
|
+
saving,
|
|
1033
|
+
presetOptions,
|
|
1034
|
+
surfaceOptions,
|
|
1035
|
+
isCustom,
|
|
1036
|
+
showSurface,
|
|
1037
|
+
baseUrlPlaceholder,
|
|
1038
|
+
canSetValue,
|
|
1039
|
+
showCredentialFields,
|
|
1040
|
+
idError,
|
|
1041
|
+
openAdd,
|
|
1042
|
+
openEdit,
|
|
1043
|
+
selectPreset,
|
|
1044
|
+
saveProvider,
|
|
1045
|
+
// delete
|
|
1046
|
+
deleteTarget,
|
|
1047
|
+
deleteOpen,
|
|
1048
|
+
deleteError,
|
|
1049
|
+
deleting,
|
|
1050
|
+
requestDelete,
|
|
1051
|
+
confirmDelete,
|
|
1052
|
+
// detected
|
|
1053
|
+
detected,
|
|
1054
|
+
detectedBusy,
|
|
1055
|
+
detectedError,
|
|
1056
|
+
addDetected,
|
|
1057
|
+
// row actions
|
|
1058
|
+
onCredentialSelect,
|
|
1059
|
+
setCredentialEnv,
|
|
1060
|
+
setEnabled,
|
|
1061
|
+
resetPatch,
|
|
1062
|
+
rowBusy,
|
|
1063
|
+
rowError,
|
|
1064
|
+
// key editor
|
|
1065
|
+
canEnterKey,
|
|
1066
|
+
keyEditing,
|
|
1067
|
+
keyForm,
|
|
1068
|
+
keyError,
|
|
1069
|
+
openKeyEditor,
|
|
1070
|
+
cancelKeyEditor,
|
|
1071
|
+
saveKeyEditor,
|
|
1072
|
+
// reference editor
|
|
1073
|
+
refEditing,
|
|
1074
|
+
refForm,
|
|
1075
|
+
refError,
|
|
1076
|
+
openRefEditor,
|
|
1077
|
+
cancelRefEditor,
|
|
1078
|
+
saveRefEditor,
|
|
1079
|
+
// environment
|
|
1080
|
+
envDocument,
|
|
1081
|
+
envEditable,
|
|
1082
|
+
envValues,
|
|
1083
|
+
envError,
|
|
1084
|
+
envBusy,
|
|
1085
|
+
setEnvValue,
|
|
1086
|
+
};
|
|
1087
|
+
}
|