@skaile/forge-settings 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +94 -2
  4. package/dist/module.d.mts +17 -0
  5. package/dist/module.json +9 -0
  6. package/dist/module.mjs +23 -0
  7. package/dist/runtime/ai-sdk/index.d.ts +46 -0
  8. package/dist/runtime/ai-sdk/index.js +31 -0
  9. package/dist/runtime/components/ForgeProvidersPanel.d.vue.ts +21 -0
  10. package/dist/runtime/components/ForgeProvidersPanel.vue +435 -0
  11. package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +21 -0
  12. package/dist/runtime/editor/index.d.ts +58 -0
  13. package/dist/runtime/editor/index.js +72 -0
  14. package/dist/runtime/model/errors.d.ts +32 -0
  15. package/dist/runtime/model/errors.js +19 -0
  16. package/dist/runtime/server/credential-policy.d.ts +61 -0
  17. package/dist/runtime/server/credential-policy.js +65 -0
  18. package/dist/runtime/server/credential-store.d.ts +184 -0
  19. package/dist/runtime/server/credential-store.js +234 -0
  20. package/dist/runtime/server/env-layers.d.ts +62 -0
  21. package/dist/runtime/server/env-layers.js +183 -0
  22. package/dist/runtime/server/env.d.ts +30 -0
  23. package/dist/runtime/server/env.js +15 -0
  24. package/dist/runtime/server/harness-settings.d.ts +150 -0
  25. package/dist/runtime/server/harness-settings.js +166 -0
  26. package/dist/runtime/server/index.d.ts +23 -0
  27. package/dist/runtime/server/index.js +14 -0
  28. package/dist/runtime/server/provider-discovery.d.ts +73 -0
  29. package/dist/runtime/server/provider-discovery.js +77 -0
  30. package/dist/runtime/server/provider-layers.d.ts +27 -0
  31. package/dist/runtime/server/provider-layers.js +35 -0
  32. package/dist/runtime/server/provider-service.d.ts +126 -0
  33. package/dist/runtime/server/provider-service.js +87 -0
  34. package/dist/runtime/server/provider-settings.d.ts +78 -0
  35. package/dist/runtime/server/provider-settings.js +81 -0
  36. package/dist/runtime/server/provider-write.d.ts +154 -0
  37. package/dist/runtime/server/provider-write.js +221 -0
  38. package/dist/runtime/server/settings-defaults.d.ts +26 -0
  39. package/dist/runtime/server/settings-defaults.js +46 -0
  40. package/dist/runtime/shared/env-vars.d.ts +55 -0
  41. package/dist/runtime/shared/env-vars.js +22 -0
  42. package/dist/runtime/shared/guards.d.ts +2 -0
  43. package/dist/runtime/shared/guards.js +3 -0
  44. package/dist/runtime/shared/harness-providers.d.ts +62 -0
  45. package/dist/runtime/shared/harness-providers.js +38 -0
  46. package/dist/runtime/shared/harnesses.d.ts +19 -0
  47. package/dist/runtime/shared/harnesses.js +15 -0
  48. package/dist/runtime/shared/provider-registry.d.ts +255 -0
  49. package/dist/runtime/shared/provider-registry.js +112 -0
  50. package/dist/runtime/shared/settings-types.d.ts +84 -0
  51. package/dist/runtime/shared/settings-types.js +0 -0
  52. package/dist/runtime/shims-vue.d.ts +8 -0
  53. package/dist/runtime/vue/index.d.ts +14 -0
  54. package/dist/runtime/vue/index.js +6 -0
  55. package/dist/runtime/vue/providers-panel-model.d.ts +147 -0
  56. package/dist/runtime/vue/providers-panel-model.js +37 -0
  57. package/dist/runtime/vue/settings-modal-scope.d.ts +62 -0
  58. package/dist/runtime/vue/settings-modal-scope.js +2 -0
  59. package/dist/runtime/vue/useProvidersPanel.d.ts +115 -0
  60. package/dist/runtime/vue/useProvidersPanel.js +633 -0
  61. package/dist/types.d.mts +9 -0
  62. package/package.json +117 -4
  63. package/src/runtime/ai-sdk/index.ts +88 -0
  64. package/src/runtime/components/ForgeProvidersPanel.vue +449 -0
  65. package/src/runtime/editor/index.ts +146 -0
  66. package/src/runtime/model/errors.ts +52 -0
  67. package/src/runtime/server/credential-policy.ts +139 -0
  68. package/src/runtime/server/credential-store.ts +473 -0
  69. package/src/runtime/server/env-layers.ts +287 -0
  70. package/src/runtime/server/env.ts +46 -0
  71. package/src/runtime/server/harness-settings.ts +373 -0
  72. package/src/runtime/server/index.ts +24 -0
  73. package/src/runtime/server/provider-discovery.ts +164 -0
  74. package/src/runtime/server/provider-layers.ts +67 -0
  75. package/src/runtime/server/provider-service.ts +244 -0
  76. package/src/runtime/server/provider-settings.ts +192 -0
  77. package/src/runtime/server/provider-write.ts +433 -0
  78. package/src/runtime/server/settings-defaults.ts +87 -0
  79. package/src/runtime/shared/env-vars.ts +82 -0
  80. package/src/runtime/shared/guards.ts +4 -0
  81. package/src/runtime/shared/harness-providers.ts +118 -0
  82. package/src/runtime/shared/harnesses.ts +34 -0
  83. package/src/runtime/shared/provider-registry.ts +391 -0
  84. package/src/runtime/shared/settings-types.ts +87 -0
  85. package/src/runtime/shims-vue.d.ts +8 -0
  86. package/src/runtime/vue/index.ts +15 -0
  87. package/src/runtime/vue/providers-panel-model.ts +203 -0
  88. package/src/runtime/vue/settings-modal-scope.ts +66 -0
  89. package/src/runtime/vue/useProvidersPanel.ts +930 -0
