@eng-ai/sdk 2.3.0 → 2.7.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 CHANGED
@@ -2,6 +2,69 @@
2
2
 
3
3
  All notable changes to `@eng-ai/sdk` will be documented in this file.
4
4
 
5
+ ## 2.7.0 - 2026-04-09
6
+
7
+ ### Added
8
+
9
+ - External Documents APIs (v2) in SDK:
10
+ - `listDocuments({ projectId?, tags?, tagMode? })`
11
+ - `getDocument(documentId)`
12
+ - `uploadDocument({ file, fileName?, contentType?, projectId?, tags? })`
13
+ - `updateDocumentTags(documentId, tags)`
14
+ - `deleteDocument(documentId)`
15
+ - Tag-aware query support for external integrations (`tagMode=any|all` + repeated `tags`).
16
+
17
+ ### Changed
18
+
19
+ - SDK internals now support multipart requests for file uploads with consistent timeout and error handling.
20
+
21
+ ## 2.6.0 - 2026-04-07
22
+
23
+ ### Added
24
+
25
+ - Vertex AI BYOK fields in settings APIs:
26
+ - `setAISettings({ vertexProjectId?, vertexLocation?, vertexServiceAccountJson? })`
27
+ - CLI `eng-ai settings ai set` now supports:
28
+ - `--provider vertex`
29
+ - `--vertex-project-id`
30
+ - `--vertex-location`
31
+ - `--vertex-service-account-json`
32
+
33
+ ### Security
34
+
35
+ - SDK debug redaction expanded for service-account sensitive keys (`service_account`, `private_key`, `private_key_id`, `client_email`, `client_id`).
36
+
37
+ ## 2.5.0 - 2026-04-05
38
+
39
+ ### Added
40
+
41
+ - External profile support (API key owner scope):
42
+ - `getProfile()`
43
+ - `updateProfile({ preferredName?, honorific?, crea?, specialty?, avatarUrl? })`
44
+ - CLI `eng-ai` profile commands:
45
+ - `profile get`
46
+ - `profile set --preferred-name ... --honorific ... --crea ... --specialty ... --avatar-url ...`
47
+
48
+ ### Changed
49
+
50
+ - Frontend profile update flow now writes through backend API only (`/api/v1/auth/me/profile`) and no longer depends on direct profile writes in Supabase client.
51
+
52
+ ## 2.4.0 - 2026-04-05
53
+
54
+ ### Added
55
+
56
+ - External user settings support (API key only):
57
+ - `getAISettings()`
58
+ - `setAISettings({ provider, model, projectModel?, apiKey?, googleApiKeyRag?, preferredTimezone? })`
59
+ - `clearAISettings()`
60
+ - `setVoiceLiveEnabled(enabled)`
61
+ - `getGeneralSettings()`
62
+ - `setGeneralSettings(preferredTimezone?)`
63
+ - CLI `eng-ai` commands for external settings:
64
+ - `settings ai get|set|clear`
65
+ - `settings ai voice-live --enabled true|false`
66
+ - `settings general get|set`
67
+
5
68
  ## 2.3.0 - 2026-04-05
6
69
 
7
70
  ### Added
package/README.md CHANGED
@@ -81,6 +81,33 @@ const context = await client.getProjectContext(project.id);
81
81
  console.log(context.autonomous_tasks_mode);
82
82
  ```
83
83
 
84
+ Project documents (tags, filters, upload, update):
85
+
86
+ ```js
87
+ const uploaded = await client.uploadDocument({
88
+ file: new Blob(["conteudo tecnico"], { type: "text/plain" }),
89
+ fileName: "escopo.txt",
90
+ projectId: project.id,
91
+ tags: ["Marco Estrutural", "Planejamento"]
92
+ });
93
+
94
+ await client.updateDocumentTags(uploaded.id, ["Marco Estrutural", "Revisado"]);
95
+
96
+ const docsAny = await client.listDocuments({
97
+ projectId: project.id,
98
+ tags: ["Marco Estrutural", "Revisado"],
99
+ tagMode: "any"
100
+ });
101
+
102
+ const docsAll = await client.listDocuments({
103
+ projectId: project.id,
104
+ tags: ["Marco Estrutural", "Revisado"],
105
+ tagMode: "all"
106
+ });
107
+
108
+ console.log(docsAny.length, docsAll.length);
109
+ ```
110
+
84
111
  External approval flow (pull):
85
112
 
86
113
  ```js
