@ltm-blueverse/alpha-semantic-hub 0.8.0 → 0.9.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/README.md CHANGED
@@ -322,6 +322,41 @@ SDK is missing. Two patterns:
322
322
  Also available: Redshift, Cassandra, MongoDB, DuckDB, Elasticsearch, Kafka-style
323
323
  streams, email, RSS/Atom feeds, Airflow, Parquet/Arrow.
324
324
 
325
+ ### Connector catalog and the credential vault
326
+
327
+ **26 connectors in 5 categories**, all selectable from the Studio's Source
328
+ Ingestion tab:
329
+
330
+ | Category | Connectors |
331
+ |---|---|
332
+ | Warehouses & Lakehouses | Databricks, Snowflake, BigQuery, Redshift |
333
+ | Databases | SQL (Postgres/MySQL/SQLite), DuckDB, MongoDB, Cassandra, Elasticsearch |
334
+ | Business SaaS | Salesforce, SAP (OData), ServiceNow, Power BI, SharePoint/OneDrive (Graph API) |
335
+ | Files & Storage | File, Parquet, XML, Cloud Storage (S3/GCS/Azure), Google Drive |
336
+ | Web & APIs | REST, Web page, RSS/Atom, Git repo, Email (IMAP/POP3), HuggingFace, MCP server |
337
+
338
+ Connectors marked `dep` need an optional dependency (e.g. `lxml`, `boto3`,
339
+ `pymongo`, `gitpython`, `duckdb`, `google-cloud-bigquery`); the Studio shows
340
+ this before you run.
341
+
342
+ **Credentials vault.** Secrets entered in the UI are encrypted and stored under
343
+ `ASHUB_VAULT_DIR` (default `/data/vault`, mounted from `./data`). Saved
344
+ credentials are reusable across ingestion runs and LLM calls.
345
+
346
+ * `ASHUB_VAULT_KEY` — optional 32-byte master key (base64/hex). When unset, a
347
+ key file is generated at `<vault_dir>/vault.key` with `0600` permissions.
348
+ * Records are encrypted with encrypt-then-MAC (HMAC-SHA256, HKDF-derived keys)
349
+ using only the standard library — no new dependencies in the hash-pinned
350
+ image. Tampered records fail closed rather than decrypting.
351
+ * Secrets are never returned by the list API and are scrubbed from job/error
352
+ messages.
353
+
354
+ **OpenAI-compatible LLM endpoints.** Besides the 8 built-in providers, the LLM
355
+ Endpoints tab can add any `/v1`-style endpoint (vLLM, Ollama's OpenAI layer,
356
+ OpenRouter, LiteLLM proxies, Azure gateways): enter a base URL + optional API
357
+ key, **fetch the model list from the endpoint**, pick a model, save it to the
358
+ vault, and set it as the platform default.
359
+
325
360
  ### Ingestion, MCP tools and LLM endpoints in the Studio
326
361
 
327
362
  The **Enrich** workspace has three tabs for this:
@@ -1,33 +1,39 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
  import {
3
3
  Database, Plug, CheckCircle2, AlertCircle, Loader2, FileText, Globe,
4
- Boxes, Cloud, Server, Play,
4
+ Boxes, Cloud, Server, Play, ChevronDown, KeyRound, Save, Trash2, Lock,
5
5
  } from "lucide-react";
6
6
 
