cortexctl 0.1.0.dev20260811135705__py3-none-any.whl

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.
cortex_cli/client.py ADDED
@@ -0,0 +1,1222 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import mimetypes
5
+ import os
6
+ import secrets
7
+ from collections.abc import Callable, Iterator
8
+ from pathlib import Path
9
+ from typing import Any
10
+ from urllib.error import HTTPError, URLError
11
+ from urllib.parse import urlencode, urlparse
12
+ from urllib.request import Request, urlopen
13
+
14
+ DEFAULT_REQUEST_TIMEOUT_SECONDS = 30
15
+ MULTIPART_READ_CHUNK_SIZE = 1024 * 1024
16
+
17
+
18
+ class MultipartBody:
19
+ """Repeatable multipart body that streams the file from disk."""
20
+
21
+ def __init__(
22
+ self,
23
+ *,
24
+ prefix: bytes,
25
+ path: Path,
26
+ suffix: bytes,
27
+ ) -> None:
28
+ self.prefix = prefix
29
+ self.path = path
30
+ self.suffix = suffix
31
+ self.file_size = path.stat().st_size
32
+ self.content_length = len(prefix) + self.file_size + len(suffix)
33
+
34
+ def __iter__(self) -> Iterator[bytes]:
35
+ yield self.prefix
36
+ with self.path.open("rb") as fileobj:
37
+ while chunk := fileobj.read(MULTIPART_READ_CHUNK_SIZE):
38
+ yield chunk
39
+ yield self.suffix
40
+
41
+
42
+ class CliAPIError(Exception):
43
+ def __init__(
44
+ self,
45
+ *,
46
+ code: str,
47
+ message: str,
48
+ status_code: int,
49
+ details: dict[str, Any] | None = None,
50
+ ) -> None:
51
+ self.code = code
52
+ self.message = message
53
+ self.status_code = status_code
54
+ self.details = details or {}
55
+
56
+
57
+ class CortexClient:
58
+ def __init__(
59
+ self,
60
+ *,
61
+ base_url: str,
62
+ access_token: str | None = None,
63
+ refresh_token: str | None = None,
64
+ token_updater: Callable[[dict[str, Any]], None] | None = None,
65
+ ) -> None:
66
+ self.base_url = base_url.rstrip("/")
67
+ self.access_token = access_token
68
+ self.refresh_token = refresh_token
69
+ self.token_updater = token_updater
70
+
71
+ def debug_login(
72
+ self,
73
+ *,
74
+ email: str,
75
+ password: str | None,
76
+ display_name: str | None,
77
+ ) -> dict[str, Any]:
78
+ return self.post(
79
+ "/auth/login",
80
+ {
81
+ "email": email,
82
+ "password": password,
83
+ "display_name": display_name,
84
+ },
85
+ authenticated=False,
86
+ )
87
+
88
+ def feishu_cli_authorize(
89
+ self,
90
+ *,
91
+ cli_redirect_uri: str,
92
+ state: str,
93
+ code_challenge: str,
94
+ ) -> dict[str, Any]:
95
+ return self.post(
96
+ "/auth/feishu/cli/authorize",
97
+ {
98
+ "cli_redirect_uri": cli_redirect_uri,
99
+ "state": state,
100
+ "code_challenge": code_challenge,
101
+ },
102
+ authenticated=False,
103
+ )
104
+
105
+ def feishu_cli_token(
106
+ self,
107
+ *,
108
+ cli_auth_code: str,
109
+ code_verifier: str,
110
+ ) -> dict[str, Any]:
111
+ return self.post(
112
+ "/auth/feishu/cli/token",
113
+ {
114
+ "cli_auth_code": cli_auth_code,
115
+ "code_verifier": code_verifier,
116
+ },
117
+ authenticated=False,
118
+ )
119
+
120
+ def device_login_start(self) -> dict[str, Any]:
121
+ return self.post("/auth/device/start", {}, authenticated=False)
122
+
123
+ def device_login_token(self, *, device_code: str) -> dict[str, Any]:
124
+ return self.post(
125
+ "/auth/device/token",
126
+ {"device_code": device_code},
127
+ authenticated=False,
128
+ )
129
+
130
+ def me(self) -> dict[str, Any]:
131
+ return self.get("/me")
132
+
133
+ def health(self) -> dict[str, Any]:
134
+ return self.get("/health", authenticated=False)
135
+
136
+ def ready(self) -> dict[str, Any]:
137
+ return self.get("/ready", authenticated=False)
138
+
139
+ def workspace_panel(self) -> dict[str, Any]:
140
+ return self.get("/me/workspace-panel")
141
+
142
+ def create_workspace(self, *, name: str, description: str | None) -> dict[str, Any]:
143
+ return self.post("/workspaces", {"name": name, "description": description})
144
+
145
+ def get_workspace(self, workspace_id: str) -> dict[str, Any]:
146
+ return self.get(f"/workspaces/{workspace_id}")
147
+
148
+ def update_workspace(
149
+ self,
150
+ workspace_id: str,
151
+ *,
152
+ name: str | None,
153
+ description: str | None,
154
+ ) -> dict[str, Any]:
155
+ return self.patch(
156
+ f"/workspaces/{workspace_id}",
157
+ {"name": name, "description": description},
158
+ )
159
+
160
+ def delete_workspace(self, workspace_id: str) -> None:
161
+ self.delete(f"/workspaces/{workspace_id}")
162
+
163
+ def list_invites(
164
+ self,
165
+ workspace_id: str,
166
+ *,
167
+ page: int,
168
+ page_size: int,
169
+ query: str | None,
170
+ status: str | None,
171
+ ) -> dict[str, Any]:
172
+ return self.get(
173
+ f"/workspaces/{workspace_id}/invites",
174
+ query=compact_query(
175
+ {
176
+ "page": page,
177
+ "page_size": page_size,
178
+ "query": query,
179
+ "status": status,
180
+ }
181
+ ),
182
+ )
183
+
184
+ def create_invite(
185
+ self,
186
+ workspace_id: str,
187
+ *,
188
+ email: str,
189
+ role: str,
190
+ ) -> dict[str, Any]:
191
+ return self.post(
192
+ f"/workspaces/{workspace_id}/invites",
193
+ {"invite_email": email, "role": role},
194
+ )
195
+
196
+ def revoke_invite(self, workspace_id: str, invite_id: str) -> None:
197
+ self.delete(f"/workspaces/{workspace_id}/invites/{invite_id}")
198
+
199
+ def enter_invite(self, invite_id: str) -> dict[str, Any]:
200
+ return self.post(f"/workspace-invites/{invite_id}/enter", {})
201
+
202
+ def reject_invite(self, invite_id: str) -> dict[str, Any]:
203
+ return self.post(f"/workspace-invites/{invite_id}/reject", {})
204
+
205
+ def list_invite_links(
206
+ self,
207
+ workspace_id: str,
208
+ *,
209
+ page: int,
210
+ page_size: int,
211
+ ) -> dict[str, Any]:
212
+ return self.get(
213
+ f"/workspaces/{workspace_id}/invite-links",
214
+ query=compact_query({"page": page, "page_size": page_size}),
215
+ )
216
+
217
+ def create_invite_link(self, workspace_id: str, *, name: str) -> dict[str, Any]:
218
+ return self.post(f"/workspaces/{workspace_id}/invite-links", {"name": name})
219
+
220
+ def revoke_invite_link(self, workspace_id: str, link_id: str) -> None:
221
+ self.delete(f"/workspaces/{workspace_id}/invite-links/{link_id}")
222
+
223
+ def preview_invite_link(self, token_or_url: str) -> dict[str, Any]:
224
+ token = invite_link_token_from_value(token_or_url)
225
+ return self.get(f"/workspace-invite-links/{token}/preview", authenticated=False)
226
+
227
+ def enter_invite_link(self, token_or_url: str) -> dict[str, Any]:
228
+ token = invite_link_token_from_value(token_or_url)
229
+ return self.post(f"/workspace-invite-links/{token}/enter", {})
230
+
231
+ def list_members(
232
+ self,
233
+ workspace_id: str,
234
+ *,
235
+ page: int,
236
+ page_size: int,
237
+ query: str | None,
238
+ ) -> dict[str, Any]:
239
+ return self.get(
240
+ f"/workspaces/{workspace_id}/members",
241
+ query=compact_query({"page": page, "page_size": page_size, "query": query}),
242
+ )
243
+
244
+ def update_member_role(
245
+ self,
246
+ workspace_id: str,
247
+ user_id: str,
248
+ *,
249
+ role: str,
250
+ ) -> dict[str, Any]:
251
+ return self.patch(
252
+ f"/workspaces/{workspace_id}/members/{user_id}",
253
+ {"role": role},
254
+ )
255
+
256
+ def remove_member(self, workspace_id: str, user_id: str) -> None:
257
+ self.delete(f"/workspaces/{workspace_id}/members/{user_id}")
258
+
259
+ def leave_workspace(self, workspace_id: str) -> None:
260
+ self.post(f"/workspaces/{workspace_id}/leave", {})
261
+
262
+ def transfer_owner(
263
+ self,
264
+ workspace_id: str,
265
+ *,
266
+ new_owner_id: str,
267
+ ) -> list[dict[str, Any]]:
268
+ return self.post(
269
+ f"/workspaces/{workspace_id}/transfer-owner",
270
+ {"new_owner_id": new_owner_id},
271
+ )
272
+
273
+ def list_knowledge_bases(
274
+ self,
275
+ workspace_id: str,
276
+ *,
277
+ page: int,
278
+ page_size: int,
279
+ query: str | None,
280
+ ) -> dict[str, Any]:
281
+ return self.get(
282
+ f"/workspaces/{workspace_id}/knowledge-bases",
283
+ query=compact_query({"page": page, "page_size": page_size, "query": query}),
284
+ )
285
+
286
+ def create_knowledge_base(
287
+ self,
288
+ workspace_id: str,
289
+ *,
290
+ name: str,
291
+ description: str | None,
292
+ pipeline_id: str | None,
293
+ ) -> dict[str, Any]:
294
+ return self.post(
295
+ f"/workspaces/{workspace_id}/knowledge-bases",
296
+ {
297
+ "name": name,
298
+ "description": description,
299
+ "pipeline_id": pipeline_id,
300
+ },
301
+ )
302
+
303
+ def get_knowledge_base(self, workspace_id: str, kb_id: str) -> dict[str, Any]:
304
+ return self.get(f"/workspaces/{workspace_id}/knowledge-bases/{kb_id}")
305
+
306
+ def update_knowledge_base(
307
+ self,
308
+ workspace_id: str,
309
+ kb_id: str,
310
+ payload: dict[str, Any],
311
+ ) -> dict[str, Any]:
312
+ return self.patch(
313
+ f"/workspaces/{workspace_id}/knowledge-bases/{kb_id}",
314
+ payload,
315
+ )
316
+
317
+ def delete_knowledge_base(self, workspace_id: str, kb_id: str) -> None:
318
+ self.delete(f"/workspaces/{workspace_id}/knowledge-bases/{kb_id}")
319
+
320
+ def update_knowledge_base_order(
321
+ self,
322
+ workspace_id: str,
323
+ items: list[dict[str, Any]],
324
+ ) -> dict[str, Any]:
325
+ return self.put(
326
+ f"/workspaces/{workspace_id}/knowledge-bases/order",
327
+ {"items": items},
328
+ )
329
+
330
+ def get_knowledge_base_permissions(
331
+ self,
332
+ workspace_id: str,
333
+ kb_id: str,
334
+ ) -> dict[str, Any]:
335
+ return self.get(
336
+ f"/workspaces/{workspace_id}/knowledge-bases/{kb_id}/permissions"
337
+ )
338
+
339
+ def update_knowledge_base_permissions(
340
+ self,
341
+ workspace_id: str,
342
+ kb_id: str,
343
+ *,
344
+ edit_policy: str,
345
+ editor_user_ids: list[str],
346
+ ) -> dict[str, Any]:
347
+ return self.put(
348
+ f"/workspaces/{workspace_id}/knowledge-bases/{kb_id}/permissions",
349
+ {
350
+ "edit_policy": edit_policy,
351
+ "editor_user_ids": editor_user_ids,
352
+ },
353
+ )
354
+
355
+ def get_folder_tree(self, workspace_id: str, kb_id: str) -> list[dict[str, Any]]:
356
+ return self.get(
357
+ f"/workspaces/{workspace_id}/knowledge-bases/{kb_id}/folders/tree"
358
+ )
359
+
360
+ def create_folder(
361
+ self,
362
+ workspace_id: str,
363
+ kb_id: str,
364
+ *,
365
+ name: str,
366
+ parent_id: str | None,
367
+ ) -> dict[str, Any]:
368
+ return self.post(
369
+ f"/workspaces/{workspace_id}/knowledge-bases/{kb_id}/folders",
370
+ {"name": name, "parent_id": parent_id},
371
+ )
372
+
373
+ def update_folder(
374
+ self,
375
+ workspace_id: str,
376
+ kb_id: str,
377
+ folder_id: str,
378
+ payload: dict[str, Any],
379
+ ) -> dict[str, Any]:
380
+ return self.patch(
381
+ f"/workspaces/{workspace_id}/knowledge-bases/{kb_id}/folders/{folder_id}",
382
+ payload,
383
+ )
384
+
385
+ def delete_folder(self, workspace_id: str, kb_id: str, folder_id: str) -> None:
386
+ self.delete(
387
+ f"/workspaces/{workspace_id}/knowledge-bases/{kb_id}/folders/{folder_id}"
388
+ )
389
+
390
+ def list_items(
391
+ self,
392
+ workspace_id: str,
393
+ *,
394
+ page: int,
395
+ page_size: int,
396
+ query: str | None,
397
+ is_available: bool | None,
398
+ is_locked: bool | None,
399
+ kb_id: str | None,
400
+ folder_id: str | None,
401
+ unclassified: bool,
402
+ ) -> dict[str, Any]:
403
+ return self.get(
404
+ f"/workspaces/{workspace_id}/knowledge-items",
405
+ query=compact_query(
406
+ {
407
+ "page": page,
408
+ "page_size": page_size,
409
+ "query": query,
410
+ "is_available": is_available,
411
+ "is_locked": is_locked,
412
+ "kb_id": kb_id,
413
+ "folder_id": folder_id,
414
+ "unclassified": unclassified if unclassified else None,
415
+ }
416
+ ),
417
+ )
418
+
419
+ def create_item(
420
+ self,
421
+ workspace_id: str,
422
+ payload: dict[str, Any],
423
+ ) -> dict[str, Any]:
424
+ return self.post(f"/workspaces/{workspace_id}/knowledge-items", payload)
425
+
426
+ def get_item(self, workspace_id: str, item_id: str) -> dict[str, Any]:
427
+ return self.get(f"/workspaces/{workspace_id}/knowledge-items/{item_id}")
428
+
429
+ def update_item(
430
+ self,
431
+ workspace_id: str,
432
+ item_id: str,
433
+ payload: dict[str, Any],
434
+ ) -> dict[str, Any]:
435
+ return self.patch(
436
+ f"/workspaces/{workspace_id}/knowledge-items/{item_id}",
437
+ payload,
438
+ )
439
+
440
+ def delete_item(self, workspace_id: str, item_id: str) -> None:
441
+ self.delete(f"/workspaces/{workspace_id}/knowledge-items/{item_id}")
442
+
443
+ def replace_item_metadata(
444
+ self,
445
+ workspace_id: str,
446
+ item_id: str,
447
+ metadata: list[dict[str, Any]],
448
+ ) -> dict[str, Any]:
449
+ return self.put(
450
+ f"/workspaces/{workspace_id}/knowledge-items/{item_id}/metadata",
451
+ {"metadata": metadata},
452
+ )
453
+
454
+ def create_item_mount(
455
+ self,
456
+ workspace_id: str,
457
+ item_id: str,
458
+ *,
459
+ kb_id: str,
460
+ folder_id: str | None,
461
+ ) -> dict[str, Any]:
462
+ return self.post(
463
+ f"/workspaces/{workspace_id}/knowledge-items/{item_id}/mounts",
464
+ {"kb_id": kb_id, "folder_id": folder_id},
465
+ )
466
+
467
+ def delete_item_mount(
468
+ self,
469
+ workspace_id: str,
470
+ item_id: str,
471
+ *,
472
+ kb_id: str,
473
+ folder_id: str | None,
474
+ ) -> dict[str, Any]:
475
+ return self.delete_with_payload(
476
+ f"/workspaces/{workspace_id}/knowledge-items/{item_id}/mounts",
477
+ {"kb_id": kb_id, "folder_id": folder_id},
478
+ )
479
+
480
+ def list_trash_entries(
481
+ self,
482
+ workspace_id: str,
483
+ *,
484
+ page: int,
485
+ page_size: int,
486
+ ) -> dict[str, Any]:
487
+ return self.get(
488
+ f"/workspaces/{workspace_id}/trash",
489
+ query=compact_query({"page": page, "page_size": page_size}),
490
+ )
491
+
492
+ def restore_trash_entry(
493
+ self,
494
+ workspace_id: str,
495
+ entry_id: str,
496
+ *,
497
+ target: dict[str, Any],
498
+ ) -> dict[str, Any]:
499
+ return self.post(
500
+ f"/workspaces/{workspace_id}/trash/{entry_id}/restore",
501
+ {"target": target},
502
+ )
503
+
504
+ def purge_trash_entry(self, workspace_id: str, entry_id: str) -> None:
505
+ self.delete(f"/workspaces/{workspace_id}/trash/{entry_id}")
506
+
507
+ def discard_trash_entry(self, workspace_id: str, entry_id: str) -> None:
508
+ self.purge_trash_entry(workspace_id, entry_id)
509
+
510
+ def upload_knowledge_item(
511
+ self,
512
+ workspace_id: str,
513
+ *,
514
+ path: Path,
515
+ title: str | None,
516
+ kb_id: str | None,
517
+ folder_id: str | None,
518
+ relation_type: str,
519
+ pipeline_id: str | None,
520
+ metadata: list[dict[str, Any]],
521
+ idempotency_key: str | None,
522
+ ) -> dict[str, Any]:
523
+ fields: dict[str, Any] = {
524
+ "title": title,
525
+ "kb_id": kb_id,
526
+ "folder_id": folder_id,
527
+ "relation_type": relation_type,
528
+ "pipeline_id": pipeline_id,
529
+ "metadata": json.dumps(metadata) if metadata else None,
530
+ }
531
+ return require_json_payload(
532
+ self.multipart_post(
533
+ f"/workspaces/{workspace_id}/uploads/knowledge-item",
534
+ fields=compact_query(fields),
535
+ file_field="file",
536
+ file_path=path,
537
+ idempotency_key=idempotency_key,
538
+ ),
539
+ operation="file.upload",
540
+ )
541
+
542
+ def add_file(
543
+ self,
544
+ workspace_id: str,
545
+ item_id: str,
546
+ *,
547
+ path: Path,
548
+ relation_type: str,
549
+ idempotency_key: str | None,
550
+ ) -> dict[str, Any]:
551
+ return require_json_payload(
552
+ self.multipart_post(
553
+ f"/workspaces/{workspace_id}/knowledge-items/{item_id}/files",
554
+ fields={"relation_type": relation_type},
555
+ file_field="file",
556
+ file_path=path,
557
+ idempotency_key=idempotency_key,
558
+ ),
559
+ operation="file.add",
560
+ )
561
+
562
+ def list_files(self, workspace_id: str, item_id: str) -> list[dict[str, Any]]:
563
+ return self.get(f"/workspaces/{workspace_id}/knowledge-items/{item_id}/files")
564
+
565
+ def download_file(self, workspace_id: str, file_id: str) -> bytes:
566
+ return self.request(
567
+ "GET",
568
+ f"/workspaces/{workspace_id}/files/{file_id}/download",
569
+ parse_json=False,
570
+ )
571
+
572
+ def preview_file(self, workspace_id: str, file_id: str) -> dict[str, Any]:
573
+ return self.get(f"/workspaces/{workspace_id}/files/{file_id}/preview")
574
+
575
+ def read_file(
576
+ self,
577
+ workspace_id: str,
578
+ file_id: str,
579
+ *,
580
+ format_: str,
581
+ range_: str | None,
582
+ source: str,
583
+ max_chars: int | None,
584
+ ) -> dict[str, Any]:
585
+ return self.get(
586
+ f"/workspaces/{workspace_id}/files/{file_id}/read",
587
+ query=compact_query(
588
+ {
589
+ "format": format_,
590
+ "range": range_,
591
+ "source": source,
592
+ "max_chars": max_chars,
593
+ }
594
+ ),
595
+ )
596
+
597
+ def delete_file(self, workspace_id: str, item_id: str, file_id: str) -> None:
598
+ self.delete(
599
+ f"/workspaces/{workspace_id}/knowledge-items/{item_id}/files/{file_id}"
600
+ )
601
+
602
+ def list_pipelines(self, workspace_id: str) -> list[dict[str, Any]]:
603
+ return self.get(f"/workspaces/{workspace_id}/processing-pipelines")
604
+
605
+ def create_pipeline(
606
+ self,
607
+ workspace_id: str,
608
+ *,
609
+ name: str,
610
+ goal: str,
611
+ config: dict[str, Any],
612
+ is_default: bool,
613
+ ) -> dict[str, Any]:
614
+ return self.post(
615
+ f"/workspaces/{workspace_id}/processing-pipelines",
616
+ {
617
+ "name": name,
618
+ "goal": goal,
619
+ "config": config,
620
+ "is_default": is_default,
621
+ },
622
+ )
623
+
624
+ def get_pipeline(self, workspace_id: str, pipeline_id: str) -> dict[str, Any]:
625
+ return self.get(
626
+ f"/workspaces/{workspace_id}/processing-pipelines/{pipeline_id}"
627
+ )
628
+
629
+ def update_pipeline(
630
+ self,
631
+ workspace_id: str,
632
+ pipeline_id: str,
633
+ payload: dict[str, Any],
634
+ ) -> dict[str, Any]:
635
+ return self.patch(
636
+ f"/workspaces/{workspace_id}/processing-pipelines/{pipeline_id}",
637
+ payload,
638
+ )
639
+
640
+ def delete_pipeline(self, workspace_id: str, pipeline_id: str) -> None:
641
+ self.delete(f"/workspaces/{workspace_id}/processing-pipelines/{pipeline_id}")
642
+
643
+ def trigger_processing(
644
+ self,
645
+ workspace_id: str,
646
+ item_id: str,
647
+ *,
648
+ pipeline_id: str | None,
649
+ reason: str,
650
+ idempotency_key: str | None,
651
+ ) -> dict[str, Any]:
652
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
653
+ return self.request(
654
+ "POST",
655
+ f"/workspaces/{workspace_id}/knowledge-items/{item_id}/processing-runs",
656
+ payload={"pipeline_id": pipeline_id, "reason": reason},
657
+ extra_headers=headers,
658
+ )
659
+
660
+ def list_processing_runs(
661
+ self,
662
+ workspace_id: str,
663
+ item_id: str,
664
+ ) -> dict[str, Any]:
665
+ return self.get(
666
+ f"/workspaces/{workspace_id}/knowledge-items/{item_id}/processing-runs"
667
+ )
668
+
669
+ def get_processing_run(self, workspace_id: str, run_id: str) -> dict[str, Any]:
670
+ return self.get(f"/workspaces/{workspace_id}/processing-runs/{run_id}")
671
+
672
+ def retry_processing_run(self, workspace_id: str, run_id: str) -> dict[str, Any]:
673
+ return self.post(
674
+ f"/workspaces/{workspace_id}/processing-runs/{run_id}/retry",
675
+ {},
676
+ )
677
+
678
+ def cancel_processing_run(self, workspace_id: str, run_id: str) -> dict[str, Any]:
679
+ return self.post(
680
+ f"/workspaces/{workspace_id}/processing-runs/{run_id}/cancel",
681
+ {},
682
+ )
683
+
684
+ def reconcile_processing_runs(
685
+ self,
686
+ workspace_id: str,
687
+ *,
688
+ status: list[str] | None,
689
+ ) -> list[dict[str, Any]]:
690
+ return self.post(
691
+ f"/workspaces/{workspace_id}/processing-runs/reconcile",
692
+ {"status": status},
693
+ )
694
+
695
+ def search_grep(
696
+ self,
697
+ workspace_id: str,
698
+ *,
699
+ query: str,
700
+ page: int,
701
+ page_size: int,
702
+ ) -> dict[str, Any]:
703
+ return self.get(
704
+ f"/workspaces/{workspace_id}/search",
705
+ query=compact_query({"query": query, "page": page, "page_size": page_size}),
706
+ )
707
+
708
+ def search_advanced(
709
+ self,
710
+ workspace_id: str,
711
+ *,
712
+ payload: dict[str, Any],
713
+ ) -> dict[str, Any]:
714
+ return self.post(f"/workspaces/{workspace_id}/search/advanced", payload)
715
+
716
+ def search_semantic(
717
+ self,
718
+ workspace_id: str,
719
+ *,
720
+ query: str,
721
+ kb_ids: list[str] | None,
722
+ top_k: int,
723
+ recall: bool = False,
724
+ ) -> list[dict[str, Any]]:
725
+ path = "recall" if recall else "search/semantic"
726
+ return self.post(
727
+ f"/workspaces/{workspace_id}/{path}",
728
+ {"query": query, "kb_ids": kb_ids, "top_k": top_k},
729
+ )
730
+
731
+ def get_chunk(
732
+ self,
733
+ workspace_id: str,
734
+ file_id: str,
735
+ chunk_idx: int,
736
+ ) -> dict[str, Any]:
737
+ return self.get(
738
+ f"/workspaces/{workspace_id}/files/{file_id}/chunks/{chunk_idx}"
739
+ )
740
+
741
+ def get_chunk_context(
742
+ self,
743
+ workspace_id: str,
744
+ file_id: str,
745
+ chunk_idx: int,
746
+ *,
747
+ window: int,
748
+ ) -> dict[str, Any]:
749
+ return self.get(
750
+ f"/workspaces/{workspace_id}/files/{file_id}/chunks/{chunk_idx}/context",
751
+ query=compact_query({"window": window}),
752
+ )
753
+
754
+ def locate_source(
755
+ self,
756
+ workspace_id: str,
757
+ file_id: str,
758
+ chunk_idx: int,
759
+ ) -> dict[str, Any]:
760
+ return self.get(
761
+ f"/workspaces/{workspace_id}/files/{file_id}/chunks/{chunk_idx}/source"
762
+ )
763
+
764
+ def expand_source(
765
+ self,
766
+ workspace_id: str,
767
+ file_id: str,
768
+ chunk_idx: int,
769
+ *,
770
+ mode: str,
771
+ window: int,
772
+ ) -> dict[str, Any]:
773
+ return self.get(
774
+ f"/workspaces/{workspace_id}/files/{file_id}/chunks/"
775
+ f"{chunk_idx}/source/expand",
776
+ query=compact_query({"mode": mode, "window": window}),
777
+ )
778
+
779
+ def read_source_page(
780
+ self,
781
+ workspace_id: str,
782
+ file_id: str,
783
+ page_index: int,
784
+ *,
785
+ include_image: bool,
786
+ max_width: int,
787
+ include_bytes: bool = False,
788
+ ) -> dict[str, Any]:
789
+ return self.get(
790
+ f"/workspaces/{workspace_id}/files/{file_id}/source/pages/{page_index}",
791
+ query=compact_query(
792
+ {
793
+ "include_image": include_image or include_bytes,
794
+ "include_bytes": include_bytes,
795
+ "max_width": max_width,
796
+ }
797
+ ),
798
+ )
799
+
800
+ def read_region_image(
801
+ self,
802
+ workspace_id: str,
803
+ file_id: str,
804
+ *,
805
+ page_index: int,
806
+ bbox: list[float],
807
+ max_width: int,
808
+ include_bytes: bool = False,
809
+ ) -> dict[str, Any]:
810
+ return self.post(
811
+ f"/workspaces/{workspace_id}/files/{file_id}/source/region-image",
812
+ {
813
+ "page_index": page_index,
814
+ "bbox": bbox,
815
+ "coordinate_space": "pdf_points",
816
+ "max_width": max_width,
817
+ "include_bytes": include_bytes,
818
+ },
819
+ )
820
+
821
+ def list_mcp_interfaces(self, workspace_id: str) -> list[dict[str, Any]]:
822
+ return self.get(f"/workspaces/{workspace_id}/mcp-interfaces")
823
+
824
+ def create_mcp_interface(
825
+ self,
826
+ workspace_id: str,
827
+ payload: dict[str, Any],
828
+ ) -> dict[str, Any]:
829
+ return self.post(f"/workspaces/{workspace_id}/mcp-interfaces", payload)
830
+
831
+ def get_mcp_interface(self, workspace_id: str, mcp_id: str) -> dict[str, Any]:
832
+ return self.get(f"/workspaces/{workspace_id}/mcp-interfaces/{mcp_id}")
833
+
834
+ def update_mcp_interface(
835
+ self,
836
+ workspace_id: str,
837
+ mcp_id: str,
838
+ payload: dict[str, Any],
839
+ ) -> dict[str, Any]:
840
+ return self.patch(
841
+ f"/workspaces/{workspace_id}/mcp-interfaces/{mcp_id}",
842
+ payload,
843
+ )
844
+
845
+ def enable_mcp_interface(self, workspace_id: str, mcp_id: str) -> dict[str, Any]:
846
+ return self.post(
847
+ f"/workspaces/{workspace_id}/mcp-interfaces/{mcp_id}/enable",
848
+ {},
849
+ )
850
+
851
+ def disable_mcp_interface(self, workspace_id: str, mcp_id: str) -> dict[str, Any]:
852
+ return self.post(
853
+ f"/workspaces/{workspace_id}/mcp-interfaces/{mcp_id}/disable",
854
+ {},
855
+ )
856
+
857
+ def delete_mcp_interface(self, workspace_id: str, mcp_id: str) -> None:
858
+ self.delete(f"/workspaces/{workspace_id}/mcp-interfaces/{mcp_id}")
859
+
860
+ def list_mcp_api_keys(
861
+ self,
862
+ workspace_id: str,
863
+ mcp_id: str,
864
+ ) -> list[dict[str, Any]]:
865
+ return self.get(f"/workspaces/{workspace_id}/mcp-interfaces/{mcp_id}/api-keys")
866
+
867
+ def create_mcp_api_key(
868
+ self,
869
+ workspace_id: str,
870
+ mcp_id: str,
871
+ *,
872
+ name: str | None,
873
+ ) -> dict[str, Any]:
874
+ return self.post(
875
+ f"/workspaces/{workspace_id}/mcp-interfaces/{mcp_id}/api-keys",
876
+ {"name": name},
877
+ )
878
+
879
+ def revoke_mcp_api_key(
880
+ self,
881
+ workspace_id: str,
882
+ mcp_id: str,
883
+ key_id: str,
884
+ ) -> None:
885
+ self.delete(
886
+ f"/workspaces/{workspace_id}/mcp-interfaces/{mcp_id}/api-keys/{key_id}"
887
+ )
888
+
889
+ def gc_workspace(
890
+ self,
891
+ workspace_id: str,
892
+ *,
893
+ dry_run: bool,
894
+ include_vectors: bool,
895
+ ) -> dict[str, Any]:
896
+ return self.post(
897
+ f"/workspaces/{workspace_id}/gc/workspace",
898
+ {"dry_run": dry_run, "include_vectors": include_vectors},
899
+ )
900
+
901
+ def gc_workspaces_deleted_before(
902
+ self,
903
+ *,
904
+ deleted_before: str,
905
+ dry_run: bool,
906
+ include_vectors: bool,
907
+ ) -> dict[str, Any]:
908
+ return self.post(
909
+ "/gc/workspaces",
910
+ {
911
+ "deleted_before": deleted_before,
912
+ "dry_run": dry_run,
913
+ "include_vectors": include_vectors,
914
+ },
915
+ )
916
+
917
+ def gc_item(
918
+ self,
919
+ workspace_id: str,
920
+ item_id: str,
921
+ *,
922
+ dry_run: bool,
923
+ include_vectors: bool,
924
+ ) -> dict[str, Any]:
925
+ return self.post(
926
+ f"/workspaces/{workspace_id}/gc/items/{item_id}",
927
+ {"dry_run": dry_run, "include_vectors": include_vectors},
928
+ )
929
+
930
+ def gc_mcp_call_logs(
931
+ self,
932
+ *,
933
+ deleted_before: str,
934
+ dry_run: bool,
935
+ workspace_id: str | None = None,
936
+ ) -> dict[str, Any]:
937
+ return self.post(
938
+ "/gc/mcp-call-logs",
939
+ {
940
+ "deleted_before": deleted_before,
941
+ "dry_run": dry_run,
942
+ "workspace_id": workspace_id,
943
+ },
944
+ )
945
+
946
+ def get_vector_index_status(self, workspace_id: str) -> dict[str, Any]:
947
+ payload = self.get(f"/workspaces/{workspace_id}/vector-index")
948
+ status = str(
949
+ payload.get("status") or payload.get("vector_index_status") or "unknown"
950
+ )
951
+ return {
952
+ **payload,
953
+ "workspace_id": payload.get("workspace_id") or workspace_id,
954
+ "vector_index_status": status,
955
+ "needs_rebuild": status == "needs_rebuild",
956
+ "vector_index_error": payload.get("error")
957
+ or payload.get("vector_index_error"),
958
+ "vector_index_rebuild_requested_at": payload.get("rebuild_requested_at")
959
+ or payload.get(
960
+ "vector_index_rebuild_requested_at",
961
+ ),
962
+ }
963
+
964
+ def rebuild_vector_index(
965
+ self,
966
+ workspace_id: str,
967
+ *,
968
+ dry_run: bool,
969
+ ) -> dict[str, Any]:
970
+ return self.post(
971
+ f"/workspaces/{workspace_id}/vector-index/rebuild",
972
+ {"dry_run": dry_run},
973
+ )
974
+
975
+ def revoke(self, refresh_token: str) -> None:
976
+ self.post(
977
+ "/auth/revoke",
978
+ {"refresh_token": refresh_token},
979
+ authenticated=False,
980
+ )
981
+
982
+ def refresh(self, refresh_token: str) -> dict[str, Any]:
983
+ return self.post(
984
+ "/auth/refresh",
985
+ {"refresh_token": refresh_token},
986
+ authenticated=False,
987
+ )
988
+
989
+ def get(
990
+ self,
991
+ path: str,
992
+ query: dict[str, Any] | None = None,
993
+ *,
994
+ authenticated: bool = True,
995
+ ) -> Any:
996
+ return self.request("GET", path, query=query, authenticated=authenticated)
997
+
998
+ def post(
999
+ self,
1000
+ path: str,
1001
+ payload: dict[str, Any],
1002
+ *,
1003
+ authenticated: bool = True,
1004
+ ) -> Any:
1005
+ return self.request("POST", path, payload=payload, authenticated=authenticated)
1006
+
1007
+ def patch(self, path: str, payload: dict[str, Any]) -> Any:
1008
+ return self.request("PATCH", path, payload=payload)
1009
+
1010
+ def put(self, path: str, payload: dict[str, Any]) -> Any:
1011
+ return self.request("PUT", path, payload=payload)
1012
+
1013
+ def delete(self, path: str) -> Any:
1014
+ return self.request("DELETE", path)
1015
+
1016
+ def delete_with_payload(self, path: str, payload: dict[str, Any]) -> Any:
1017
+ return self.request("DELETE", path, payload=payload)
1018
+
1019
+ def request(
1020
+ self,
1021
+ method: str,
1022
+ path: str,
1023
+ *,
1024
+ payload: dict[str, Any] | None = None,
1025
+ query: dict[str, Any] | None = None,
1026
+ authenticated: bool = True,
1027
+ retry_on_unauthorized: bool = True,
1028
+ raw_body: bytes | MultipartBody | None = None,
1029
+ content_type: str | None = None,
1030
+ extra_headers: dict[str, str] | None = None,
1031
+ parse_json: bool = True,
1032
+ ) -> Any:
1033
+ url = f"{self.base_url}{path}"
1034
+ if query:
1035
+ url = f"{url}?{urlencode(query)}"
1036
+
1037
+ headers = {
1038
+ "Accept": "application/json",
1039
+ "X-Request-ID": secrets.token_hex(16),
1040
+ }
1041
+ body = None
1042
+ if payload is not None:
1043
+ body = json.dumps(payload).encode("utf-8")
1044
+ headers["Content-Type"] = "application/json"
1045
+ if raw_body is not None:
1046
+ body = raw_body
1047
+ if content_type is not None:
1048
+ headers["Content-Type"] = content_type
1049
+ if isinstance(raw_body, MultipartBody):
1050
+ headers["Content-Length"] = str(raw_body.content_length)
1051
+ if extra_headers:
1052
+ headers.update(extra_headers)
1053
+ if authenticated:
1054
+ if not self.access_token:
1055
+ raise CliAPIError(
1056
+ code="auth.unauthorized",
1057
+ message="Not logged in. Run `cortex auth login`.",
1058
+ status_code=401,
1059
+ )
1060
+ headers["Authorization"] = f"Bearer {self.access_token}"
1061
+
1062
+ request = Request(url, data=body, headers=headers, method=method)
1063
+ try:
1064
+ with urlopen(request, timeout=request_timeout_seconds()) as response:
1065
+ content = response.read()
1066
+ if not parse_json:
1067
+ return content
1068
+ if not content:
1069
+ return None
1070
+ return json.loads(content.decode("utf-8"))
1071
+ except HTTPError as exc:
1072
+ error = parse_http_error(exc)
1073
+ if (
1074
+ authenticated
1075
+ and retry_on_unauthorized
1076
+ and error.status_code == 401
1077
+ and self.refresh_token
1078
+ ):
1079
+ self.refresh_access_token()
1080
+ return self.request(
1081
+ method,
1082
+ path,
1083
+ payload=payload,
1084
+ query=query,
1085
+ authenticated=authenticated,
1086
+ retry_on_unauthorized=False,
1087
+ raw_body=raw_body,
1088
+ content_type=content_type,
1089
+ extra_headers=extra_headers,
1090
+ parse_json=parse_json,
1091
+ )
1092
+ raise error from exc
1093
+ except URLError as exc:
1094
+ raise CliAPIError(
1095
+ code="network.error",
1096
+ message=str(exc.reason),
1097
+ status_code=0,
1098
+ ) from exc
1099
+
1100
+ def multipart_post(
1101
+ self,
1102
+ endpoint_path: str,
1103
+ *,
1104
+ fields: dict[str, Any],
1105
+ file_field: str,
1106
+ file_path: Path,
1107
+ idempotency_key: str | None,
1108
+ ) -> Any:
1109
+ body, content_type = encode_multipart(
1110
+ fields=fields,
1111
+ file_field=file_field,
1112
+ path=file_path,
1113
+ )
1114
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
1115
+ return self.request(
1116
+ "POST",
1117
+ endpoint_path,
1118
+ raw_body=body,
1119
+ content_type=content_type,
1120
+ extra_headers=headers,
1121
+ )
1122
+
1123
+ def refresh_access_token(self) -> None:
1124
+ if not self.refresh_token:
1125
+ raise CliAPIError(
1126
+ code="auth.unauthorized",
1127
+ message="Not logged in. Run `cortex auth login`.",
1128
+ status_code=401,
1129
+ )
1130
+ payload = self.refresh(self.refresh_token)
1131
+ self.access_token = payload["access_token"]
1132
+ self.refresh_token = payload["refresh_token"]
1133
+ if self.token_updater is not None:
1134
+ self.token_updater(payload)
1135
+
1136
+
1137
+ def parse_http_error(exc: HTTPError) -> CliAPIError:
1138
+ try:
1139
+ payload = json.loads(exc.read().decode("utf-8"))
1140
+ error = payload.get("error", {})
1141
+ except (json.JSONDecodeError, UnicodeDecodeError):
1142
+ error = {}
1143
+
1144
+ return CliAPIError(
1145
+ code=str(error.get("code") or "http.error"),
1146
+ message=str(error.get("message") or exc.reason),
1147
+ status_code=exc.code,
1148
+ details=error.get("details") or {},
1149
+ )
1150
+
1151
+
1152
+ def request_timeout_seconds() -> int:
1153
+ raw = os.environ.get("CORTEX_CLI_REQUEST_TIMEOUT_SECONDS")
1154
+ if raw is None:
1155
+ return DEFAULT_REQUEST_TIMEOUT_SECONDS
1156
+ try:
1157
+ value = int(raw)
1158
+ except ValueError:
1159
+ return DEFAULT_REQUEST_TIMEOUT_SECONDS
1160
+ if value <= 0:
1161
+ return DEFAULT_REQUEST_TIMEOUT_SECONDS
1162
+ return value
1163
+
1164
+
1165
+ def compact_query(query: dict[str, Any]) -> dict[str, Any]:
1166
+ return {key: value for key, value in query.items() if value is not None}
1167
+
1168
+
1169
+ def invite_link_token_from_value(value: str) -> str:
1170
+ raw = value.strip()
1171
+ parsed = urlparse(raw)
1172
+ if parsed.scheme and parsed.netloc:
1173
+ path_token = parsed.path.rstrip("/").rsplit("/", 1)[-1]
1174
+ return path_token or raw
1175
+ return raw
1176
+
1177
+
1178
+ def require_json_payload(payload: Any, *, operation: str) -> dict[str, Any]:
1179
+ if isinstance(payload, dict):
1180
+ return payload
1181
+ raise CliAPIError(
1182
+ code="api.empty_response",
1183
+ message=f"{operation} expected a JSON response body.",
1184
+ status_code=502,
1185
+ )
1186
+
1187
+
1188
+ def encode_multipart(
1189
+ *,
1190
+ fields: dict[str, Any],
1191
+ file_field: str,
1192
+ path: Path,
1193
+ ) -> tuple[MultipartBody, str]:
1194
+ boundary = f"----cortex-{secrets.token_hex(16)}"
1195
+ chunks: list[bytes] = []
1196
+ for name, value in fields.items():
1197
+ chunks.extend(
1198
+ [
1199
+ f"--{boundary}\r\n".encode(),
1200
+ f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(),
1201
+ str(value).encode(),
1202
+ b"\r\n",
1203
+ ]
1204
+ )
1205
+ filename = path.name
1206
+ mime_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
1207
+ chunks.extend(
1208
+ [
1209
+ f"--{boundary}\r\n".encode(),
1210
+ (
1211
+ f'Content-Disposition: form-data; name="{file_field}"; '
1212
+ f'filename="{filename}"\r\n'
1213
+ ).encode(),
1214
+ f"Content-Type: {mime_type}\r\n\r\n".encode(),
1215
+ ]
1216
+ )
1217
+ body = MultipartBody(
1218
+ prefix=b"".join(chunks),
1219
+ path=path,
1220
+ suffix=b"\r\n" + f"--{boundary}--\r\n".encode(),
1221
+ )
1222
+ return body, f"multipart/form-data; boundary={boundary}"