@@ -100,6 +127,36 @@ Supported automation plan actions:
100
127
  - `rejectAutomationPlan(projectId, planId, note?, { idempotencyKey? })`
101
128
  - `applyAutomationPlan(projectId, planId, { idempotencyKey? })`
102
129
 
130
+ External user settings (API key owner scope):
131
+
132
+ ```js
133
+ const ai = await client.getAISettings();
134
+ await client.setAISettings({
135
+ provider: "vertex",
136
+ model: "gemini-2.5-flash",
137
+ projectModel: "gemini-2.5-flash",
138
+ vertexProjectId: "my-gcp-project",
139
+ vertexLocation: "southamerica-east1",
140
+ vertexServiceAccountJson: process.env.VERTEX_SERVICE_ACCOUNT_JSON,
141
+ preferredTimezone: "America/Sao_Paulo",
142
+ });
143
+ await client.setVoiceLiveEnabled(true);
144
+ const general = await client.getGeneralSettings();
145
+ await client.setGeneralSettings(general.preferred_timezone || "America/Sao_Paulo");
146
+ ```
147
+
148
+ External profile (API key owner scope):
149
+
150
+ ```js
151
+ const profile = await client.getProfile();
152
+ await client.updateProfile({
153
+ preferredName: "Felipe",
154
+ honorific: "Eng.",
155
+ crea: "123456/D",
156
+ specialty: "Geotecnia",
157
+ });
158
+ ```
159
+
103
160
  CLI (`eng-ai`) included in package:
104
161
 
105
162
  ```bash
@@ -108,6 +165,13 @@ ENG_AI_API_KEY=sk_xxx eng-ai plans actionable list --limit 30
108
165
  ENG_AI_API_KEY=sk_xxx eng-ai plans approve --project-id <id> --plan-id <id> --note "Approved"
109
166
  ENG_AI_API_KEY=sk_xxx eng-ai plans apply --project-id <id> --plan-id <id>
110
167
  ENG_AI_API_KEY=sk_xxx eng-ai plans watch --interval 15 --limit 30
168
+ ENG_AI_API_KEY=sk_xxx eng-ai settings ai get
169
+ ENG_AI_API_KEY=sk_xxx eng-ai settings ai set --provider google --model gemini-3.1-flash-lite-preview --preferred-timezone America/Sao_Paulo
170
+ ENG_AI_API_KEY=sk_xxx eng-ai settings ai set --provider vertex --model gemini-2.5-flash --vertex-project-id my-gcp-project --vertex-location southamerica-east1 --vertex-service-account-json "$VERTEX_SERVICE_ACCOUNT_JSON"
171
+ ENG_AI_API_KEY=sk_xxx eng-ai settings ai voice-live --enabled true
172
+ ENG_AI_API_KEY=sk_xxx eng-ai settings general set --preferred-timezone America/Sao_Paulo
173
+ ENG_AI_API_KEY=sk_xxx eng-ai profile get
174
+ ENG_AI_API_KEY=sk_xxx eng-ai profile set --preferred-name Felipe --honorific "Eng." --crea "123456/D" --specialty Geotecnia
111
175
  ```
112
176
 
113
177
  Default base URL: `https://api.eng-ai.com/api/v2`.
package/bin/eng-ai.js CHANGED
@@ -40,6 +40,14 @@ function toPositiveInt(value, fallback) {
40
40
  return Math.floor(parsed);
41
41
  }
42
42
 