7
- type SourceField = {
8
- name: string;
9
- label: string;
10
- required: boolean;
11
- secret?: boolean;
12
- boolean?: boolean;
7
+ type ConnField = {
8
+ name: string; label: string; required?: boolean;
9
+ secret?: boolean; boolean?: boolean;
13
10
  };
14
11
 
15
- type SourceDef = {
16
- kind: string;
17
- label: string;
18
- fields: SourceField[];
19
- hint: string;
12
+ type Connector = {
13
+ kind: string; label: string; credential_hint: string | null;
14
+ env_fallback: string | null; source_label: string; source_placeholder: string;
15
+ optional_fields: ConnField[]; secret_fields: string[];
16
+ extra_dependency: string | null;
20
17
  };
21
18
 
19
+ type Category = { category: string; connectors: Connector[] };
20
+ type Credential = {
21
+ id: string; kind: string; label: string; metadata: Record<string, unknown>;
22
+ field_names: string[]; updated_at: number;
23
+ };
22
24
  type Toast = { id: number; type: "success" | "error"; text: string };
25
+ type FieldValues = Record<string, string | boolean>;
23
26
 
24
27
  const KIND_ICONS: Record<string, typeof Database> = {
25
- file: FileText, databricks: Boxes, sap: Server, snowflake: Cloud,
26
- salesforce: Cloud, rest: Plug, web: Globe,
28
+ databricks: Boxes, snowflake: Cloud, bigquery: Cloud, redshift: Cloud,
29
+ sql: Database, duckdb: Database, mongo: Database, cassandra: Database,
30
+ elastic: Database, salesforce: Cloud, sap: Server, servicenow: Server,
31
+ powerbi: Cloud, sharepoint: Cloud, file: FileText, parquet: FileText,
32
+ xml: FileText, cloudstorage: Cloud, gdrive: Cloud, rest: Plug,
33
+ web: Globe, feed: Globe, repo: Globe, email: Globe,
34
+ huggingface: Globe, mcp: Plug,
27
35
  };
28
36
 
29
- type FieldValues = Record<string, string | boolean>;
30
-
31
37
  function nestedSet(obj: Record<string, unknown>, path: string, value: unknown) {
32
38
  const parts = path.split(".");
33
39
  let cur = obj;
@@ -39,9 +45,14 @@ function nestedSet(obj: Record<string, unknown>, path: string, value: unknown) {
39
45
  }
40
46
 
41
47
  export function IngestWorkspace() {
42
- const [sources, setSources] = useState<SourceDef[]>([]);
43
- const [activeKind, setActiveKind] = useState<string | null>(null);
48
+ const [categories, setCategories] = useState<Category[]>([]);
49
+ const [active, setActive] = useState<Connector | null>(null);
50
+ const [pickerOpen, setPickerOpen] = useState(false);
51
+ const [credentials, setCredentials] = useState<Credential[]>([]);
52
+ const [credentialId, setCredentialId] = useState("");
44
53
  const [values, setValues] = useState<FieldValues>({});
54
+ const [saveToVault, setSaveToVault] = useState(true);
55
+ const [vaultName, setVaultName] = useState("");
45
56
  const [sourceRef, setSourceRef] = useState("");
46
57
  const [extract, setExtract] = useState(true);
47
58
  const [validating, setValidating] = useState(false);
@@ -51,28 +62,90 @@ export function IngestWorkspace() {
51
62
  const [toasts, setToasts] = useState<Toast[]>([]);
52
63
  const pollRef = useRef<number | null>(null);
53
64
 
54
- useEffect(() => {
55
- fetch("/api/ingest/sources").then((r) => r.json()).then((d) => {
56
- setSources(d.sources ?? []);
57
- if (d.sources?.length) setActiveKind((k) => k ?? d.sources[0].kind);
58
- }).catch(() => {});
59
- return () => { if (pollRef.current) window.clearInterval(pollRef.current); };
60
- }, []);
61
-
62
- const active = sources.find((s) => s.kind === activeKind) ?? null;
63
-
64
65
  function showToast(type: Toast["type"], text: string) {
65
66
  const id = Date.now();
66
67
  setToasts((p) => [...p, { id, type, text }]);
67
68
  setTimeout(() => setToasts((p) => p.filter((t) => t.id !== id)), 6000);
68
69
  }
69
70
 
70
- function selectKind(kind: string) {
71
- setActiveKind(kind);
71
+ async function loadCredentials() {
72
+ try {
73
+ const r = await fetch("/api/vault/credentials?kind=source");
74
+ const d = await r.json();
75
+ setCredentials(d.credentials ?? []);
76
+ } catch { /* ignore */ }
77
+ }
78
+
79
+ useEffect(() => {
80
+ fetch("/api/ingest/sources").then((r) => r.json()).then((d) => {
81
+ const cats: Category[] = d.categories ?? [];
82
+ setCategories(cats);
83
+ const first = cats[0]?.connectors[0];
84
+ if (first) setActive(first);
85
+ }).catch(() => {});
86
+ loadCredentials();
87
+ return () => { if (pollRef.current) window.clearInterval(pollRef.current); };
88
+ }, []);
89
+
90
+ function selectConnector(c: Connector) {
91
+ setActive(c);
92
+ setPickerOpen(false);
72
93
  setValues({});
73
94
  setSourceRef("");
74
95
  setValidation(null);
75
96
  setJob(null);
97
+ setCredentialId("");
98
+ setVaultName(`${c.kind}-credentials`);
99
+ }
100
+
101
+ function applyCredential(id: string) {
102
+ setCredentialId(id);
103
+ setValidation(null);
104
+ }
105
+
106
+ function buildOptions(): Record<string, unknown> {
107
+ const options: Record<string, unknown> = {};
108
+ for (const [k, v] of Object.entries(values)) {
109
+ if (v !== "" && v !== false && v !== undefined) nestedSet(options, k, v);
110
+ }
111
+ return options;
112
+ }
113
+
114
+ async function handleSaveCredential() {
115
+ if (!active) return;
116
+ const fields: Record<string, string> = {};
117
+ for (const [k, v] of Object.entries(values)) {
118
+ if (typeof v === "string" && v.trim()) fields[k] = v;
119
+ }
120
+ if (Object.keys(fields).length === 0) {
121
+ showToast("error", "Enter credential fields before saving");
122
+ return;
123
+ }
124
+ try {
125
+ const r = await fetch("/api/vault/credentials", {
126
+ method: "POST", headers: { "Content-Type": "application/json" },
127
+ body: JSON.stringify({ id: vaultName.trim() || `${active.kind}-credentials`,
128
+ kind: "source", label: `${active.label} credentials`, fields }),
129
+ });
130
+ if (!r.ok) throw new Error((await r.json()).detail ?? `HTTP ${r.status}`);
131
+ showToast("success", "Saved to vault");
132
+ await loadCredentials();
133
+ setCredentialId(vaultName.trim() || `${active.kind}-credentials`);
134
+ } catch (e) {
135
+ showToast("error", e instanceof Error ? e.message : "Save failed");
136
+ }
137
+ }
138
+
139
+ async function handleDeleteCredential(id: string) {
140
+ try {
141
+ const r = await fetch(`/api/vault/credentials/${encodeURIComponent(id)}`, { method: "DELETE" });
142
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
143
+ showToast("success", "Credential deleted");
144
+ if (credentialId === id) setCredentialId("");
145
+ await loadCredentials();
146
+ } catch (e) {
147
+ showToast("error", e instanceof Error ? e.message : "Delete failed");
148
+ }
76
149
  }
77
150
 
78
151
  async function handleValidate() {
@@ -80,18 +153,12 @@ export function IngestWorkspace() {
80
153
  setValidating(true);
81
154
  setValidation(null);
82
155
  try {
83
- const options: Record<string, unknown> = {};
84
- for (const [k, v] of Object.entries(values)) {
85
- if (v !== "" && v !== false) nestedSet(options, k, v);
86
- }
87
- if (active.kind === "sap" && options["service"] === undefined) delete options["service"];
88
156
  const res = await fetch("/api/ingest/validate", {
89
- method: "POST",
90
- headers: { "Content-Type": "application/json" },
91
- body: JSON.stringify({ kind: active.kind, options }),
157
+ method: "POST", headers: { "Content-Type": "application/json" },
158
+ body: JSON.stringify({ kind: active.kind, credential_id: credentialId || null, options: buildOptions() }),
92
159
  });
93
160
  const data = await res.json();
94
- setValidation({ ok: !!data.ok, detail: data.detail ?? data.detail?.toString() ?? "Unknown response" });
161
+ setValidation({ ok: !!data.ok, detail: data.detail ?? "Unknown response" });
95
162
  } catch (e) {
96
163
  setValidation({ ok: false, detail: e instanceof Error ? e.message : "Validation failed" });
97
164
  } finally {
@@ -100,20 +167,26 @@ export function IngestWorkspace() {
100
167
  }
101
168
 
102
169
  async function handleRun() {
103
- if (!active || !sourceRef.trim()) return;
170
+ if (!active) return;
171
+ if (saveToVault && credentialId === "") {
172
+ const fields: Record<string, string> = {};
173
+ for (const [k, v] of Object.entries(values)) {
174
+ if (typeof v === "string" && v.trim()) fields[k] = v;
175
+ }
176
+ if (Object.keys(fields).length > 0) await handleSaveCredential();
177
+ }
104
178
  setRunning(true);
105
179
  setJob({ status: "queued", stage: "queued", progress: 0, message: "Starting…" });
106
180
  try {
107
- const options: Record<string, unknown> = {};
108
- for (const [k, v] of Object.entries(values)) {
109
- if (v !== "" && v !== false) nestedSet(options, k, v);
110
- }
111
181
  const res = await fetch("/api/ingest/run", {
112
- method: "POST",
113
- headers: { "Content-Type": "application/json" },
114
- body: JSON.stringify({ kind: active.kind, source: sourceRef.trim(), options, extract }),
182
+ method: "POST", headers: { "Content-Type": "application/json" },
183
+ body: JSON.stringify({
184
+ kind: active.kind, source: sourceRef.trim(),
185
+ credential_id: credentialId || null,
186
+ options: buildOptions(), extract,
187
+ }),
115
188
  });
116
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
189
+ if (!res.ok) throw new Error((await res.json()).detail ?? `HTTP ${res.status}`);
117
190
  const { job_id } = await res.json();
118
191
  pollRef.current = window.setInterval(async () => {
119
192
  try {
@@ -121,27 +194,25 @@ export function IngestWorkspace() {
121
194
  if (!st.ok) return;
122
195
  const data = await st.json();
123
196
  setJob(data);
124
- if (data.status === "done") {
125
- window.clearInterval(pollRef.current!); pollRef.current = null;
126
- setRunning(false);
127
- showToast("success", data.message);
128
- } else if (data.status === "error") {
197
+ if (data.status === "done" || data.status === "error") {
129
198
  window.clearInterval(pollRef.current!); pollRef.current = null;
130
199
  setRunning(false);
131
- showToast("error", data.message);
200
+ showToast(data.status === "done" ? "success" : "error", data.message);
132
201
  }
133
202
  } catch { /* transient */ }
134
203
  }, 1000);
135
204
  } catch (e) {
136
- setRunning(false);
137
- setJob(null);
205
+ setRunning(false); setJob(null);
138
206
  showToast("error", e instanceof Error ? e.message : "Failed to start ingestion");
139
207
  }
140
208
  }
141
209
 
210
+ const ActiveIcon = active ? (KIND_ICONS[active.kind] ?? Database) : Database;
211
+ const vaultForKind = credentials.filter((c) => c.id.startsWith(active?.kind ?? "___"));
212
+
142
213
  return (
143
214
  <div className="ws-page ws-scroll">
144
- <div className="ws-padded" style={{ display: "flex", flexDirection: "column", gap: 24, maxWidth: 980, margin: "0 auto", width: "100%" }}>
215
+ <div className="ws-padded" style={{ display: "flex", flexDirection: "column", gap: 24, maxWidth: 1040, margin: "0 auto", width: "100%" }}>
145
216
  {/* Header */}
146
217
  <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
147
218
  <div style={{ width: 42, height: 42, borderRadius: 13, background: "var(--ws-accent-soft)", border: "1px solid var(--ws-border-strong)", display: "grid", placeItems: "center", color: "var(--ws-accent)", flexShrink: 0 }}>
@@ -149,111 +220,214 @@ export function IngestWorkspace() {
149
220
  </div>
150
221
  <div>
151
222
  <h2 className="ws-title" style={{ fontSize: 18 }}>Ingest from Source Systems</h2>
152
- <div className="ws-body" style={{ marginTop: 2 }}>Connect Databricks, SAP, Snowflake, Salesforce, REST APIs, web pages or files — extraction runs automatically and the graph loads into the Studio.</div>
223
+ <div className="ws-body" style={{ marginTop: 2 }}>
224
+ {categories.reduce((n, c) => n + c.connectors.length, 0)} connectors across{" "}
225
+ {categories.length} categories. Credentials save to the encrypted vault and can be reused.
226
+ </div>
153
227
  </div>
154
228
  </div>
155
229
 
156
- <div style={{ display: "grid", gridTemplateColumns: "280px 1fr", gap: 18, alignItems: "start" }}>
157
- {/* Source picker */}
158
- <div className="ws-card" style={{ padding: 18, display: "flex", flexDirection: "column", gap: 8 }}>
159
- <div className="ws-label">Source type</div>
160
- {sources.map((s) => {
161
- const Icon = KIND_ICONS[s.kind] ?? Database;
162
- return (
163
- <button key={s.kind} className="ws-btn" data-active={activeKind === s.kind}
164
- onClick={() => selectKind(s.kind)}
165
- style={{ justifyContent: "flex-start", fontSize: 13 }}>
166
- <Icon size={15} /> {s.label}
167
- </button>
168
- );
169
- })}
170
- </div>
230
+ {/* Connector picker */}
231
+ <div className="ws-card" style={{ padding: 18, position: "relative" }}>
232
+ <div className="ws-label">Connector</div>
233
+ <button className="ws-btn" onClick={() => setPickerOpen((v) => !v)}
234
+ style={{ width: "100%", justifyContent: "space-between", fontSize: 13 }}>
235
+ <span style={{ display: "flex", alignItems: "center", gap: 8 }}>
236
+ <ActiveIcon size={15} /> {active?.label ?? "Select a connector…"}
237
+ </span>
238
+ <ChevronDown size={15} />
239
+ </button>
171
240
 
172
- {/* Connection form */}
173
- <div className="ws-card" style={{ padding: 24, display: "flex", flexDirection: "column", gap: 16 }}>
174
- {!active ? <div className="ws-body">Loading sources…</div> : (
175
- <>
176
- <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
177
- <Plug size={16} color="var(--ws-accent)" />
178
- <div style={{ color: "var(--ws-text)", fontWeight: 700, fontSize: 14 }}>{active.label} connection</div>
241
+ {pickerOpen ? (
242
+ <div style={{
243
+ position: "absolute", top: "100%", left: 18, right: 18, zIndex: 50, marginTop: 6,
244
+ background: "#fff", border: "1px solid var(--ws-border)", borderRadius: "var(--ws-radius)",
245
+ boxShadow: "0 12px 32px rgba(0,0,0,0.18)", maxHeight: 420, overflowY: "auto", padding: 10,
246
+ }}>
247
+ {categories.map((cat) => (
248
+ <div key={cat.category} style={{ marginBottom: 8 }}>
249
+ <div className="ws-label" style={{ padding: "6px 8px", margin: 0, opacity: 0.75 }}>{cat.category}</div>
250
+ {cat.connectors.map((c) => {
251
+ const Icon = KIND_ICONS[c.kind] ?? Database;
252
+ return (
253
+ <button key={c.kind} className="ws-btn" data-active={active?.kind === c.kind}
254
+ onClick={() => selectConnector(c)}
255
+ style={{ width: "100%", justifyContent: "flex-start", fontSize: 12.5, marginBottom: 2 }}>
256
+ <Icon size={14} />
257
+ <span style={{ flex: 1, textAlign: "left" }}>{c.label}</span>
258
+ {c.extra_dependency ? (
259
+ <span title={`Extra dependency: ${c.extra_dependency}`}
260
+ style={{ fontSize: 10, opacity: 0.6, color: "#c9a227" }}>dep</span>
261
+ ) : null}
262
+ </button>
263
+ );
264
+ })}
179
265
  </div>
180
- <div className="ws-body" style={{ fontSize: 12 }}>{active.hint}</div>
266
+ ))}
267
+ </div>
268
+ ) : null}
181
269
 
182
- <div>
183
- <label className="ws-label">{active.kind === "file" ? "File path" : active.kind === "web" || active.kind === "rest" ? "URL" : active.kind === "databricks" || active.kind === "snowflake" ? "Table (or SQL query)" : active.kind === "sap" ? "Entity set" : "SObject"}</label>
270
+ {active ? (
271
+ <div style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 4 }}>
272
+ {active.credential_hint ? (
273
+ <div className="ws-body" style={{ fontSize: 12 }}>
274
+ <KeyRound size={12} style={{ verticalAlign: -2, marginRight: 6 }} />
275
+ Credentials: {active.credential_hint}
276
+ </div>
277
+ ) : null}
278
+ {active.env_fallback ? (
279
+ <div className="ws-body" style={{ fontSize: 11, opacity: 0.75 }}>
280
+ Env fallback: <code>{active.env_fallback}</code>
281
+ </div>
282
+ ) : null}
283
+ {active.extra_dependency ? (
284
+ <div style={{ fontSize: 11, color: "#a8791f" }}>
285
+ Requires extra dependency: <code>{active.extra_dependency}</code>
286
+ </div>
287
+ ) : null}
288
+ </div>
289
+ ) : null}
290
+ </div>
291
+
292
+ {active ? (
293
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 18, alignItems: "start" }}>
294
+ {/* Credentials card */}
295
+ <div className="ws-card" style={{ padding: 22, display: "flex", flexDirection: "column", gap: 14 }}>
296
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
297
+ <Lock size={16} color="var(--ws-accent)" />
298
+ <div style={{ color: "var(--ws-text)", fontWeight: 700, fontSize: 14 }}>Credentials</div>
299
+ </div>
300
+
301
+ <div>
302
+ <label className="ws-label">Saved credential (from vault)</label>
303
+ <select className="ws-input" style={{ width: "100%" }} value={credentialId}
304
+ onChange={(e) => applyCredential(e.target.value)} disabled={running}>
305
+ <option value="">— none (enter below / use env) —</option>
306
+ {credentials.map((c) => (
307
+ <option key={c.id} value={c.id}>{c.label} ({c.id})</option>
308
+ ))}
309
+ </select>
310
+ {vaultForKind.length > 0 ? (
311
+ <div className="ws-body" style={{ fontSize: 11, marginTop: 4 }}>
312
+ {vaultForKind.length} credential(s) saved for {active.kind}
313
+ </div>
314
+ ) : null}
315
+ </div>
316
+
317
+ {active.optional_fields.filter((f) => !["as_query", "include_subfolders", "download", "as_file", "respect_robots", "unread_only"].includes(f.name)).map((f) => (
318
+ <div key={f.name}>
319
+ <label className="ws-label">{f.label}{f.required ? " *" : ""}</label>
184
320
  <input className="ws-input" style={{ width: "100%" }}
185
- value={sourceRef}
186
- onChange={(e) => setSourceRef(e.target.value)}
187
- placeholder={active.kind === "file" ? "/data/documents/report.txt" : active.kind === "databricks" ? "catalog.schema.table" : active.kind === "snowflake" ? "DATABASE.SCHEMA.TABLE" : active.kind === "sap" ? "SalesOrderSet" : active.kind === "salesforce" ? "Account" : "https://…"}
321
+ type={f.secret ? "password" : "text"}
322
+ value={String(values[f.name] ?? "")}
323
+ onChange={(e) => setValues((v) => ({ ...v, [f.name]: e.target.value }))}
188
324
  disabled={running} />
189
325
  </div>
326
+ ))}
190
327
 
191
- {active.fields.filter((f) => f.name !== "source").map((f) => (
192
- <div key={f.name}>
193
- <label className="ws-label">{f.label}{f.required ? " *" : ""}</label>
194
- {f.boolean ? (
195
- <button className={`ws-btn ${values[f.name] ? "ws-btn--primary" : "ws-btn--ghost"}`}
196
- onClick={() => setValues((v) => ({ ...v, [f.name]: !v[f.name] }))}
197
- style={{ justifyContent: "center" }}>
198
- {values[f.name] ? "Yes" : "No"}
199
- </button>
200
- ) : (
201
- <input className="ws-input" style={{ width: "100%" }}
202
- type={f.secret ? "password" : "text"}
203
- value={String(values[f.name] ?? "")}
204
- onChange={(e) => setValues((v) => ({ ...v, [f.name]: e.target.value }))}
205
- disabled={running} />
206
- )}
207
- </div>
208
- ))}
328
+ {active.optional_fields.filter((f) => f.boolean).length > 0 ? (
329
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
330
+ {active.optional_fields.filter((f) => f.boolean).map((f) => (
331
+ <button key={f.name} className={`ws-btn ${values[f.name] ? "ws-btn--primary" : "ws-btn--ghost"}`}
332
+ onClick={() => setValues((v) => ({ ...v, [f.name]: !v[f.name] }))}
333
+ disabled={running} style={{ fontSize: 11.5 }}>
334
+ {f.label}: {values[f.name] ? "Yes" : "No"}
335
+ </button>
336
+ ))}
337
+ </div>
338
+ ) : null}
209
339
 
210
- {/* Extraction toggle */}
211
- <button className={`ws-btn ${extract ? "ws-btn--primary" : "ws-btn--ghost"}`}
212
- onClick={() => setExtract((v) => !v)} disabled={running}
213
- style={{ justifyContent: "center" }}>
214
- {extract ? "Extraction ON — build entities & relations" : "Extraction OFF — documents only"}
340
+ <div style={{ display: "flex", gap: 8, alignItems: "flex-end" }}>
341
+ <div style={{ flex: 1 }}>
342
+ <label className="ws-label">Save as (vault name)</label>
343
+ <input className="ws-input" style={{ width: "100%" }} value={vaultName}
344
+ onChange={(e) => setVaultName(e.target.value)} disabled={running} />
345
+ </div>
346
+ <button className="ws-btn ws-btn--ghost" onClick={handleSaveCredential}
347
+ disabled={running} style={{ whiteSpace: "nowrap" }}>
348
+ <Save size={14} /> Save
215
349
  </button>
350
+ </div>
216
351
 
217
- {/* Validation result */}
218
- {validation ? (
219
- <div style={{ display: "flex", alignItems: "center", gap: 8, padding: "10px 14px", borderRadius: "var(--ws-radius-sm)", border: `1px solid ${validation.ok ? "rgba(76,195,138,0.4)" : "rgba(236,35,21,0.4)"}`, background: "rgba(255,255,255,0.6)" }}>
220
- {validation.ok ? <CheckCircle2 size={15} color="#1f9d5f" /> : <AlertCircle size={15} color="#ec2315" />}
221
- <span style={{ fontSize: 13, color: "var(--ws-text)" }}>{validation.detail}</span>
222
- </div>
223
- ) : null}
224
-
225
- {/* Progress */}
226
- {job ? (
227
- <div style={{ display: "flex", flexDirection: "column", gap: 8, padding: "14px 16px", borderRadius: "var(--ws-radius-sm)", background: "rgba(0,0,0,0.16)", border: "1px solid var(--ws-border)" }}>
228
- <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
229
- {job.status === "running" || job.status === "queued" ? <Loader2 size={15} className="ws-spin" color="var(--ws-accent)" /> : job.status === "done" ? <CheckCircle2 size={15} color="#1f9d5f" /> : <AlertCircle size={15} color="#ec2315" />}
230
- <span style={{ fontSize: 13, color: "var(--ws-text)", fontWeight: 600 }}>{job.message}</span>
231
- </div>
232
- <div style={{ height: 6, borderRadius: 3, background: "rgba(0,0,0,0.25)", overflow: "hidden" }}>
233
- <div style={{ width: `${job.progress}%`, height: "100%", background: job.status === "error" ? "#ec2315" : "var(--ws-accent)", transition: "width 400ms ease" }} />
352
+ {credentials.length > 0 ? (
353
+ <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
354
+ <div className="ws-label" style={{ marginBottom: 2 }}>Vault entries</div>
355
+ {credentials.map((c) => (
356
+ <div key={c.id} style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12 }}>
357
+ <CheckCircle2 size={12} color="#1f9d5f" />
358
+ <span style={{ flex: 1, color: "var(--ws-text)" }}>{c.label}</span>
359
+ <button className="ws-btn ws-btn--ghost" style={{ padding: "2px 8px" }}
360
+ onClick={() => handleDeleteCredential(c.id)} title="Delete">
361
+ <Trash2 size={12} />
362
+ </button>
234
363
  </div>
235
- <div className="ws-body" style={{ fontSize: 11 }}>Stage: {job.stage}</div>
236
- </div>
237
- ) : null}
364
+ ))}
365
+ </div>
366
+ ) : null}
367
+ </div>
368
+
369
+ {/* Run card */}
370
+ <div className="ws-card" style={{ padding: 22, display: "flex", flexDirection: "column", gap: 14 }}>
371
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
372
+ <Play size={16} color="var(--ws-accent)" />
373
+ <div style={{ color: "var(--ws-text)", fontWeight: 700, fontSize: 14 }}>Run ingestion</div>
374
+ </div>
375
+
376
+ <div>
377
+ <label className="ws-label">{active.source_label}</label>
378
+ <input className="ws-input" style={{ width: "100%" }} value={sourceRef}
379
+ onChange={(e) => setSourceRef(e.target.value)}
380
+ placeholder={active.source_placeholder} disabled={running} />
381
+ </div>
382
+
383
+ <button className={`ws-btn ${extract ? "ws-btn--primary" : "ws-btn--ghost"}`}
384
+ onClick={() => setExtract((v) => !v)} disabled={running}
385
+ style={{ justifyContent: "center", fontSize: 12 }}>
386
+ {extract ? "Extraction ON — build entities & relations" : "Extraction OFF — documents only"}
387
+ </button>
238
388
 
239
- <div style={{ display: "flex", gap: 10 }}>
240
- <button className="ws-btn ws-btn--ghost" onClick={handleValidate}
241
- disabled={validating || running || !sourceRef.trim()}
242
- style={{ flex: 1, justifyContent: "center" }}>
243
- {validating ? <Loader2 size={15} className="ws-spin" /> : <Plug size={15} />} Test connection
244
- </button>
245
- <button className="ws-btn" onClick={handleRun}
246
- disabled={running || validating || !sourceRef.trim()}
247
- style={{ flex: 2, justifyContent: "center", background: "var(--ws-accent-soft)", borderColor: "var(--ws-accent)", color: "var(--ws-accent)" }}>
248
- {running ? <><Loader2 size={15} className="ws-spin" />Ingesting…</> : <><Play size={15} />Run ingestion</>}
249
- </button>
389
+ <button className={`ws-btn ${saveToVault ? "ws-btn--primary" : "ws-btn--ghost"}`}
390
+ onClick={() => setSaveToVault((v) => !v)} disabled={running}
391
+ style={{ justifyContent: "center", fontSize: 12 }}>
392
+ <Lock size={13} /> {saveToVault ? "Auto-save credentials to vault" : "Don't save credentials"}
393
+ </button>
394
+
395
+ {validation ? (
396
+ <div style={{ display: "flex", alignItems: "center", gap: 8, padding: "10px 14px", borderRadius: "var(--ws-radius-sm)", border: `1px solid ${validation.ok ? "rgba(76,195,138,0.4)" : "rgba(236,35,21,0.4)"}`, background: "rgba(255,255,255,0.6)" }}>
397
+ {validation.ok ? <CheckCircle2 size={15} color="#1f9d5f" /> : <AlertCircle size={15} color="#ec2315" />}
398
+ <span style={{ fontSize: 12.5, color: "var(--ws-text)" }}>{validation.detail}</span>
399
+ </div>
400
+ ) : null}
401
+
402
+ {job ? (
403
+ <div style={{ display: "flex", flexDirection: "column", gap: 8, padding: "14px 16px", borderRadius: "var(--ws-radius-sm)", background: "rgba(0,0,0,0.16)", border: "1px solid var(--ws-border)" }}>
404
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
405
+ {job.status === "running" || job.status === "queued"
406
+ ? <Loader2 size={15} className="ws-spin" color="var(--ws-accent)" />
407
+ : job.status === "done" ? <CheckCircle2 size={15} color="#1f9d5f" /> : <AlertCircle size={15} color="#ec2315" />}
408
+ <span style={{ fontSize: 12.5, color: "var(--ws-text)", fontWeight: 600 }}>{job.message}</span>
409
+ </div>
410
+ <div style={{ height: 6, borderRadius: 3, background: "rgba(0,0,0,0.25)", overflow: "hidden" }}>
411
+ <div style={{ width: `${job.progress}%`, height: "100%", background: job.status === "error" ? "#ec2315" : "var(--ws-accent)", transition: "width 400ms ease" }} />
412
+ </div>
413
+ <div className="ws-body" style={{ fontSize: 11 }}>Stage: {job.stage}</div>
250
414
  </div>
251
- </>
252
- )}
415
+ ) : null}
416
+
417
+ <div style={{ display: "flex", gap: 10 }}>
418
+ <button className="ws-btn ws-btn--ghost" onClick={handleValidate}
419
+ disabled={validating || running} style={{ flex: 1, justifyContent: "center" }}>
420
+ {validating ? <Loader2 size={15} className="ws-spin" /> : <Plug size={15} />} Test connection
421
+ </button>
422
+ <button className="ws-btn" onClick={handleRun} disabled={running || validating}
423
+ style={{ flex: 2, justifyContent: "center", background: "var(--ws-accent-soft)", borderColor: "var(--ws-accent)", color: "var(--ws-accent)" }}>
424
+ {running ? <><Loader2 size={15} className="ws-spin" />Ingesting…</> : <><Play size={15} />Run ingestion</>}
425
+ </button>
426
+ </div>
427
+ </div>
253
428
  </div>
254
- </div>
429
+ ) : null}
255
430
 
256
- {/* Toasts */}
257
431
  <div style={{ position: "fixed", bottom: 28, right: 28, display: "flex", flexDirection: "column", gap: 10, zIndex: 1000 }}>
258
432
  {toasts.map((t) => (
259
433
  <div key={t.id} className="ws-animate-in" style={{ display: "flex", alignItems: "center", gap: 10, padding: "13px 18px", borderRadius: "var(--ws-radius-sm)", background: "rgba(255,255,255,0.96)", border: `1px solid ${t.type === "success" ? "rgba(76,195,138,0.4)" : "rgba(255,123,114,0.4)"}`, boxShadow: "0 8px 24px rgba(0,0,0,0.5)", backdropFilter: "blur(12px)" }}>
@@ -265,4 +439,4 @@ export function IngestWorkspace() {
265
439
  </div>
266
440
  </div>
267
441
  );
268
- }
442
+ }
@@ -1,15 +1,15 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import {
3
- Sparkles, CheckCircle2, AlertCircle, Loader2, Send, Info,
3
+ Sparkles, CheckCircle2, AlertCircle, Loader2, Send, Info, Save, Plus, Trash2,
4
4
  } from "lucide-react";
5
5
 
6
6
  type Provider = {
7
7
  name: string; label: string; env_var: string | null;
8
8
  models: string[]; configured: boolean; env_value_hint: string | null;
9
+ type: "builtin" | "custom"; base_url?: string;
9
10
  };
10
11
 
11
12
  type UsageRow = { task: string; when: string; where: string };
12
-
13
13
  type Toast = { id: number; type: "success" | "error"; text: string };
14
14
 
15
15
  export function LlmSettingsWorkspace() {
@@ -17,18 +17,19 @@ export function LlmSettingsWorkspace() {
17
17
  const [usage, setUsage] = useState<UsageRow[]>([]);
18
18
  const [selected, setSelected] = useState<Provider | null>(null);
19
19
  const [model, setModel] = useState("");
20
+ const [fetchedModels, setFetchedModels] = useState<string[]>([]);
20
21
  const [testing, setTesting] = useState(false);
22
+ const [fetching, setFetching] = useState(false);
21
23
  const [result, setResult] = useState<{ ok: boolean; detail: string; model?: string } | null>(null);
22
- const [toasts, setToasts] = useState<Toast[]>([]);
23
24
 
24
- useEffect(() => {
25
- fetch("/api/llm/status").then((r) => r.json()).then((d) => {
26
- setProviders(d.providers ?? []);
27
- setUsage(d.usage ?? []);
28
- const first = (d.providers ?? []).find((p: Provider) => p.configured) ?? d.providers?.[0];
29
- if (first) { setSelected(first); setModel(first.models[0] ?? ""); }
30
- }).catch(() => {});
31
- }, []);
25
+ // custom endpoint form
26
+ const [showAdd, setShowAdd] = useState(false);
27
+ const [epName, setEpName] = useState("");
28
+ const [epLabel, setEpLabel] = useState("");
29
+ const [epBaseUrl, setEpBaseUrl] = useState("");
30
+ const [epKey, setEpKey] = useState("");
31
+ const [saving, setSaving] = useState(false);
32
+ const [toasts, setToasts] = useState<Toast[]>([]);
32
33
 
33
34
  function showToast(type: Toast["type"], text: string) {
34
35
  const id = Date.now();
@@ -36,30 +37,108 @@ export function LlmSettingsWorkspace() {
36
37
  setTimeout(() => setToasts((p) => p.filter((t) => t.id !== id)), 5000);
37
38
  }
38
39
 
40
+ async function loadStatus() {
41
+ try {
42
+ const r = await fetch("/api/llm/status");
43
+ const d = await r.json();
44
+ setProviders(d.providers ?? []);
45
+ setUsage(d.usage ?? []);
46
+ const first = (d.providers ?? []).find((p: Provider) => p.configured) ?? d.providers?.[0];
47
+ if (first) { setSelected((s) => s ?? first); setModel((m) => m || first.models[0] || ""); }
48
+ } catch { /* ignore */ }
49
+ }
50
+
51
+ useEffect(() => { loadStatus(); }, []);
52
+
53
+ function selectProvider(p: Provider) {
54
+ setSelected(p);
55
+ setResult(null);
56
+ setFetchedModels([]);
57
+ setModel(p.models[0] ?? "");
58
+ }
59
+
60
+ async function handleFetchModels() {
61
+ if (!selected) return;
62
+ setFetching(true);
63
+ try {
64
+ const body = selected.type === "custom"
65
+ ? { provider: "custom", credential_id: selected.name }
66
+ : { provider: selected.name };
67
+ const r = await fetch("/api/llm/fetch_models", {
68
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
69
+ });
70
+ const d = await r.json();
71
+ if (d.ok) {
72
+ setFetchedModels(d.models ?? []);
73
+ if (d.models?.length && !d.models.includes(model)) setModel(d.models[0]);
74
+ showToast("success", `Found ${d.models?.length ?? 0} models`);
75
+ } else {
76
+ showToast("error", d.detail ?? "Fetch failed");
77
+ }
78
+ } catch (e) {
79
+ showToast("error", e instanceof Error ? e.message : "Fetch failed");
80
+ } finally { setFetching(false); }
81
+ }
82
+
39
83
  async function handleTest() {
40
84
  if (!selected) return;
41
- setTesting(true);
42
- setResult(null);
85
+ setTesting(true); setResult(null);
43
86
  try {
44
- const res = await fetch("/api/llm/test", {
45
- method: "POST",
46
- headers: { "Content-Type": "application/json" },
47
- body: JSON.stringify({ provider: selected.name, model }),
87
+ const body = selected.type === "custom"
88
+ ? { provider: "custom", credential_id: selected.name, model }
89
+ : { provider: selected.name, model };
90
+ const r = await fetch("/api/llm/test", {
91
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
48
92
  });
49
- const data = await res.json();
50
- setResult(data);
51
- showToast(data.ok ? "success" : "error",
52
- data.ok ? `${selected.label} responded` : `${selected.label}: unavailable`);
93
+ const d = await r.json();
94
+ setResult(d);
95
+ showToast(d.ok ? "success" : "error", d.ok ? `${selected.label} responded` : `${selected.label}: unavailable`);
53
96
  } catch (e) {
54
97
  setResult({ ok: false, detail: e instanceof Error ? e.message : "Test failed" });
55
- } finally {
56
- setTesting(false);
98
+ } finally { setTesting(false); }
99
+ }
100
+
101
+ async function handleSaveEndpoint() {
102
+ if (!epName.trim() || !epBaseUrl.trim()) {
103
+ showToast("error", "Name and base URL are required");
104
+ return;
105
+ }
106
+ setSaving(true);
107
+ try {
108
+ const r = await fetch("/api/llm/save", {
109
+ method: "POST", headers: { "Content-Type": "application/json" },
110
+ body: JSON.stringify({ name: epName.trim(), label: epLabel.trim() || epName.trim(),
111
+ base_url: epBaseUrl.trim(), api_key: epKey,
112
+ default_model: model }),
113
+ });
114
+ const d = await r.json();
115
+ if (!r.ok) throw new Error(d.detail ?? `HTTP ${r.status}`);
116
+ showToast("success", "Endpoint saved to vault");
117
+ setShowAdd(false); setEpName(""); setEpLabel(""); setEpBaseUrl(""); setEpKey("");
118
+ await loadStatus();
119
+ } catch (e) {
120
+ showToast("error", e instanceof Error ? e.message : "Save failed");
121
+ } finally { setSaving(false); }
122
+ }
123
+
124
+ async function handleDeleteProvider(p: Provider) {
125
+ if (p.type !== "custom") return;
126
+ try {
127
+ const r = await fetch(`/api/vault/credentials/${encodeURIComponent(p.name)}`, { method: "DELETE" });
128
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
129
+ showToast("success", `${p.label} removed`);
130
+ if (selected?.name === p.name) setSelected(null);
131
+ await loadStatus();
132
+ } catch (e) {
133
+ showToast("error", e instanceof Error ? e.message : "Delete failed");
57
134
  }
58
135
  }
59
136
 
137
+ const modelChoices = fetchedModels.length > 0 ? fetchedModels : (selected?.models ?? []);
138
+
60
139
  return (
61
140
  <div className="ws-page ws-scroll">
62
- <div className="ws-padded" style={{ display: "flex", flexDirection: "column", gap: 24, maxWidth: 980, margin: "0 auto", width: "100%" }}>
141
+ <div className="ws-padded" style={{ display: "flex", flexDirection: "column", gap: 24, maxWidth: 1020, margin: "0 auto", width: "100%" }}>
63
142
  {/* Header */}
64
143
  <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
65
144
  <div style={{ width: 42, height: 42, borderRadius: 13, background: "var(--ws-accent-soft)", border: "1px solid var(--ws-border-strong)", display: "grid", placeItems: "center", color: "var(--ws-accent)", flexShrink: 0 }}>
@@ -68,12 +147,12 @@ export function LlmSettingsWorkspace() {
68
147
  <div>
69
148
  <h2 className="ws-title" style={{ fontSize: 18 }}>LLM Endpoints</h2>
70
149
  <div className="ws-body" style={{ marginTop: 2 }}>
71
- Where the platform uses language models, which are configured in this deployment, and a live test for each.
150
+ Built-in providers, OpenAI-compatible <code>/v1</code> endpoints (vLLM, Ollama, OpenRouter, gateways), and where the platform uses LLMs.
72
151
  </div>
73
152
  </div>
74
153
  </div>
75
154
 
76
- {/* Where LLMs are used — informational */}
155
+ {/* Where LLMs are used */}
77
156
  <div className="ws-card" style={{ padding: 24, display: "flex", flexDirection: "column", gap: 12 }}>
78
157
  <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
79
158
  <Info size={16} color="var(--ws-accent)" />
@@ -86,28 +165,66 @@ export function LlmSettingsWorkspace() {
86
165
  <div className="ws-body" style={{ fontSize: 11, marginTop: 2, fontFamily: "var(--ws-mono, monospace)", opacity: 0.7 }}>{u.where}</div>
87
166
  </div>
88
167
  ))}
89
- <div className="ws-body" style={{ fontSize: 12 }}>
90
- Credentials are read from environment variables when the Studio starts — set them in <code style={{ color: "var(--ws-accent)" }}>docker-compose.yml</code> (or <code style={{ color: "var(--ws-accent)" }}>.env</code>) and restart.
91
- </div>
92
168
  </div>
93
169
 
94
- {/* Provider grid */}
170
+ {/* Providers */}
95
171
  <div className="ws-card" style={{ padding: 24, display: "flex", flexDirection: "column", gap: 16 }}>
96
- <div style={{ color: "var(--ws-text)", fontWeight: 700, fontSize: 14 }}>Configured providers</div>
97
- <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(210px, 1fr))", gap: 12 }}>
172
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
173
+ <div style={{ color: "var(--ws-text)", fontWeight: 700, fontSize: 14 }}>Providers & endpoints</div>
174
+ <button className="ws-btn ws-btn--ghost" style={{ fontSize: 12 }} onClick={() => setShowAdd((v) => !v)}>
175
+ <Plus size={14} /> Add OpenAI-compatible endpoint
176
+ </button>
177
+ </div>
178
+
179
+ {showAdd ? (
180
+ <div style={{ padding: 16, borderRadius: "var(--ws-radius-sm)", border: "1px solid var(--ws-border-strong)", background: "rgba(0,0,0,0.16)", display: "flex", flexDirection: "column", gap: 10 }}>
181
+ <div className="ws-label" style={{ margin: 0 }}>New endpoint (stored encrypted in the vault)</div>
182
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
183
+ <div>
184
+ <label className="ws-label">Name *</label>
185
+ <input className="ws-input" style={{ width: "100%" }} value={epName}
186
+ onChange={(e) => setEpName(e.target.value)} placeholder="local-vllm" />
187
+ </div>
188
+ <div>
189
+ <label className="ws-label">Display label</label>
190
+ <input className="ws-input" style={{ width: "100%" }} value={epLabel}
191
+ onChange={(e) => setEpLabel(e.target.value)} placeholder="Local vLLM" />
192
+ </div>
193
+ </div>
194
+ <div>
195
+ <label className="ws-label">Base URL (OpenAI-compatible /v1) *</label>
196
+ <input className="ws-input" style={{ width: "100%" }} value={epBaseUrl}
197
+ onChange={(e) => setEpBaseUrl(e.target.value)} placeholder="http://localhost:8000/v1" />
198
+ </div>
199
+ <div>
200
+ <label className="ws-label">API key (optional — many local endpoints need none)</label>
201
+ <input className="ws-input" style={{ width: "100%" }} type="password" value={epKey}
202
+ onChange={(e) => setEpKey(e.target.value)} placeholder="sk-…" />
203
+ </div>
204
+ <button className="ws-btn" onClick={handleSaveEndpoint} disabled={saving}
205
+ style={{ justifyContent: "center", background: "var(--ws-accent-soft)", borderColor: "var(--ws-accent)", color: "var(--ws-accent)" }}>
206
+ {saving ? <Loader2 size={15} className="ws-spin" /> : <Save size={15} />} Save to vault
207
+ </button>
208
+ </div>
209
+ ) : null}
210
+
211
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: 12 }}>
98
212
  {providers.map((p) => (
99
213
  <button key={p.name} className="ws-btn" data-active={selected?.name === p.name}
100
- onClick={() => { setSelected(p); setModel(p.models[0] ?? ""); setResult(null); }}
214
+ onClick={() => selectProvider(p)}
101
215
  style={{ flexDirection: "column", alignItems: "flex-start", gap: 6, padding: 14 }}>
102
216
  <div style={{ display: "flex", alignItems: "center", gap: 8, width: "100%" }}>
103
- {p.configured
104
- ? <CheckCircle2 size={14} color="#1f9d5f" />
105
- : <AlertCircle size={14} color="#c9a227" />}
217
+ {p.configured ? <CheckCircle2 size={14} color="#1f9d5f" /> : <AlertCircle size={14} color="#c9a227" />}
106
218
  <span style={{ fontSize: 13, fontWeight: 700, color: "var(--ws-text)" }}>{p.label}</span>
219
+ {p.type === "custom" ? (
220
+ <span style={{ fontSize: 10, padding: "1px 6px", borderRadius: 6, background: "var(--ws-accent-soft)", color: "var(--ws-accent)" }}>custom</span>
221
+ ) : null}
107
222
  </div>
108
- <div className="ws-body" style={{ fontSize: 11, textAlign: "left" }}>
109
- {p.env_var ? <code>{p.env_var}</code> : "no key needed"}
110
- {p.configured && p.env_value_hint ? ` · set (${p.env_value_hint})` : p.env_var ? " · not set" : ""}
223
+ <div className="ws-body" style={{ fontSize: 11, textAlign: "left", wordBreak: "break-all" }}>
224
+ {p.type === "custom"
225
+ ? p.base_url
226
+ : p.env_var ? <code>{p.env_var}</code> : "no key needed"}
227
+ {p.configured && p.env_value_hint ? ` · set (${p.env_value_hint})` : p.env_var && !p.configured ? " · not set" : ""}
111
228
  </div>
112
229
  </button>
113
230
  ))}
@@ -116,11 +233,23 @@ export function LlmSettingsWorkspace() {
116
233
  {selected ? (
117
234
  <>
118
235
  <div>
119
- <label className="ws-label">Model</label>
120
- <select className="ws-input" style={{ width: "100%" }} value={model}
121
- onChange={(e) => setModel(e.target.value)}>
122
- {selected.models.map((m) => <option key={m} value={m}>{m}</option>)}
123
- </select>
236
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
237
+ <label className="ws-label">Model</label>
238
+ <button className="ws-btn ws-btn--ghost" style={{ fontSize: 11, padding: "3px 10px" }}
239
+ onClick={handleFetchModels} disabled={fetching || selected.type !== "custom"}>
240
+ {fetching ? <Loader2 size={12} className="ws-spin" /> : null}
241
+ {selected.type === "custom" ? "Fetch models from endpoint" : "Model list (built-in)"}
242
+ </button>
243
+ </div>
244
+ {modelChoices.length > 0 ? (
245
+ <select className="ws-input" style={{ width: "100%" }} value={model}
246
+ onChange={(e) => setModel(e.target.value)}>
247
+ {modelChoices.map((m) => <option key={m} value={m}>{m}</option>)}
248
+ </select>
249
+ ) : (
250
+ <input className="ws-input" style={{ width: "100%" }} value={model}
251
+ onChange={(e) => setModel(e.target.value)} placeholder="model id" />
252
+ )}
124
253
  </div>
125
254
 
126
255
  <button className="ws-btn" onClick={handleTest} disabled={testing}
@@ -139,6 +268,13 @@ export function LlmSettingsWorkspace() {
139
268
  <div className="ws-body" style={{ fontSize: 12, marginTop: 6, whiteSpace: "pre-wrap", fontFamily: "var(--ws-mono, monospace)" }}>{result.detail}</div>
140
269
  </div>
141
270
  ) : null}
271
+
272
+ {selected.type === "custom" ? (
273
+ <button className="ws-btn ws-btn--ghost" style={{ fontSize: 12, color: "#a33" }}
274
+ onClick={() => handleDeleteProvider(selected)}>
275
+ <Trash2 size={13} /> Remove this endpoint
276
+ </button>
277
+ ) : null}
142
278
  </>
143
279
  ) : null}
144
280
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ltm-blueverse/alpha-semantic-hub",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Alpha Semantic Hub \u2014 Graph-Native Infrastructure for Context and Accountable AI Systems",
5
5
  "keywords": [
6
6
  "alpha-semantic-hub",