veadk-python 1.0.2__py3-none-any.whl → 1.0.3__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.
- veadk/a2a/registry_client.py +133 -12
- veadk/cloud/harness_app/app.py +36 -9
- veadk/cloud/harness_app/utils.py +27 -0
- veadk/config.py +2 -0
- veadk/configs/database_configs.py +31 -0
- veadk/knowledgebase/backends/milvus_backend.py +201 -0
- veadk/knowledgebase/backends/openviking_backend.py +295 -0
- veadk/knowledgebase/knowledgebase.py +32 -6
- veadk/memory/long_term_memory.py +211 -35
- veadk/memory/long_term_memory_backends/openviking_backend.py +322 -0
- veadk/runner.py +1 -1
- veadk/tools/builtin_tools/load_knowledgebase.py +2 -1
- veadk/tools/load_knowledgebase_tool.py +2 -1
- {veadk_python-1.0.2.dist-info → veadk_python-1.0.3.dist-info}/METADATA +4 -2
- {veadk_python-1.0.2.dist-info → veadk_python-1.0.3.dist-info}/RECORD +21 -18
- {veadk_python-1.0.2.dist-info → veadk_python-1.0.3.dist-info}/scm_file_list.json +7 -0
- veadk_python-1.0.3.dist-info/scm_version.json +8 -0
- veadk_python-1.0.2.dist-info/scm_version.json +0 -8
- {veadk_python-1.0.2.dist-info → veadk_python-1.0.3.dist-info}/WHEEL +0 -0
- {veadk_python-1.0.2.dist-info → veadk_python-1.0.3.dist-info}/entry_points.txt +0 -0
- {veadk_python-1.0.2.dist-info → veadk_python-1.0.3.dist-info}/licenses/LICENSE +0 -0
- {veadk_python-1.0.2.dist-info → veadk_python-1.0.3.dist-info}/top_level.txt +0 -0
veadk/a2a/registry_client.py
CHANGED
|
@@ -22,6 +22,7 @@ import os
|
|
|
22
22
|
import re
|
|
23
23
|
import time
|
|
24
24
|
import uuid
|
|
25
|
+
from collections.abc import Mapping
|
|
25
26
|
from dataclasses import dataclass
|
|
26
27
|
from datetime import datetime, timezone
|
|
27
28
|
from typing import Any
|
|
@@ -30,6 +31,7 @@ from urllib.parse import quote, urlparse, urlunparse
|
|
|
30
31
|
import requests
|
|
31
32
|
|
|
32
33
|
from veadk.auth.veauth.utils import get_credential_from_vefaas_iam
|
|
34
|
+
from veadk.utils.auth import VE_TIP_TOKEN_HEADER
|
|
33
35
|
|
|
34
36
|
DEFAULT_ENDPOINT = "http://volcengineapi.byted.org/"
|
|
35
37
|
DEFAULT_VERSION = "2025-10-30"
|
|
@@ -66,6 +68,8 @@ class AgentKitA2ARegistryConfig:
|
|
|
66
68
|
top_k: int = DEFAULT_TOP_K
|
|
67
69
|
timeout_ms: int = DEFAULT_TIMEOUT_MS
|
|
68
70
|
poll_interval_ms: int = DEFAULT_POLL_INTERVAL_MS
|
|
71
|
+
upstream_tip_token: str = ""
|
|
72
|
+
upstream_authorization: str = ""
|
|
69
73
|
|
|
70
74
|
|
|
71
75
|
@dataclass(frozen=True)
|
|
@@ -97,9 +101,41 @@ def registry_config_from_env() -> AgentKitA2ARegistryConfig:
|
|
|
97
101
|
poll_interval_ms=_int_env(
|
|
98
102
|
"REGISTRY_POLL_INTERVAL_MS", DEFAULT_POLL_INTERVAL_MS, minimum=100
|
|
99
103
|
),
|
|
104
|
+
upstream_tip_token=_first_env(
|
|
105
|
+
[
|
|
106
|
+
"REGISTRY_UPSTREAM_TIP_TOKEN",
|
|
107
|
+
"AGENTKIT_UPSTREAM_TIP_TOKEN",
|
|
108
|
+
"A2A_REGISTRY_UPSTREAM_TIP_TOKEN",
|
|
109
|
+
"VE_TIP_TOKEN",
|
|
110
|
+
"X_VE_TIP_TOKEN",
|
|
111
|
+
"TIP_TOKEN",
|
|
112
|
+
]
|
|
113
|
+
),
|
|
100
114
|
)
|
|
101
115
|
|
|
102
116
|
|
|
117
|
+
def registry_tip_token_from_headers(headers: Mapping[str, str]) -> str:
|
|
118
|
+
"""Extract an inbound TIP token from HTTP headers."""
|
|
119
|
+
|
|
120
|
+
normalized = {str(key).lower(): str(value) for key, value in headers.items()}
|
|
121
|
+
return normalized.get(VE_TIP_TOKEN_HEADER.lower(), "").strip()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def registry_authorization_from_headers(headers: Mapping[str, str]) -> str:
|
|
125
|
+
"""Extract a bearer Authorization header for downstream A2A calls."""
|
|
126
|
+
|
|
127
|
+
normalized = {str(key).lower(): str(value) for key, value in headers.items()}
|
|
128
|
+
for header_name in (
|
|
129
|
+
"authorization",
|
|
130
|
+
"x-forwarded-authorization",
|
|
131
|
+
"x-original-authorization",
|
|
132
|
+
):
|
|
133
|
+
value = _normalize_bearer_authorization(normalized.get(header_name, ""))
|
|
134
|
+
if value:
|
|
135
|
+
return value
|
|
136
|
+
return ""
|
|
137
|
+
|
|
138
|
+
|
|
103
139
|
def search_agent_cards(
|
|
104
140
|
prompt: str,
|
|
105
141
|
top_k: int | None = None,
|
|
@@ -250,6 +286,12 @@ def _resolve_config(
|
|
|
250
286
|
or DEFAULT_POLL_INTERVAL_MS
|
|
251
287
|
),
|
|
252
288
|
),
|
|
289
|
+
upstream_tip_token=(
|
|
290
|
+
config.upstream_tip_token or env_config.upstream_tip_token
|
|
291
|
+
).strip(),
|
|
292
|
+
upstream_authorization=_normalize_bearer_authorization(
|
|
293
|
+
config.upstream_authorization or env_config.upstream_authorization
|
|
294
|
+
),
|
|
253
295
|
)
|
|
254
296
|
|
|
255
297
|
|
|
@@ -534,15 +576,25 @@ def _send_message(
|
|
|
534
576
|
if task_id:
|
|
535
577
|
message["taskId"] = task_id
|
|
536
578
|
|
|
579
|
+
headers: dict[str, str] = {}
|
|
537
580
|
try:
|
|
581
|
+
headers = _agent_auth_headers(card, config)
|
|
538
582
|
return _a2a_jsonrpc(
|
|
539
583
|
card["url"],
|
|
540
584
|
"message/send",
|
|
541
585
|
{"message": message, "configuration": {"blocking": False}},
|
|
542
|
-
|
|
586
|
+
headers,
|
|
543
587
|
config,
|
|
544
588
|
)
|
|
545
589
|
except RegistryError as exc:
|
|
590
|
+
if _should_retry_with_m2m(exc, card, config, headers):
|
|
591
|
+
return _a2a_jsonrpc(
|
|
592
|
+
card["url"],
|
|
593
|
+
"message/send",
|
|
594
|
+
{"message": message, "configuration": {"blocking": False}},
|
|
595
|
+
_agent_auth_headers(card, config, force_m2m=True),
|
|
596
|
+
config,
|
|
597
|
+
)
|
|
546
598
|
if exc.code in {
|
|
547
599
|
"A2A_HTTP_FAILED",
|
|
548
600
|
"A2A_RESPONSE_PARSE_FAILED",
|
|
@@ -563,13 +615,25 @@ def _poll_card(
|
|
|
563
615
|
started: float | None = None,
|
|
564
616
|
) -> dict[str, Any]:
|
|
565
617
|
started = started or time.monotonic()
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
618
|
+
headers = _agent_auth_headers(card, config)
|
|
619
|
+
try:
|
|
620
|
+
a2a_result = _a2a_jsonrpc(
|
|
621
|
+
card["url"],
|
|
622
|
+
"tasks/get",
|
|
623
|
+
{"id": task_id.strip(), "historyLength": max(0, int(history_length))},
|
|
624
|
+
headers,
|
|
625
|
+
config,
|
|
626
|
+
)
|
|
627
|
+
except RegistryError as exc:
|
|
628
|
+
if not _should_retry_with_m2m(exc, card, config, headers):
|
|
629
|
+
raise
|
|
630
|
+
a2a_result = _a2a_jsonrpc(
|
|
631
|
+
card["url"],
|
|
632
|
+
"tasks/get",
|
|
633
|
+
{"id": task_id.strip(), "historyLength": max(0, int(history_length))},
|
|
634
|
+
_agent_auth_headers(card, config, force_m2m=True),
|
|
635
|
+
config,
|
|
636
|
+
)
|
|
573
637
|
state = _task_state(a2a_result)
|
|
574
638
|
is_terminal = state in TERMINAL_STATES
|
|
575
639
|
payload: dict[str, Any] = {
|
|
@@ -815,11 +879,18 @@ def _sanitize_get_agent_result(
|
|
|
815
879
|
|
|
816
880
|
|
|
817
881
|
def _agent_auth_headers(
|
|
818
|
-
card: dict[str, Any],
|
|
882
|
+
card: dict[str, Any],
|
|
883
|
+
config: AgentKitA2ARegistryConfig | None = None,
|
|
884
|
+
*,
|
|
885
|
+
force_m2m: bool = False,
|
|
819
886
|
) -> dict[str, str]:
|
|
887
|
+
resolved_config = _resolve_config(config)
|
|
820
888
|
security = card.get("security") or []
|
|
821
889
|
schemes = card.get("securitySchemes") or {}
|
|
822
890
|
headers: dict[str, str] = {}
|
|
891
|
+
upstream_authorization = _normalize_bearer_authorization(
|
|
892
|
+
resolved_config.upstream_authorization
|
|
893
|
+
)
|
|
823
894
|
|
|
824
895
|
for requirement in security:
|
|
825
896
|
if not isinstance(requirement, dict):
|
|
@@ -837,9 +908,17 @@ def _agent_auth_headers(
|
|
|
837
908
|
if isinstance(token, str) and token:
|
|
838
909
|
headers[header_name] = token
|
|
839
910
|
elif scheme_type == "oauth2":
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
911
|
+
if upstream_authorization and not force_m2m:
|
|
912
|
+
headers["Authorization"] = upstream_authorization
|
|
913
|
+
else:
|
|
914
|
+
headers["Authorization"] = (
|
|
915
|
+
"Bearer "
|
|
916
|
+
+ _oauth2_client_credentials_token(scheme, resolved_config)
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
tip_token = resolved_config.upstream_tip_token
|
|
920
|
+
if tip_token:
|
|
921
|
+
headers[VE_TIP_TOKEN_HEADER] = tip_token
|
|
843
922
|
|
|
844
923
|
if security and not headers:
|
|
845
924
|
raise RegistryError(
|
|
@@ -849,6 +928,38 @@ def _agent_auth_headers(
|
|
|
849
928
|
return headers
|
|
850
929
|
|
|
851
930
|
|
|
931
|
+
def _should_retry_with_m2m(
|
|
932
|
+
exc: RegistryError,
|
|
933
|
+
card: dict[str, Any],
|
|
934
|
+
config: AgentKitA2ARegistryConfig | None,
|
|
935
|
+
headers: dict[str, str],
|
|
936
|
+
) -> bool:
|
|
937
|
+
if exc.code != "A2A_HTTP_FAILED" or exc.diagnostics.get("status_code") != 401:
|
|
938
|
+
return False
|
|
939
|
+
|
|
940
|
+
resolved_config = _resolve_config(config)
|
|
941
|
+
upstream_authorization = _normalize_bearer_authorization(
|
|
942
|
+
resolved_config.upstream_authorization
|
|
943
|
+
)
|
|
944
|
+
if not upstream_authorization:
|
|
945
|
+
return False
|
|
946
|
+
if headers.get("Authorization") != upstream_authorization:
|
|
947
|
+
return False
|
|
948
|
+
return _has_oauth2_security(card)
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _has_oauth2_security(card: dict[str, Any]) -> bool:
|
|
952
|
+
schemes = card.get("securitySchemes") or {}
|
|
953
|
+
for requirement in card.get("security") or []:
|
|
954
|
+
if not isinstance(requirement, dict):
|
|
955
|
+
continue
|
|
956
|
+
for scheme_name in requirement:
|
|
957
|
+
scheme = schemes.get(scheme_name) or {}
|
|
958
|
+
if str(scheme.get("type") or "").lower() == "oauth2":
|
|
959
|
+
return True
|
|
960
|
+
return False
|
|
961
|
+
|
|
962
|
+
|
|
852
963
|
def _oauth2_client_credentials_token(
|
|
853
964
|
scheme: dict[str, Any], config: AgentKitA2ARegistryConfig
|
|
854
965
|
) -> str:
|
|
@@ -957,6 +1068,16 @@ def _clean_config_url(value: Any) -> str:
|
|
|
957
1068
|
return cleaned
|
|
958
1069
|
|
|
959
1070
|
|
|
1071
|
+
def _normalize_bearer_authorization(value: Any) -> str:
|
|
1072
|
+
cleaned = _clean_config_url(value)
|
|
1073
|
+
if not cleaned:
|
|
1074
|
+
return ""
|
|
1075
|
+
parts = cleaned.split(None, 1)
|
|
1076
|
+
if len(parts) == 2 and parts[0].lower() == "bearer" and parts[1].strip():
|
|
1077
|
+
return f"Bearer {parts[1].strip()}"
|
|
1078
|
+
return ""
|
|
1079
|
+
|
|
1080
|
+
|
|
960
1081
|
def _user_pool_id_from_token_url(token_url: str) -> str:
|
|
961
1082
|
host = urlparse(token_url).netloc
|
|
962
1083
|
match = re.match(r"userpool-([^.]+)\.userpool\.auth\.id\.", host)
|
veadk/cloud/harness_app/app.py
CHANGED
|
@@ -62,6 +62,10 @@ from google.adk.utils.context_utils import Aclosing
|
|
|
62
62
|
from typing_extensions import override
|
|
63
63
|
|
|
64
64
|
from veadk import Agent
|
|
65
|
+
from veadk.a2a.registry_client import (
|
|
66
|
+
registry_authorization_from_headers,
|
|
67
|
+
registry_tip_token_from_headers,
|
|
68
|
+
)
|
|
65
69
|
from veadk.a2a.utils.agent_to_a2a import to_a2a
|
|
66
70
|
from veadk.cloud.harness_app.agent import agent, short_term_memory
|
|
67
71
|
from veadk.cloud.harness_app.harness_plugins import (
|
|
@@ -229,6 +233,8 @@ class HarnessApp:
|
|
|
229
233
|
)
|
|
230
234
|
|
|
231
235
|
try:
|
|
236
|
+
tip_token = registry_tip_token_from_headers(http_request.headers)
|
|
237
|
+
auth_header = registry_authorization_from_headers(http_request.headers)
|
|
232
238
|
header_plugins = build_harness_plugins_from_headers(
|
|
233
239
|
http_request.headers
|
|
234
240
|
)
|
|
@@ -269,6 +275,8 @@ class HarnessApp:
|
|
|
269
275
|
request.prompt,
|
|
270
276
|
request.harness,
|
|
271
277
|
download_dir=Path(work_dir),
|
|
278
|
+
registry_tip_token=tip_token,
|
|
279
|
+
registry_authorization=auth_header,
|
|
272
280
|
)
|
|
273
281
|
runner = Runner(
|
|
274
282
|
agent=agent,
|
|
@@ -283,11 +291,15 @@ class HarnessApp:
|
|
|
283
291
|
run_config=run_config,
|
|
284
292
|
)
|
|
285
293
|
elif needs_scoped_runner:
|
|
286
|
-
|
|
287
|
-
spawn_harness_run_agent(
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
294
|
+
if has_registry:
|
|
295
|
+
run_agent = spawn_harness_run_agent(
|
|
296
|
+
self.agent,
|
|
297
|
+
request.prompt,
|
|
298
|
+
registry_tip_token=tip_token,
|
|
299
|
+
registry_authorization=auth_header,
|
|
300
|
+
)
|
|
301
|
+
else:
|
|
302
|
+
run_agent = self.agent
|
|
291
303
|
runner = Runner(
|
|
292
304
|
agent=run_agent,
|
|
293
305
|
short_term_memory=self.short_term_memory,
|
|
@@ -359,7 +371,7 @@ class HarnessApp:
|
|
|
359
371
|
break
|
|
360
372
|
|
|
361
373
|
@self.app.post("/run_sse")
|
|
362
|
-
async def run_sse(req: HarnessRunAgentRequest):
|
|
374
|
+
async def run_sse(req: HarnessRunAgentRequest, http_request: Request):
|
|
363
375
|
if (
|
|
364
376
|
req.harness is None
|
|
365
377
|
and not has_a2a_registry_config(self.agent)
|
|
@@ -367,8 +379,11 @@ class HarnessApp:
|
|
|
367
379
|
):
|
|
368
380
|
# No override -> exactly ADK's default /run_sse.
|
|
369
381
|
return await adk_run_sse(req)
|
|
382
|
+
tip_token = registry_tip_token_from_headers(http_request.headers)
|
|
383
|
+
auth_header = registry_authorization_from_headers(http_request.headers)
|
|
370
384
|
return StreamingResponse(
|
|
371
|
-
self._run_sse_events(req),
|
|
385
|
+
self._run_sse_events(req, tip_token, auth_header),
|
|
386
|
+
media_type="text/event-stream",
|
|
372
387
|
)
|
|
373
388
|
|
|
374
389
|
# Move ours to the front so it wins (Starlette matches the first route),
|
|
@@ -381,7 +396,12 @@ class HarnessApp:
|
|
|
381
396
|
routes.insert(0, routes.pop(i))
|
|
382
397
|
break
|
|
383
398
|
|
|
384
|
-
async def _run_sse_events(
|
|
399
|
+
async def _run_sse_events(
|
|
400
|
+
self,
|
|
401
|
+
req: "HarnessRunAgentRequest",
|
|
402
|
+
tip_token: str = "",
|
|
403
|
+
auth_header: str = "",
|
|
404
|
+
):
|
|
385
405
|
"""Yield SSE ``data:`` lines for a run, spawning the agent on override."""
|
|
386
406
|
run_config = RunConfig(
|
|
387
407
|
streaming_mode=StreamingMode.SSE if req.streaming else StreamingMode.NONE
|
|
@@ -400,13 +420,20 @@ class HarnessApp:
|
|
|
400
420
|
prompt,
|
|
401
421
|
req.harness,
|
|
402
422
|
download_dir=Path(work_dir_ctx.name),
|
|
423
|
+
registry_tip_token=tip_token,
|
|
424
|
+
registry_authorization=auth_header,
|
|
403
425
|
)
|
|
404
426
|
except (SkillLoadError, ToolLoadError) as e:
|
|
405
427
|
logger.error(f"Once-time override failed to load: {e}")
|
|
406
428
|
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
|
407
429
|
return
|
|
408
430
|
elif has_a2a_registry_config(self.agent):
|
|
409
|
-
agent = spawn_harness_run_agent(
|
|
431
|
+
agent = spawn_harness_run_agent(
|
|
432
|
+
self.agent,
|
|
433
|
+
prompt,
|
|
434
|
+
registry_tip_token=tip_token,
|
|
435
|
+
registry_authorization=auth_header,
|
|
436
|
+
)
|
|
410
437
|
else:
|
|
411
438
|
agent = self.agent
|
|
412
439
|
|
veadk/cloud/harness_app/utils.py
CHANGED
|
@@ -527,6 +527,30 @@ def _apply_registry_overrides(
|
|
|
527
527
|
setattr(agent, _REGISTRY_CONFIG_ATTR, overridden_config)
|
|
528
528
|
|
|
529
529
|
|
|
530
|
+
def _apply_registry_request_auth(
|
|
531
|
+
agent: Agent, tip_token: str = "", authorization: str = ""
|
|
532
|
+
) -> None:
|
|
533
|
+
cleaned_tip_token = (tip_token or "").strip()
|
|
534
|
+
cleaned_authorization = (authorization or "").strip()
|
|
535
|
+
if not cleaned_tip_token and not cleaned_authorization:
|
|
536
|
+
return
|
|
537
|
+
|
|
538
|
+
from veadk.tools.builtin_tools.a2a_registry import build_a2a_registry_tools
|
|
539
|
+
|
|
540
|
+
config = getattr(agent, _REGISTRY_CONFIG_ATTR, None)
|
|
541
|
+
if config is None:
|
|
542
|
+
return
|
|
543
|
+
|
|
544
|
+
updated_config = replace(
|
|
545
|
+
config,
|
|
546
|
+
upstream_tip_token=cleaned_tip_token or config.upstream_tip_token,
|
|
547
|
+
upstream_authorization=cleaned_authorization or config.upstream_authorization,
|
|
548
|
+
)
|
|
549
|
+
_remove_a2a_registry_tools(agent)
|
|
550
|
+
agent.tools.extend(build_a2a_registry_tools(updated_config))
|
|
551
|
+
setattr(agent, _REGISTRY_CONFIG_ATTR, updated_config)
|
|
552
|
+
|
|
553
|
+
|
|
530
554
|
def has_a2a_registry_config(agent: Agent) -> bool:
|
|
531
555
|
"""Return whether ``agent`` has an AgentKit A2A registry configured."""
|
|
532
556
|
|
|
@@ -604,6 +628,8 @@ def spawn_harness_run_agent(
|
|
|
604
628
|
prompt: str,
|
|
605
629
|
overrides: HarnessOverrides | None = None,
|
|
606
630
|
download_dir: Path | None = None,
|
|
631
|
+
registry_tip_token: str = "",
|
|
632
|
+
registry_authorization: str = "",
|
|
607
633
|
) -> Agent:
|
|
608
634
|
"""Clone a harness agent for one run and attach per-turn dynamic tools."""
|
|
609
635
|
|
|
@@ -612,5 +638,6 @@ def spawn_harness_run_agent(
|
|
|
612
638
|
else:
|
|
613
639
|
cloned = base_agent.clone(update={})
|
|
614
640
|
|
|
641
|
+
_apply_registry_request_auth(cloned, registry_tip_token, registry_authorization)
|
|
615
642
|
_add_dynamic_a2a_agent_tools(cloned, prompt)
|
|
616
643
|
return cloned
|
veadk/config.py
CHANGED
|
@@ -21,6 +21,7 @@ from pydantic import BaseModel, Field
|
|
|
21
21
|
from veadk.configs.auth_configs import VeIdentityConfig
|
|
22
22
|
from veadk.configs.model_configs import RealtimeModelConfig
|
|
23
23
|
from veadk.configs.database_configs import (
|
|
24
|
+
MilvusConfig,
|
|
24
25
|
MysqlConfig,
|
|
25
26
|
OpensearchConfig,
|
|
26
27
|
RedisConfig,
|
|
@@ -79,6 +80,7 @@ class VeADKConfig(BaseModel):
|
|
|
79
80
|
opensearch: OpensearchConfig = Field(default_factory=OpensearchConfig)
|
|
80
81
|
mysql: MysqlConfig = Field(default_factory=MysqlConfig)
|
|
81
82
|
redis: RedisConfig = Field(default_factory=RedisConfig)
|
|
83
|
+
milvus: MilvusConfig = Field(default_factory=MilvusConfig)
|
|
82
84
|
viking_knowledgebase: VikingKnowledgebaseConfig = Field(
|
|
83
85
|
default_factory=VikingKnowledgebaseConfig
|
|
84
86
|
)
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import os
|
|
16
16
|
from functools import cached_property
|
|
17
17
|
|
|
18
|
+
from pydantic import Field
|
|
18
19
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
19
20
|
|
|
20
21
|
from veadk.consts import DEFAULT_TOS_BUCKET_NAME
|
|
@@ -95,6 +96,26 @@ class RedisConfig(BaseSettings):
|
|
|
95
96
|
"""STS token for Redis auth, not supported yet."""
|
|
96
97
|
|
|
97
98
|
|
|
99
|
+
class MilvusConfig(BaseSettings):
|
|
100
|
+
model_config = SettingsConfigDict(env_prefix="DATABASE_MILVUS_")
|
|
101
|
+
|
|
102
|
+
uri: str = ""
|
|
103
|
+
|
|
104
|
+
token: str = ""
|
|
105
|
+
|
|
106
|
+
user: str = ""
|
|
107
|
+
|
|
108
|
+
password: str = ""
|
|
109
|
+
|
|
110
|
+
db_name: str = "default"
|
|
111
|
+
|
|
112
|
+
overwrite: bool = False
|
|
113
|
+
|
|
114
|
+
timeout: float | None = None
|
|
115
|
+
|
|
116
|
+
output_fields: list[str] | str = Field(default_factory=list)
|
|
117
|
+
|
|
118
|
+
|
|
98
119
|
class Mem0Config(BaseSettings):
|
|
99
120
|
model_config = SettingsConfigDict(env_prefix="DATABASE_MEM0_")
|
|
100
121
|
|
|
@@ -108,6 +129,16 @@ class Mem0Config(BaseSettings):
|
|
|
108
129
|
base_url: str = "" # "https://api.mem0.ai/v1"
|
|
109
130
|
|
|
110
131
|
|
|
132
|
+
class OpenVikingConfig(BaseSettings):
|
|
133
|
+
model_config = SettingsConfigDict(env_prefix="DATABASE_OPENVIKING_")
|
|
134
|
+
|
|
135
|
+
url: str = ""
|
|
136
|
+
"""OpenViking server base URL"""
|
|
137
|
+
|
|
138
|
+
api_key: str = ""
|
|
139
|
+
"""OpenViking service owner user key"""
|
|
140
|
+
|
|
141
|
+
|
|
111
142
|
class VikingKnowledgebaseConfig(BaseSettings):
|
|
112
143
|
model_config = SettingsConfigDict(env_prefix="DATABASE_VIKING_")
|
|
113
144
|
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from pydantic import Field
|
|
20
|
+
from typing_extensions import override
|
|
21
|
+
|
|
22
|
+
import veadk.config # noqa E401
|
|
23
|
+
from veadk.configs.database_configs import MilvusConfig
|
|
24
|
+
from veadk.configs.model_configs import EmbeddingModelConfig, NormalEmbeddingModelConfig
|
|
25
|
+
from veadk.knowledgebase.backends.base_backend import BaseKnowledgebaseBackend
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MilvusKnowledgeBackend(BaseKnowledgebaseBackend):
|
|
29
|
+
"""Milvus-based backend for knowledgebase.
|
|
30
|
+
|
|
31
|
+
Milvus backend stores embedded chunks in a Milvus collection through
|
|
32
|
+
LlamaIndex's Milvus vector store. ``index`` maps directly to the Milvus
|
|
33
|
+
collection name.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
milvus_config: MilvusConfig = Field(default_factory=MilvusConfig)
|
|
37
|
+
"""Milvus connection config."""
|
|
38
|
+
|
|
39
|
+
embedding_config: EmbeddingModelConfig | NormalEmbeddingModelConfig = Field(
|
|
40
|
+
default_factory=EmbeddingModelConfig
|
|
41
|
+
)
|
|
42
|
+
"""Embedding model configs."""
|
|
43
|
+
|
|
44
|
+
def model_post_init(self, __context: Any) -> None:
|
|
45
|
+
self.precheck_index_naming()
|
|
46
|
+
self._precheck_milvus_uri()
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
from llama_index.core import StorageContext, VectorStoreIndex
|
|
50
|
+
from llama_index.vector_stores.milvus import MilvusVectorStore
|
|
51
|
+
from veadk.models.ark_embedding import create_embedding_model
|
|
52
|
+
except ImportError as e:
|
|
53
|
+
raise ImportError(
|
|
54
|
+
"Please install VeADK extensions\npip install veadk-python[extensions]"
|
|
55
|
+
) from e
|
|
56
|
+
|
|
57
|
+
self._embed_model = create_embedding_model(
|
|
58
|
+
model_name=self.embedding_config.name,
|
|
59
|
+
api_key=self.embedding_config.api_key,
|
|
60
|
+
api_base=self.embedding_config.api_base,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
vector_store_kwargs: dict[str, Any] = {
|
|
64
|
+
"uri": self.milvus_config.uri,
|
|
65
|
+
"collection_name": self.index,
|
|
66
|
+
"dim": self.embedding_config.dim,
|
|
67
|
+
"overwrite": self.milvus_config.overwrite,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
token = self._resolve_token()
|
|
71
|
+
if token:
|
|
72
|
+
vector_store_kwargs["token"] = token
|
|
73
|
+
|
|
74
|
+
if self.milvus_config.db_name:
|
|
75
|
+
vector_store_kwargs["db_name"] = self.milvus_config.db_name
|
|
76
|
+
|
|
77
|
+
if self.milvus_config.timeout is not None:
|
|
78
|
+
vector_store_kwargs["timeout"] = self.milvus_config.timeout
|
|
79
|
+
|
|
80
|
+
output_fields = self._resolve_output_fields()
|
|
81
|
+
if output_fields:
|
|
82
|
+
vector_store_kwargs["output_fields"] = output_fields
|
|
83
|
+
|
|
84
|
+
self._vector_store = MilvusVectorStore(**vector_store_kwargs)
|
|
85
|
+
self._storage_context = StorageContext.from_defaults(
|
|
86
|
+
vector_store=self._vector_store
|
|
87
|
+
)
|
|
88
|
+
self._vector_index = VectorStoreIndex(
|
|
89
|
+
nodes=[],
|
|
90
|
+
storage_context=self._storage_context,
|
|
91
|
+
embed_model=self._embed_model,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@override
|
|
95
|
+
def precheck_index_naming(self) -> None:
|
|
96
|
+
if not isinstance(self.index, str) or not self.index:
|
|
97
|
+
raise ValueError("Milvus collection name must not be empty.")
|
|
98
|
+
if len(self.index) > 255:
|
|
99
|
+
raise ValueError("Milvus collection name is too long.")
|
|
100
|
+
if not re.fullmatch(r"^[A-Za-z_][A-Za-z0-9_]*$", self.index):
|
|
101
|
+
raise ValueError(
|
|
102
|
+
"Milvus collection name must start with a letter or underscore "
|
|
103
|
+
"and contain only letters, numbers, and underscores."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def _precheck_milvus_uri(self) -> None:
|
|
107
|
+
if not self.milvus_config.uri:
|
|
108
|
+
raise ValueError(
|
|
109
|
+
"Milvus uri must be configured via DATABASE_MILVUS_URI or "
|
|
110
|
+
"MilvusConfig(uri=...)."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@override
|
|
114
|
+
def add_from_directory(self, directory: str) -> bool:
|
|
115
|
+
from llama_index.core import SimpleDirectoryReader
|
|
116
|
+
|
|
117
|
+
documents = SimpleDirectoryReader(input_dir=directory).load_data()
|
|
118
|
+
nodes = self._split_documents(documents)
|
|
119
|
+
self._vector_index.insert_nodes(nodes)
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
@override
|
|
123
|
+
def add_from_files(self, files: list[str]) -> bool:
|
|
124
|
+
from llama_index.core import SimpleDirectoryReader
|
|
125
|
+
|
|
126
|
+
documents = SimpleDirectoryReader(input_files=files).load_data()
|
|
127
|
+
nodes = self._split_documents(documents)
|
|
128
|
+
self._vector_index.insert_nodes(nodes)
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
@override
|
|
132
|
+
def add_from_text(self, text: str | list[str]) -> bool:
|
|
133
|
+
from llama_index.core import Document
|
|
134
|
+
|
|
135
|
+
if isinstance(text, str):
|
|
136
|
+
documents = [Document(text=text)]
|
|
137
|
+
else:
|
|
138
|
+
documents = [Document(text=t) for t in text]
|
|
139
|
+
nodes = self._split_documents(documents)
|
|
140
|
+
self._vector_index.insert_nodes(nodes)
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
@override
|
|
144
|
+
def search(self, query: str, top_k: int = 5) -> list[str]:
|
|
145
|
+
self._ensure_collection_loaded()
|
|
146
|
+
_retriever = self._vector_index.as_retriever(similarity_top_k=top_k)
|
|
147
|
+
retrieved_nodes = _retriever.retrieve(query)
|
|
148
|
+
return [node.text for node in retrieved_nodes]
|
|
149
|
+
|
|
150
|
+
def _resolve_token(self) -> str | None:
|
|
151
|
+
if self.milvus_config.token:
|
|
152
|
+
return self.milvus_config.token
|
|
153
|
+
if self.milvus_config.user and self.milvus_config.password:
|
|
154
|
+
return f"{self.milvus_config.user}:{self.milvus_config.password}"
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
def _resolve_output_fields(self) -> list[str]:
|
|
158
|
+
output_fields = self.milvus_config.output_fields
|
|
159
|
+
if isinstance(output_fields, str):
|
|
160
|
+
output_fields = output_fields.strip()
|
|
161
|
+
if not output_fields:
|
|
162
|
+
return []
|
|
163
|
+
if output_fields.startswith("["):
|
|
164
|
+
parsed_output_fields = json.loads(output_fields)
|
|
165
|
+
if not isinstance(parsed_output_fields, list) or not all(
|
|
166
|
+
isinstance(field, str) for field in parsed_output_fields
|
|
167
|
+
):
|
|
168
|
+
raise ValueError("Milvus output_fields must be a list of strings.")
|
|
169
|
+
output_fields = parsed_output_fields
|
|
170
|
+
else:
|
|
171
|
+
output_fields = output_fields.split(",")
|
|
172
|
+
|
|
173
|
+
return [field.strip() for field in output_fields if field.strip()]
|
|
174
|
+
|
|
175
|
+
def _ensure_collection_loaded(self) -> None:
|
|
176
|
+
vector_store = getattr(self, "_vector_store", None)
|
|
177
|
+
client = getattr(vector_store, "client", None)
|
|
178
|
+
collection_name = getattr(vector_store, "collection_name", self.index)
|
|
179
|
+
if client is None or not collection_name:
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
load_state = client.get_load_state(collection_name=collection_name)
|
|
184
|
+
except Exception:
|
|
185
|
+
client.load_collection(collection_name=collection_name)
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
state = load_state.get("state") if isinstance(load_state, dict) else load_state
|
|
189
|
+
if "loaded" not in str(state).lower():
|
|
190
|
+
client.load_collection(collection_name=collection_name)
|
|
191
|
+
|
|
192
|
+
def _split_documents(self, documents: list[Any]) -> list[Any]:
|
|
193
|
+
"""Split document into chunks."""
|
|
194
|
+
from veadk.knowledgebase.backends.utils import get_llama_index_splitter
|
|
195
|
+
|
|
196
|
+
nodes = []
|
|
197
|
+
for document in documents:
|
|
198
|
+
splitter = get_llama_index_splitter(document.metadata.get("file_path", ""))
|
|
199
|
+
_nodes = splitter.get_nodes_from_documents([document])
|
|
200
|
+
nodes.extend(_nodes)
|
|
201
|
+
return nodes
|