43
+ function toBoolean(value, fallback = undefined) {
44
+ if (value === undefined || value === null) return fallback;
45
+ const normalized = String(value).trim().toLowerCase();
46
+ if (["1", "true", "yes", "y", "on"].includes(normalized)) return true;
47
+ if (["0", "false", "no", "n", "off"].includes(normalized)) return false;
48
+ return fallback;
49
+ }
50
+
43
51
  function maskId(value) {
44
52
  const text = String(value || "");
45
53
  if (text.length <= 8) return text;
@@ -57,6 +65,14 @@ Usage:
57
65
  eng-ai plans reject --project-id <id> --plan-id <id> [--note <text>] [--idempotency-key <key>]
58
66
  eng-ai plans apply --project-id <id> --plan-id <id> [--idempotency-key <key>]
59
67
  eng-ai plans watch [--interval <seconds>] [--limit <n>]
68
+ eng-ai settings ai get
69
+ eng-ai settings ai set --provider <openai|anthropic|google|vertex> --model <name> [--project-model <name>] [--api-key <key>] [--google-api-key-rag <key>] [--preferred-timezone <IANA_TZ>] [--vertex-project-id <id>] [--vertex-location <region>] [--vertex-service-account-json <json>]
70
+ eng-ai settings ai clear
71
+ eng-ai settings ai voice-live --enabled <true|false>
72
+ eng-ai settings general get
73
+ eng-ai settings general set [--preferred-timezone <IANA_TZ>]
74
+ eng-ai profile get
75
+ eng-ai profile set [--preferred-name <name>] [--honorific <text>] [--crea <text>] [--specialty <text>] [--avatar-url <url>]
60
76
 
61
77
  Environment:
62
78
  ENG_AI_API_KEY (required)
@@ -192,6 +208,112 @@ async function runPlansCommand(client, subTokens, options) {
192
208
  console.log(JSON.stringify(result, null, 2));
193
209
  }
194
210
 
211
+ async function runSettingsCommand(client, subTokens, options) {
212
+ const section = String(subTokens[0] || "").trim().toLowerCase();
213
+ const action = String(subTokens[1] || "").trim().toLowerCase();
214
+
215
+ if (!section || !action) {
216
+ throw new Error("Unsupported settings command. Use: settings ai|get ...");
217
+ }
218
+
219
+ if (section === "ai") {
220
+ if (action === "get") {
221
+ const result = await client.getAISettings();
222
+ console.log(JSON.stringify(result, null, 2));
223
+ return;
224
+ }
225
+
226
+ if (action === "clear") {
227
+ const result = await client.clearAISettings();
228
+ console.log("AI settings removed.");
229
+ console.log(JSON.stringify(result, null, 2));
230
+ return;
231
+ }
232
+
233
+ if (action === "set") {
234
+ const provider = options.provider;
235
+ const model = options.model;
236
+ if (!provider || !model) {
237
+ throw new Error("Missing required options: --provider and --model");
238
+ }
239
+
240
+ const result = await client.setAISettings({
241
+ provider,
242
+ model,
243
+ projectModel: options["project-model"],
244
+ apiKey: options["api-key"],
245
+ googleApiKeyRag: options["google-api-key-rag"],
246
+ preferredTimezone: options["preferred-timezone"],
247
+ vertexProjectId: options["vertex-project-id"],
248
+ vertexLocation: options["vertex-location"],
249
+ vertexServiceAccountJson: options["vertex-service-account-json"],
250
+ });
251
+ console.log("AI settings updated.");
252
+ console.log(JSON.stringify(result, null, 2));
253
+ return;
254
+ }
255
+
256
+ if (action === "voice-live") {
257
+ const enabled = toBoolean(options.enabled, undefined);
258
+ if (enabled === undefined) {
259
+ throw new Error("Missing or invalid --enabled. Use --enabled true|false");
260
+ }
261
+ const result = await client.setVoiceLiveEnabled(enabled);
262
+ console.log("Voice-live toggle updated.");
263
+ console.log(JSON.stringify(result, null, 2));
264
+ return;
265
+ }
266
+ }
267
+
268
+ if (section === "general") {
269
+ if (action === "get") {
270
+ const result = await client.getGeneralSettings();
271
+ console.log(JSON.stringify(result, null, 2));
272
+ return;
273
+ }
274
+
275
+ if (action === "set") {
276
+ const result = await client.setGeneralSettings(options["preferred-timezone"] || null);
277
+ console.log("General settings updated.");
278
+ console.log(JSON.stringify(result, null, 2));
279
+ return;
280
+ }
281
+ }
282
+
283
+ throw new Error("Unsupported settings command. Use ai|get/set/clear/voice-live or general|get/set");
284
+ }
285
+
286
+ async function runProfileCommand(client, subTokens, options) {
287
+ const action = String(subTokens[0] || "").trim().toLowerCase();
288
+ if (action === "get") {
289
+ const result = await client.getProfile();
290
+ console.log(JSON.stringify(result, null, 2));
291
+ return;
292
+ }
293
+
294
+ if (action === "set") {
295
+ const payload = {
296
+ preferredName: options["preferred-name"],
297
+ honorific: options.honorific,
298
+ crea: options.crea,
299
+ specialty: options.specialty,
300
+ avatarUrl: options["avatar-url"],
301
+ };
302
+ const hasAnyField = Object.values(payload).some((value) => value !== undefined);
303
+ if (!hasAnyField) {
304
+ throw new Error(
305
+ "Missing profile fields. Use at least one: --preferred-name, --honorific, --crea, --specialty, --avatar-url"
306
+ );
307
+ }
308
+ const result = await client.updateProfile(payload);
309
+ console.log("Profile updated.");
310
+ console.log(JSON.stringify(result, null, 2));
311
+ return;
312
+ }
313
+
314
+ throw new Error("Unsupported profile command. Use: profile get|set");
315
+ }
316
+
195
317
  async function main() {
196
318
  const { tokens, options } = parseArgs(process.argv.slice(2));
197
319
  const command = tokens[0];
@@ -209,6 +331,14 @@ async function main() {
209
331
  await runPlansCommand(client, tokens.slice(1), options);
210
332
  return;
211
333
  }
334
+ if (command === "settings") {
335
+ await runSettingsCommand(client, tokens.slice(1), options);
336
+ return;
337
+ }
338
+ if (command === "profile") {
339
+ await runProfileCommand(client, tokens.slice(1), options);
340
+ return;
341
+ }
212
342
 
213
343
  throw new Error(`Unknown command: ${command}`);
214
344
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eng-ai/sdk",
3
- "version": "2.3.0",
3
+ "version": "2.7.0",
4
4
  "description": "Official JavaScript SDK for ENG-AI External API v2",
5
5
  "type": "module",
6
6
  "main": "./src/client.js",
package/src/client.js CHANGED
@@ -1,11 +1,13 @@
1
1
  const DEFAULT_BASE_URL = "https://api.eng-ai.com/api/v2";
2
2
  const DEFAULT_TIMEOUT_MS = 60000;
3
- const REDACT_KEY_PATTERN = /api[-_]?key|authorization|token|secret|password|cookie|set-cookie/i;
3
+ const REDACT_KEY_PATTERN =
4
+ /api[-_]?key|authorization|token|secret|password|cookie|set-cookie|service[-_]?account|private[-_]?key|private[-_]?key[-_]?id|client[-_]?email|client[-_]?id/i;
4
5
  const VALID_AUTONOMOUS_TASKS_MODES = new Set([
5
6
  "approval_required",
6
7
  "approval_delete_only",
7
8
  "full_autonomy",
8
9
  ]);
10
+ const VALID_DOCUMENT_TAG_MODES = new Set(["any", "all"]);
9
11
 
10
12
  function isPlainObject(value) {
11
13
  return !!value && Object.prototype.toString.call(value) === "[object Object]";
@@ -165,6 +167,28 @@ export class EngAIClient {
165
167
  return { ...this.defaultHeaders, ...extra };
166
168
  }
167
169
 
170
+ _normalizeTagArray(tags, fieldName = "tags") {
171
+ if (tags === undefined || tags === null) return [];
172
+ if (!Array.isArray(tags)) {
173
+ throw new EngAIError(`${fieldName} must be an array of strings`, {
174
+ status: 400,
175
+ code: "validation_error",
176
+ detail: `${fieldName} must be an array`,
177
+ });
178
+ }
179
+ const normalized = [];
180
+ const seen = new Set();
181
+ for (const rawTag of tags) {
182
+ const tag = String(rawTag || "").trim().replace(/\s+/g, " ");
183
+ if (!tag) continue;
184
+ const key = tag.toLowerCase();
185
+ if (seen.has(key)) continue;
186
+ seen.add(key);
187
+ normalized.push(tag);
188
+ }
189
+ return normalized;
190
+ }
191
+
168
192
  async _parseResponseBody(response) {
169
193
  const contentType = String(response.headers.get("content-type") || "").toLowerCase();
170
194
  if (contentType.includes("application/json") || contentType.includes("+json")) {
@@ -282,6 +306,77 @@ export class EngAIClient {
282
306
  }
283
307
  }
284
308
 
309
+ async _requestMultipart(pathname, { method = "POST", formData, headers = {} } = {}) {
310
+ if (!(formData instanceof FormData)) {
311
+ throw new EngAIError("formData must be a FormData instance", {
312
+ status: 400,
313
+ code: "validation_error",
314
+ detail: "multipart request requires FormData",
315
+ });
316
+ }
317
+
318
+ const url = this._buildUrl(pathname);
319
+ const requestHeaders = {
320
+ ...this.defaultHeaders,
321
+ ...headers,
322
+ };
323
+ delete requestHeaders["Content-Type"];
324
+
325
+ this._log("info", "request.start", {
326
+ method,
327
+ url,
328
+ headers: requestHeaders,
329
+ body: { type: "form-data" },
330
+ timeout_ms: this.timeoutMs,
331
+ });
332
+
333
+ try {
334
+ const response = await this._fetchWithTimeout(url, {
335
+ method,
336
+ headers: requestHeaders,
337
+ body: formData,
338
+ });
339
+ const parsedBody = await this._parseResponseBody(response);
340
+ if (!response.ok) {
341
+ const err = this._buildHttpError(response, parsedBody, method, url);
342
+ this._log("warn", "request.error", err.toJSON());
343
+ throw err;
344
+ }
345
+ this._log("info", "request.success", {
346
+ method,
347
+ url,
348
+ status: response.status,
349
+ request_id: extractRequestId(parsedBody),
350
+ });
351
+ return parsedBody;
352
+ } catch (error) {
353
+ if (error instanceof EngAIError) throw error;
354
+ if (error?.name === "AbortError") {
355
+ const timeoutErr = new EngAIError(
356
+ `ENG-AI request timeout after ${this.timeoutMs}ms`,
357
+ { status: 408, code: "request_timeout", method, url, detail: "Request timeout" },
358
+ error
359
+ );
360
+ this._log("warn", "request.timeout", timeoutErr.toJSON());
361
+ throw timeoutErr;
362
+ }
363
+
364
+ const networkErr = new EngAIError(
365
+ "ENG-AI network error",
366
+ {
367
+ status: 0,
368
+ code: "network_error",
369
+ detail: error?.message || "Unknown network error",
370
+ method,
371
+ url,
372
+ },
373
+ error
374
+ );
375
+ this._log("warn", "request.network_error", networkErr.toJSON());
376
+ throw networkErr;
377
+ }
378
+ }
379
+
285
380
  _requireNonEmptyString(value, fieldName) {
286
381
  const normalized = String(value || "").trim();
287
382
  if (!normalized) {
@@ -378,6 +473,204 @@ export class EngAIClient {
378
473
  });
379
474
  }
380
475
 
476
+ async listDocuments(options = {}) {
477
+ const query = new URLSearchParams();
478
+ if (options.projectId ?? options.project_id) {
479
+ query.set("project_id", String(options.projectId ?? options.project_id));
480
+ }
481
+
482
+ const tagMode = options.tagMode ?? options.tag_mode;
483
+ if (tagMode !== undefined && tagMode !== null) {
484
+ const normalizedTagMode = String(tagMode).trim().toLowerCase();
485
+ if (!VALID_DOCUMENT_TAG_MODES.has(normalizedTagMode)) {
486
+ throw new EngAIError("invalid tagMode for listDocuments", {
487
+ status: 400,
488
+ code: "validation_error",
489
+ detail: "tagMode must be one of: any, all",
490
+ });
491
+ }
492
+ query.set("tag_mode", normalizedTagMode);
493
+ }
494
+
495
+ const normalizedTags = this._normalizeTagArray(options.tags, "tags");
496
+ for (const tag of normalizedTags) {
497
+ query.append("tags", tag);
498
+ }
499
+
500
+ const suffix = query.toString() ? `?${query.toString()}` : "";
501
+ return await this._request(`/documents/${suffix}`, {
502
+ method: "GET",
503
+ });
504
+ }
505
+
506
+ async getDocument(documentId) {
507
+ const normalizedDocumentId = this._requireNonEmptyString(documentId, "documentId");
508
+ return await this._request(`/documents/${encodeURIComponent(normalizedDocumentId)}`, {
509
+ method: "GET",
510
+ });
511
+ }
512
+
513
+ async uploadDocument(payload = {}) {
514
+ const file = payload.file ?? payload.content;
515
+ if (file === undefined || file === null) {
516
+ throw new EngAIError("file is required for uploadDocument", {
517
+ status: 400,
518
+ code: "validation_error",
519
+ detail: "payload.file must be provided",
520
+ });
521
+ }
522
+
523
+ const formData = new FormData();
524
+ const filename = String(payload.fileName ?? payload.filename ?? "document.bin");
525
+ const contentType = payload.contentType ?? payload.content_type;
526
+ const normalizedProjectId = payload.projectId ?? payload.project_id;
527
+ const normalizedTags = this._normalizeTagArray(payload.tags, "tags");
528
+
529
+ if (
530
+ file instanceof Blob ||
531
+ (typeof File !== "undefined" && file instanceof File)
532
+ ) {
533
+ formData.append("file", file, filename);
534
+ } else if (file instanceof ArrayBuffer) {
535
+ formData.append(
536
+ "file",
537
+ new Blob([file], {
538
+ type: contentType ? String(contentType) : "application/octet-stream",
539
+ }),
540
+ filename
541
+ );
542
+ } else if (ArrayBuffer.isView(file)) {
543
+ formData.append(
544
+ "file",
545
+ new Blob([file], {
546
+ type: contentType ? String(contentType) : "application/octet-stream",
547
+ }),
548
+ filename
549
+ );
550
+ } else {
551
+ throw new EngAIError("unsupported file type for uploadDocument", {
552
+ status: 400,
553
+ code: "validation_error",
554
+ detail: "file must be Blob, File, ArrayBuffer, Buffer, or Uint8Array",
555
+ });
556
+ }
557
+
558
+ if (normalizedProjectId) {
559
+ formData.append("project_id", String(normalizedProjectId));
560
+ }
561
+ if (normalizedTags.length > 0) {
562
+ formData.append("tags", JSON.stringify(normalizedTags));
563
+ }
564
+
565
+ return await this._requestMultipart("/documents/upload", {
566
+ method: "POST",
567
+ formData,
568
+ });
569
+ }
570
+
571
+ async updateDocumentTags(documentId, tags = []) {
572
+ const normalizedDocumentId = this._requireNonEmptyString(documentId, "documentId");
573
+ const normalizedTags = this._normalizeTagArray(tags, "tags");
574
+ return await this._request(`/documents/${encodeURIComponent(normalizedDocumentId)}/tags`, {
575
+ method: "PATCH",
576
+ body: { tags: normalizedTags },
577
+ });
578
+ }
579
+
580
+ async deleteDocument(documentId) {
581
+ const normalizedDocumentId = this._requireNonEmptyString(documentId, "documentId");
582
+ return await this._request(`/documents/${encodeURIComponent(normalizedDocumentId)}`, {
583
+ method: "DELETE",
584
+ });
585
+ }
586
+
587
+ async getAISettings() {
588
+ return await this._request("/settings/llm", {
589
+ method: "GET",
590
+ });
591
+ }
592
+
593
+ async setAISettings(payload) {
594
+ const provider = this._requireNonEmptyString(payload?.provider, "provider").toLowerCase();
595
+ const model = this._requireNonEmptyString(payload?.model, "model");
596
+ const body = {
597
+ provider,
598
+ model,
599
+ project_model: payload?.projectModel ?? payload?.project_model,
600
+ api_key: payload?.apiKey ?? payload?.api_key,
601
+ google_api_key_rag: payload?.googleApiKeyRag ?? payload?.google_api_key_rag,
602
+ vertex_project_id: payload?.vertexProjectId ?? payload?.vertex_project_id,
603
+ vertex_location: payload?.vertexLocation ?? payload?.vertex_location,
604
+ vertex_service_account_json:
605
+ payload?.vertexServiceAccountJson ?? payload?.vertex_service_account_json,
606
+ preferred_timezone: payload?.preferredTimezone ?? payload?.preferred_timezone,
607
+ };
608
+
609
+ return await this._request("/settings/llm", {
610
+ method: "PUT",
611
+ body,
612
+ });
613
+ }
614
+
615
+ async clearAISettings() {
616
+ return await this._request("/settings/llm", {
617
+ method: "DELETE",
618
+ });
619
+ }
620
+
621
+ async setVoiceLiveEnabled(enabled) {
622
+ return await this._request("/settings/voice-live", {
623
+ method: "PUT",
624
+ body: { enabled: !!enabled },
625
+ });
626
+ }
627
+
628
+ async getGeneralSettings() {
629
+ return await this._request("/settings/general", {
630
+ method: "GET",
631
+ });
632
+ }
633
+
634
+ async setGeneralSettings(preferredTimezone = null) {
635
+ return await this._request("/settings/general", {
636
+ method: "PUT",
637
+ body: {
638
+ preferred_timezone: preferredTimezone || null,
639
+ },
640
+ });
641
+ }
642
+
643
+ async getProfile() {
644
+ return await this._request("/profile", {
645
+ method: "GET",
646
+ });
647
+ }
648
+
649
+ async updateProfile(payload = {}) {
650
+ const body = {
651
+ preferred_name: payload?.preferredName ?? payload?.preferred_name,
652
+ honorific: payload?.honorific,
653
+ crea: payload?.crea ?? payload?.crea_cau,
654
+ specialty: payload?.specialty,
655
+ avatar_url: payload?.avatarUrl ?? payload?.avatar_url,
656
+ };
657
+
658
+ const hasAnyField = Object.values(body).some((value) => value !== undefined);
659
+ if (!hasAnyField) {
660
+ throw new EngAIError("at least one profile field is required for updateProfile", {
661
+ status: 400,
662
+ code: "validation_error",
663
+ detail:
664
+ "provide one of: preferred_name, honorific, crea, specialty, avatar_url",
665
+ });
666
+ }
667
+
668
+ return await this._request("/profile", {
669
+ method: "PUT",
670
+ body,
671
+ });
672
+ }
673
+
381
674
  async getProjectContext(projectId) {
382
675
  const normalizedProjectId = this._requireNonEmptyString(projectId, "projectId");
383
676
  return await this._request(`/projects/${encodeURIComponent(normalizedProjectId)}/context`, {