package/package.json CHANGED
@@ -1,6 +1,119 @@
1
1
  {
2
2
  "name": "@skaile/forge-settings",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
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
+ "type": "module",
6
+ "packageManager": "bun@1.3.9",
7
+ "main": "./dist/module.mjs",
8
+ "types": "./dist/types.d.mts",
9
+ "exports": {
10
+ "./nuxt": {
11
+ "types": "./dist/types.d.mts",
12
+ "import": "./dist/module.mjs"
13
+ },
14
+ "./server": {
15
+ "types": "./dist/runtime/server/index.d.ts",
16
+ "import": "./dist/runtime/server/index.js"
17
+ },
18
+ "./vue": {
19
+ "types": "./dist/runtime/vue/index.d.ts",
20
+ "import": "./dist/runtime/vue/index.js"
21
+ },
22
+ "./ai-sdk": {
23
+ "types": "./dist/runtime/ai-sdk/index.d.ts",
24
+ "import": "./dist/runtime/ai-sdk/index.js"
25
+ },
26
+ "./editor": {
27
+ "types": "./dist/runtime/editor/index.d.ts",
28
+ "import": "./dist/runtime/editor/index.js"
29
+ }
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "src/runtime",
34
+ "README.md",
35
+ "CHANGELOG.md"
36
+ ],
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/skaile-ai/forge-settings.git"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "license": "MIT",
45
+ "scripts": {
46
+ "prepare": "nuxt-module-build prepare",
47
+ "dev:prepare": "nuxt-module-build build --stub",
48
+ "build": "nuxt-module-build build",
49
+ "prepublishOnly": "bun run build",
50
+ "test": "bun x --bun vitest run",
51
+ "typecheck": "tsc --noEmit",
52
+ "check": "bun run format && bun run lint",
53
+ "format": "biome format --write src tests",
54
+ "lint": "biome lint --fix --unsafe src tests"
55
+ },
56
+ "dependencies": {
57
+ "@nuxt/kit": "^4.0.0",
58
+ "@skaile/workspaces": "^6.10.0",
59
+ "ofetch": "^1.4.0",
60
+ "yaml": "^2.9.0"
61
+ },
62
+ "peerDependencies": {
63
+ "@ai-sdk/anthropic": "^4.0.0",
64
+ "@ai-sdk/openai": "^4.0.0",
65
+ "@ai-sdk/openai-compatible": "^3.0.0",
66
+ "@nuxt/ui": "^4.11.1",
67
+ "@skaile/forge-editor": "^6.0.0",
68
+ "ai": "^7.0.0",
69
+ "nuxt": "^4.0.0",
70
+ "vue": "^3.5.0"
71
+ },
72
+ "peerDependenciesMeta": {
73
+ "@ai-sdk/anthropic": {
74
+ "optional": true
75
+ },
76
+ "@ai-sdk/openai": {
77
+ "optional": true
78
+ },
79
+ "@ai-sdk/openai-compatible": {
80
+ "optional": true
81
+ },
82
+ "@nuxt/ui": {
83
+ "optional": true
84
+ },
85
+ "@skaile/forge-editor": {
86
+ "optional": true
87
+ },
88
+ "ai": {
89
+ "optional": true
90
+ },
91
+ "nuxt": {
92
+ "optional": true
93
+ },
94
+ "vue": {
95
+ "optional": true
96
+ }
97
+ },
98
+ "devDependencies": {
99
+ "@ai-sdk/openai-compatible": "^3.0.80",
100
+ "@biomejs/biome": "^2.4.9",
101
+ "@nuxt/module-builder": "^1.0.0",
102
+ "@nuxt/schema": "^4.0.0",
103
+ "@nuxt/ui": "^4.11.1",
104
+ "@skaile/forge-editor": "^6.0.0",
105
+ "@types/bun": "^1.2.0",
106
+ "@types/node": "^22.0.0",
107
+ "ai": "^7.0.155",
108
+ "bun-types": "^1.3.11",
109
+ "nuxt": "^4.0.0",
110
+ "typescript": "^5.0.0",
111
+ "unbuild": "^3.6.0",
112
+ "vitest": "^3.2.0",
113
+ "vue": "^3.5.28",
114
+ "vue-tsc": "^2.1.0"
115
+ },
116
+ "engines": {
117
+ "node": ">=20"
118
+ }
119
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `@skaile/forge-settings/ai-sdk` — the AI SDK language model of a registry
3
+ * selector, resolved by `@skaile/workspaces/ai-sdk` and gated by the host's
4
+ * credential policy.
5
+ *
6
+ * Workspaces owns endpoint, header and credential wiring for every surface;
7
+ * this module only decides *whether* an entry may be resolved and against
8
+ * *which* environment. It is not a service locator: the caller passes the
9
+ * selector (role-to-selector mapping belongs to the host's settings document)
10
+ * and the environment explicitly.
11
+ *
12
+ * Provider SDKs (`@ai-sdk/anthropic`, `@ai-sdk/openai`,
13
+ * `@ai-sdk/openai-compatible`) are optional peers of Workspaces: install the
14
+ * one the selected surface needs.
15
+ */
16
+
17
+ import { parseModelRef } from "@skaile/workspaces/core";
18
+ import { type ResolvedLanguageModel, resolveLanguageModel } from "@skaile/workspaces/ai-sdk";
19
+ import { loadMergedProviderRegistry } from "@skaile/workspaces/sdk/bridge";
20
+ import type { CredentialPolicy } from "../server/credential-policy";
21
+ import { ModelResolutionError, assertEntryAllowed } from "../model/errors";
22
+
23
+ export { ModelResolutionError } from "../model/errors";
24
+ export type { ModelResolutionCode } from "../model/errors";
25
+ export type { ResolvedLanguageModel };
26
+
27
+ /** Options for {@link resolveRegistryLanguageModel}. */
28
+ export interface ResolveRegistryLanguageModelOptions {
29
+ /** Workspace whose `.skaile/providers.yml` is the workspace layer. */
30
+ projectDir: string;
31
+ /**
32
+ * The environment `env:` credential references are read from. Required, so a
33
+ * host never reads the ambient process by accident.
34
+ */
35
+ env: Record<string, string | undefined>;
36
+ /** Role → model-ref map `@role` selectors resolve against. */
37
+ roles?: Record<string, string>;
38
+ /**
39
+ * Which credential references may be resolved. The selected entry is checked
40
+ * before anything is read; a refused one is a `policy_refused` failure.
41
+ */
42
+ policy?: CredentialPolicy;
43
+ }
44
+
45
+ /**
46
+ * The AI SDK model for `selector` (`<providerId>/<modelId>`, a bare model id or
47
+ * `@role`), resolved by Workspaces against the registry on disk.
48
+ *
49
+ * @throws {ModelResolutionError} `registry_invalid` · `unknown_selector` ·
50
+ * `policy_refused` · `unresolved` (credential, endpoint or provider SDK; the
51
+ * message is fixed and never carries Workspaces' text, which stays on `cause`).
52
+ */
53
+ export async function resolveRegistryLanguageModel(
54
+ selector: string,
55
+ options: ResolveRegistryLanguageModelOptions,
56
+ ): Promise<ResolvedLanguageModel> {
57
+ const { merged, diagnostics } = loadMergedProviderRegistry({ projectDir: options.projectDir });
58
+ const errors = diagnostics
59
+ .filter((d) => d.severity === "error")
60
+ .map((d) => (d.path ? `${d.path}: ${d.message}` : d.message));
61
+ if (errors.length > 0) {
62
+ throw new ModelResolutionError("registry_invalid", `providers registry: ${errors.join("; ")}`);
63
+ }
64
+
65
+ const ref = parseModelRef(selector, merged, options.roles);
66
+ if (!ref) {
67
+ throw new ModelResolutionError("unknown_selector", `no provider serves '${selector}'`);
68
+ }
69
+ const entry = merged.providers[ref.providerId];
70
+ if (entry) assertEntryAllowed(entry, ref.providerId, options.policy);
71
+
72
+ try {
73
+ return await resolveLanguageModel(selector, {
74
+ projectDir: options.projectDir,
75
+ env: options.env,
76
+ ...(options.roles ? { roles: options.roles } : {}),
77
+ });
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.
82
+ throw new ModelResolutionError(
83
+ "unresolved",
84
+ `provider '${ref.providerId}': credential, endpoint or provider SDK could not be resolved`,
85
+ { providerId: ref.providerId, cause },
86
+ );
87
+ }
88
+ }
@@ -0,0 +1,449 @@
1
+ <template>
2
+ <div data-testid="providers-panel" class="space-y-4">
3
+ <!-- Detected in environment -->
4
+ <div
5
+ v-if="detected.length"
6
+ data-testid="providers-detected"
7
+ class="p-3 bg-primary-500/10 border border-primary-500/20 rounded-lg space-y-2"
8
+ >
9
+ <div class="flex items-center gap-2">
10
+ <Icon name="lucide:key-round" class="text-primary-500 shrink-0" />
11
+ <p class="text-xs font-medium text-foreground">Detected in environment</p>
12
+ </div>
13
+ <div v-for="preset in detected" :key="preset.id" class="flex items-center gap-2 flex-wrap text-xs">
14
+ <span class="font-medium text-foreground">{{ preset.label }}</span>
15
+ <code class="font-mono text-foreground-muted">{{ preset.credential.env }}</code>
16
+ <UButton
17
+ v-if="!props.readOnly"
18
+ size="xs"
19
+ variant="ghost"
20
+ class="ml-auto"
21
+ :loading="detectedBusy === preset.id"
22
+ :disabled="detectedBusy !== null"
23
+ :data-testid="`provider-detected-add-${preset.id}`"
24
+ @click="addDetected(preset)"
25
+ >
26
+ <Icon name="lucide:plus" class="mr-1.5" />Add
27
+ </UButton>
28
+ </div>
29
+ <p v-if="detectedError" class="text-xs text-error-500">{{ detectedError }}</p>
30
+ </div>
31
+
32
+ <div class="flex items-center gap-2">
33
+ <p class="text-xs text-foreground-muted leading-relaxed flex-1">
34
+ {{ props.layer === 'workspace'
35
+ ? 'Entries of this workspace\'s providers.yml can be changed here; instance entries are inherited.'
36
+ : 'The instance registry, $FORGE_HOME/.skaile/providers.yml.' }}
37
+ Keys stay in the environment; an entry only references them.
38
+ </p>
39
+ <UButton
40
+ v-if="!props.readOnly"
41
+ size="sm"
42
+ color="primary"
43
+ data-testid="provider-add"
44
+ @click="openAdd()"
45
+ >
46
+ <Icon name="lucide:plus" class="mr-2" />Add provider
47
+ </UButton>
48
+ </div>
49
+
50
+ <p v-if="loadError" class="text-sm text-error-500">{{ loadError }}</p>
51
+ <div v-else-if="loading && rows.length === 0" class="text-xs text-foreground-subtle py-2 text-center">
52
+ Loading providers…
53
+ </div>
54
+ <div v-else-if="rows.length === 0" class="text-xs text-foreground-subtle py-2 text-center">
55
+ This registry has no providers yet.
56
+ </div>
57
+
58
+ <div v-else class="overflow-x-auto rounded-lg border border-surface-border">
59
+ <table class="w-full text-xs">
60
+ <thead class="bg-surface-ground/50 text-foreground-muted">
61
+ <tr class="text-left">
62
+ <th class="px-3 py-2 font-medium">Provider</th>
63
+ <th class="px-3 py-2 font-medium">Preset / surface</th>
64
+ <th class="px-3 py-2 font-medium">Base URL</th>
65
+ <th class="px-3 py-2 font-medium">Credential</th>
66
+ <th class="px-3 py-2 font-medium">Enabled</th>
67
+ <th class="px-3 py-2" />
68
+ </tr>
69
+ </thead>
70
+ <tbody>
71
+ <tr
72
+ v-for="row in rows"
73
+ :key="row.id"
74
+ :data-testid="`provider-row-${row.id}`"
75
+ class="border-t border-surface-border align-top"
76
+ :class="row.enabled ? '' : 'opacity-60'"
77
+ >
78
+ <td class="px-3 py-2">
79
+ <div class="flex items-center gap-1.5 flex-wrap">
80
+ <span class="font-medium text-foreground">{{ row.name }}</span>
81
+ <UBadge
82
+ :data-testid="`provider-badge-${row.id}`"
83
+ size="sm"
84
+ variant="subtle"
85
+ color="neutral"
86
+ >{{ row.badge }}</UBadge>
87
+ <Icon v-if="row.locked" name="lucide:lock" class="text-foreground-subtle shrink-0" />
88
+ </div>
89
+ <code v-if="row.name !== row.id" class="font-mono text-foreground-subtle">{{ row.id }}</code>
90
+ <p v-if="row.inheritedNote" class="text-foreground-subtle italic">{{ row.inheritedNote }}</p>
91
+ </td>
92
+ <td class="px-3 py-2 font-mono text-foreground-muted">{{ row.origin }}</td>
93
+ <td class="px-3 py-2 font-mono text-foreground-muted break-all">{{ row.baseUrl ?? '—' }}</td>
94
+ <td class="px-3 py-2 min-w-[15rem] space-y-1">
95
+ <USelect
96
+ v-if="row.needsCredential"
97
+ :model-value="row.credentialValue"
98
+ :items="row.credentialOptions"
99
+ placeholder="Choose a credential"
100
+ :disabled="!row.canEditCredential || rowBusy === row.id"
101
+ :data-testid="`provider-credential-${row.id}`"
102
+ class="w-full bg-surface-ground border-surface-border text-foreground text-xs font-mono"
103
+ @update:model-value="onCredentialSelect(row, String($event ?? ''))"
104
+ />
105
+ <p :class="row.configured ? 'text-foreground-subtle' : 'text-amber-600 dark:text-amber-400'" class="italic">
106
+ {{ row.status }}
107
+ </p>
108
+
109
+ <!-- Inline key editor -->
110
+ <div
111
+ v-if="keyEditing === row.id"
112
+ :data-testid="`provider-credential-enter-key-${row.id}`"
113
+ class="space-y-1.5 p-2 rounded-lg border border-surface-border bg-surface-ground/50"
114
+ >
115
+ <UInput
116
+ v-model="keyForm.name"
117
+ placeholder="PROVIDER_API_KEY"
118
+ :data-testid="`provider-key-name-${row.id}`"
119
+ class="w-full bg-surface-ground border-surface-border text-foreground text-xs font-mono"
120
+ />
121
+ <UInput
122
+ v-model="keyForm.value"
123
+ type="password"
124
+ autocomplete="off"
125
+ placeholder="Key"
126
+ :data-testid="`provider-key-value-${row.id}`"
127
+ class="w-full bg-surface-ground border-surface-border text-foreground text-xs font-mono"
128
+ />
129
+ <p v-if="keyError" class="text-error-500">{{ keyError }}</p>
130
+ <div class="flex justify-end gap-1">
131
+ <UButton size="xs" variant="ghost" :data-testid="`provider-key-cancel-${row.id}`" @click="cancelKeyEditor()">Cancel</UButton>
132
+ <UButton
133
+ size="xs"
134
+ color="primary"
135
+ :loading="rowBusy === row.id"
136
+ :data-testid="`provider-key-save-${row.id}`"
137
+ @click="saveKeyEditor()"
138
+ >Save</UButton>
139
+ </div>
140
+ </div>
141
+
142
+ <!-- Inline reference editor -->
143
+ <div
144
+ v-if="refEditing === row.id"
145
+ :data-testid="`provider-credential-ref-${row.id}`"
146
+ class="space-y-1.5 p-2 rounded-lg border border-surface-border bg-surface-ground/50"
147
+ >
148
+ <UInput
149
+ v-model="refForm.ref"
150
+ placeholder="keyring:forge/name · cmd:… · platform:…"
151
+ :data-testid="`provider-ref-input-${row.id}`"
152
+ class="w-full bg-surface-ground border-surface-border text-foreground text-xs font-mono"
153
+ />
154
+ <p v-if="refError" class="text-error-500">{{ refError }}</p>
155
+ <div class="flex justify-end gap-1">
156
+ <UButton size="xs" variant="ghost" :data-testid="`provider-ref-cancel-${row.id}`" @click="cancelRefEditor()">Cancel</UButton>
157
+ <UButton
158
+ size="xs"
159
+ color="primary"
160
+ :loading="rowBusy === row.id"
161
+ :data-testid="`provider-ref-save-${row.id}`"
162
+ @click="saveRefEditor()"
163
+ >Save</UButton>
164
+ </div>
165
+ </div>
166
+
167
+ <p
168
+ v-for="notice in row.overrideNotices"
169
+ :key="notice"
170
+ :data-testid="`provider-override-notice-${row.id}`"
171
+ class="text-foreground-subtle"
172
+ >{{ notice }}</p>
173
+ <p
174
+ v-if="rowError?.id === row.id"
175
+ :data-testid="`provider-row-error-${row.id}`"
176
+ class="text-error-500"
177
+ >{{ rowError.message }}</p>
178
+ </td>
179
+ <td class="px-3 py-2">
180
+ <USwitch
181
+ :model-value="row.enabled"
182
+ :disabled="!row.canToggle || rowBusy === row.id"
183
+ :aria-label="`Enable ${row.id}`"
184
+ :data-testid="`provider-enabled-${row.id}`"
185
+ @update:model-value="setEnabled(row, Boolean($event))"
186
+ />
187
+ </td>
188
+ <td class="px-3 py-2">
189
+ <div class="flex items-center justify-end gap-1">
190
+ <UButton
191
+ v-if="row.canReset"
192
+ size="sm"
193
+ variant="ghost"
194
+ :loading="rowBusy === row.id"
195
+ :data-testid="`provider-reset-${row.id}`"
196
+ @click="resetPatch(row)"
197
+ >
198
+ <Icon name="lucide:undo-2" class="mr-1.5 w-3.5 h-3.5" />Reset
199
+ </UButton>
200
+ <template v-if="row.editable">
201
+ <UButton
202
+ size="sm"
203
+ variant="ghost"
204
+ :aria-label="`Edit ${row.id}`"
205
+ :data-testid="`provider-edit-${row.id}`"
206
+ @click="openEdit(row)"
207
+ >
208
+ <Icon name="lucide:pencil" class="w-3.5 h-3.5" />
209
+ </UButton>
210
+ <UButton
211
+ size="sm"
212
+ variant="ghost"
213
+ color="error"
214
+ :aria-label="`Delete ${row.id}`"
215
+ :data-testid="`provider-delete-${row.id}`"
216
+ @click="requestDelete(row)"
217
+ >
218
+ <Icon name="lucide:trash-2" class="w-3.5 h-3.5" />
219
+ </UButton>
220
+ </template>
221
+ </div>
222
+ </td>
223
+ </tr>
224
+ </tbody>
225
+ </table>
226
+ </div>
227
+
228
+ <!-- Add / edit dialog -->
229
+ <UModal
230
+ :open="dialogVisible"
231
+ :title="dialogTitle"
232
+ :ui="{
233
+ content: 'bg-surface-card border border-surface-border sm:max-w-[460px]',
234
+ header: 'bg-surface-card border-b border-surface-border text-foreground',
235
+ body: 'bg-surface-card p-5',
236
+ footer: 'bg-surface-ground/50 border-t border-surface-border p-3 justify-end',
237
+ }"
238
+ @update:open="dialogVisible = $event"
239
+ >
240
+ <template #body>
241
+ <div data-testid="provider-dialog" class="space-y-4">
242
+ <div class="flex flex-col gap-1">
243
+ <label class="text-xs text-foreground-muted font-medium">
244
+ Preset <span class="text-error-500">*</span>
245
+ </label>
246
+ <USelect
247
+ :model-value="form.preset"
248
+ :items="presetOptions"
249
+ placeholder="Choose a preset"
250
+ data-testid="provider-preset"
251
+ class="w-full bg-surface-ground border-surface-border text-foreground text-sm"
252
+ @update:model-value="selectPreset(String($event ?? ''))"
253
+ />
254
+ </div>
255
+
256
+ <div v-if="showSurface" class="flex flex-col gap-1">
257
+ <label class="text-xs text-foreground-muted font-medium">
258
+ Surface <span class="text-error-500">*</span>
259
+ </label>
260
+ <USelect
261
+ v-model="form.surface"
262
+ :items="surfaceOptions"
263
+ placeholder="Wire protocol"
264
+ data-testid="provider-surface"
265
+ class="w-full bg-surface-ground border-surface-border text-foreground text-sm"
266
+ />
267
+ </div>
268
+
269
+ <div class="flex flex-col gap-1">
270
+ <label class="text-xs text-foreground-muted font-medium">
271
+ ID <span class="text-error-500">*</span>
272
+ </label>
273
+ <UInput
274
+ v-model="form.id"
275
+ :disabled="editing !== null"
276
+ placeholder="openrouter"
277
+ data-testid="provider-id"
278
+ class="w-full bg-surface-ground border-surface-border text-foreground text-sm font-mono"
279
+ :class="editing !== null ? 'opacity-60 cursor-not-allowed' : ''"
280
+ />
281
+ <p v-if="idError" class="text-xs text-error-500">{{ idError }}</p>
282
+ <p v-else class="text-[10px] text-foreground-subtle">The left half of a model selector: <code class="font-mono">&lt;id&gt;/&lt;model&gt;</code>.</p>
283
+ </div>
284
+
285
+ <div class="flex flex-col gap-1">
286
+ <label class="text-xs text-foreground-muted font-medium">
287
+ Base URL <span v-if="isCustom" class="text-error-500">*</span>
288
+ </label>
289
+ <UInput
290
+ v-model="form.baseUrl"
291
+ :placeholder="baseUrlPlaceholder"
292
+ data-testid="provider-base-url"
293
+ class="w-full bg-surface-ground border-surface-border text-foreground text-sm font-mono"
294
+ />
295
+ </div>
296
+
297
+ <template v-if="showCredentialFields">
298
+ <div class="flex flex-col gap-1">
299
+ <label class="text-xs text-foreground-muted font-medium">Credential environment variable</label>
300
+ <UInput
301
+ v-model="form.envName"
302
+ placeholder="PROVIDER_API_KEY"
303
+ data-testid="provider-credential-env"
304
+ class="w-full bg-surface-ground border-surface-border text-foreground text-sm font-mono"
305
+ />
306
+ <p class="text-[10px] text-foreground-subtle">
307
+ Stored as <code class="font-mono">env:{{ form.envName.trim() || 'NAME' }}</code> — the key itself stays in the environment.
308
+ </p>
309
+ </div>
310
+
311
+ <div v-if="canSetValue" class="flex flex-col gap-1">
312
+ <label class="text-xs text-foreground-muted font-medium">Set value now</label>
313
+ <UInput
314
+ v-model="form.secret"
315
+ type="password"
316
+ autocomplete="off"
317
+ placeholder="Optional — written to this scope's .env"
318
+ data-testid="provider-set-value"
319
+ class="w-full bg-surface-ground border-surface-border text-foreground text-sm font-mono"
320
+ />
321
+ </div>
322
+ </template>
323
+
324
+ <p v-if="dialogError" data-testid="provider-dialog-error" class="text-sm text-error-500">{{ dialogError }}</p>
325
+ </div>
326
+ </template>
327
+
328
+ <template #footer>
329
+ <div class="flex gap-2 justify-end w-full">
330
+ <UButton size="sm" variant="ghost" @click="dialogVisible = false">Cancel</UButton>
331
+ <UButton
332
+ size="sm"
333
+ color="primary"
334
+ data-testid="provider-save"
335
+ :loading="saving"
336
+ @click="saveProvider"
337
+ >
338
+ <Icon name="lucide:check" class="mr-1.5" />{{ editing ? 'Save' : 'Add' }}
339
+ </UButton>
340
+ </div>
341
+ </template>
342
+ </UModal>
343
+
344
+ <!-- Delete confirmation -->
345
+ <UModal
346
+ :open="deleteOpen"
347
+ title="Delete provider"
348
+ :ui="{
349
+ content: 'bg-surface-card border border-surface-border sm:max-w-[22rem]',
350
+ header: 'bg-surface-card border-b border-surface-border text-foreground',
351
+ body: 'bg-surface-card p-5',
352
+ }"
353
+ @update:open="deleteOpen = $event"
354
+ >
355
+ <template #body>
356
+ <p class="text-sm text-foreground-muted">
357
+ Delete <span class="font-medium font-mono">{{ deleteTarget?.id }}</span> from the {{ props.layer }} registry?
358
+ <template v-if="props.layer === 'workspace'"> An instance entry with the same id shows again.</template>
359
+ </p>
360
+ <p v-if="deleteError" class="text-sm text-error-500 mt-2">{{ deleteError }}</p>
361
+ <div class="flex justify-end gap-2 mt-4">
362
+ <UButton size="sm" variant="ghost" @click="deleteOpen = false">Cancel</UButton>
363
+ <UButton
364
+ size="sm"
365
+ color="error"
366
+ data-testid="provider-delete-confirm"
367
+ :loading="deleting"
368
+ @click="confirmDelete"
369
+ >
370
+ Delete
371
+ </UButton>
372
+ </div>
373
+ </template>
374
+ </UModal>
375
+ </div>
376
+ </template>
377
+
378
+ <script setup lang="ts">
379
+ import { type ProvidersPanelLayer, useProvidersPanel } from "../vue/useProvidersPanel";
380
+
381
+ const props = defineProps<{
382
+ /** `GET` merged view for this scope (`/api/providers` | `/api/workspaces/:id/providers`). */
383
+ providersUrl: string;
384
+ /** `PUT|DELETE ${providersWriteUrl}/:id`; `GET ${providersWriteUrl}/detected`. */
385
+ providersWriteUrl: string;
386
+ /** Catalog preset summaries. Default `/api/providers/presets`. */
387
+ presetsUrl?: string;
388
+ /** The scope's `.env` route: hand-entered keys. Omitted, key entry is hidden. */
389
+ environmentUrl?: string;
390
+ /** Edit/Delete are offered only for entries this layer defines. */
391
+ layer: ProvidersPanelLayer;
392
+ /** Disables add, edit and delete. */
393
+ readOnly?: boolean;
394
+ }>();
395
+
396
+ const emit = defineEmits<{
397
+ /** After every successful `PUT`/`DELETE`. */
398
+ (event: "changed"): void;
399
+ }>();
400
+
401
+ const {
402
+ rows,
403
+ loading,
404
+ loadError,
405
+ dialogVisible,
406
+ dialogTitle,
407
+ editing,
408
+ form,
409
+ dialogError,
410
+ saving,
411
+ presetOptions,
412
+ surfaceOptions,
413
+ isCustom,
414
+ showSurface,
415
+ baseUrlPlaceholder,
416
+ canSetValue,
417
+ showCredentialFields,
418
+ rowBusy,
419
+ rowError,
420
+ onCredentialSelect,
421
+ setEnabled,
422
+ resetPatch,
423
+ keyEditing,
424
+ keyForm,
425
+ keyError,
426
+ cancelKeyEditor,
427
+ saveKeyEditor,
428
+ refEditing,
429
+ refForm,
430
+ refError,
431
+ cancelRefEditor,
432
+ saveRefEditor,
433
+ idError,
434
+ openAdd,
435
+ openEdit,
436
+ selectPreset,
437
+ saveProvider,
438
+ deleteTarget,
439
+ deleteOpen,
440
+ deleteError,
441
+ deleting,
442
+ requestDelete,
443
+ confirmDelete,
444
+ detected,
445
+ detectedBusy,
446
+ detectedError,
447
+ addDetected,
448
+ } = useProvidersPanel(props, emit);
449
+ </script>