licos-dev-sdk 0.2.1__tar.gz → 0.2.3__tar.gz
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.
- {licos_dev_sdk-0.2.1 → licos_dev_sdk-0.2.3}/PKG-INFO +1 -1
- {licos_dev_sdk-0.2.1 → licos_dev_sdk-0.2.3}/pyproject.toml +1 -1
- {licos_dev_sdk-0.2.1 → licos_dev_sdk-0.2.3}/src/licos_dev_sdk/__init__.py +19 -2
- {licos_dev_sdk-0.2.1 → licos_dev_sdk-0.2.3}/src/licos_dev_sdk/model.py +126 -113
- licos_dev_sdk-0.2.3/src/licos_dev_sdk/observability.py +527 -0
- {licos_dev_sdk-0.2.1 → licos_dev_sdk-0.2.3}/tests/test_model.py +90 -74
- licos_dev_sdk-0.2.3/tests/test_observability.py +150 -0
- {licos_dev_sdk-0.2.1 → licos_dev_sdk-0.2.3}/.gitignore +0 -0
- {licos_dev_sdk-0.2.1 → licos_dev_sdk-0.2.3}/src/licos_dev_sdk/_utils.py +0 -0
- {licos_dev_sdk-0.2.1 → licos_dev_sdk-0.2.3}/src/licos_dev_sdk/archive.py +0 -0
- {licos_dev_sdk-0.2.1 → licos_dev_sdk-0.2.3}/src/licos_dev_sdk/chart.py +0 -0
- {licos_dev_sdk-0.2.1 → licos_dev_sdk-0.2.3}/src/licos_dev_sdk/data.py +0 -0
- {licos_dev_sdk-0.2.1 → licos_dev_sdk-0.2.3}/src/licos_dev_sdk/diagram.py +0 -0
- {licos_dev_sdk-0.2.1 → licos_dev_sdk-0.2.3}/src/licos_dev_sdk/document.py +0 -0
- {licos_dev_sdk-0.2.1 → licos_dev_sdk-0.2.3}/src/licos_dev_sdk/image.py +0 -0
- {licos_dev_sdk-0.2.1 → licos_dev_sdk-0.2.3}/src/licos_dev_sdk/presentation.py +0 -0
- {licos_dev_sdk-0.2.1 → licos_dev_sdk-0.2.3}/src/licos_dev_sdk/spreadsheet.py +0 -0
- {licos_dev_sdk-0.2.1 → licos_dev_sdk-0.2.3}/src/licos_dev_sdk/web.py +0 -0
|
@@ -43,12 +43,14 @@ def __getattr__(name: str):
|
|
|
43
43
|
"ConfigurationError": ("model", "ConfigurationError"),
|
|
44
44
|
"LLMClient": ("model", "LLMClient"),
|
|
45
45
|
"VisionClient": ("model", "VisionClient"),
|
|
46
|
+
"VisionUnderstandingClient": ("model", "VisionUnderstandingClient"),
|
|
46
47
|
"ImageGenerationClient": ("model", "ImageGenerationClient"),
|
|
47
48
|
"VideoGenerationClient": ("model", "VideoGenerationClient"),
|
|
48
49
|
"SpeechRecognitionClient": ("model", "SpeechRecognitionClient"),
|
|
49
50
|
"ASRClient": ("model", "ASRClient"),
|
|
50
51
|
"fetch_model_catalogs": ("model", "fetch_model_catalogs"),
|
|
51
52
|
"resolve_llm_endpoint": ("model", "resolve_llm_endpoint"),
|
|
53
|
+
"resolve_vision_endpoint": ("model", "resolve_vision_endpoint"),
|
|
52
54
|
"resolve_image_generation_endpoint": ("model", "resolve_image_generation_endpoint"),
|
|
53
55
|
"resolve_video_generation_endpoint": ("model", "resolve_video_generation_endpoint"),
|
|
54
56
|
"resolve_speech_recognition_endpoint": ("model", "resolve_speech_recognition_endpoint"),
|
|
@@ -57,6 +59,17 @@ def __getattr__(name: str):
|
|
|
57
59
|
"generate_video": ("model", "generate_video"),
|
|
58
60
|
"recognize_speech": ("model", "recognize_speech"),
|
|
59
61
|
"understand_image": ("model", "understand_image"),
|
|
62
|
+
# observability
|
|
63
|
+
"ObservabilityClient": ("observability", "ObservabilityClient"),
|
|
64
|
+
"ObservabilityRuntime": ("observability", "ObservabilityRuntime"),
|
|
65
|
+
"ensure_observability_database": ("observability", "ensure_observability_database"),
|
|
66
|
+
"log": ("observability", "log"),
|
|
67
|
+
"log_info": ("observability", "log_info"),
|
|
68
|
+
"log_warning": ("observability", "log_warning"),
|
|
69
|
+
"log_error": ("observability", "log_error"),
|
|
70
|
+
"record_trace": ("observability", "record_trace"),
|
|
71
|
+
"record_metric": ("observability", "record_metric"),
|
|
72
|
+
"record_error": ("observability", "record_error"),
|
|
60
73
|
}
|
|
61
74
|
if name in _map:
|
|
62
75
|
mod_name, attr = _map[name]
|
|
@@ -78,10 +91,14 @@ __all__ = [
|
|
|
78
91
|
"create_pptx",
|
|
79
92
|
"ModelRuntime", "ModelEndpoint", "ModelResult",
|
|
80
93
|
"ApiError", "ConfigurationError",
|
|
81
|
-
"LLMClient", "VisionClient", "ImageGenerationClient", "VideoGenerationClient",
|
|
94
|
+
"LLMClient", "VisionClient", "VisionUnderstandingClient", "ImageGenerationClient", "VideoGenerationClient",
|
|
82
95
|
"SpeechRecognitionClient", "ASRClient",
|
|
83
|
-
"fetch_model_catalogs", "resolve_llm_endpoint",
|
|
96
|
+
"fetch_model_catalogs", "resolve_llm_endpoint", "resolve_vision_endpoint",
|
|
84
97
|
"resolve_image_generation_endpoint", "resolve_video_generation_endpoint",
|
|
85
98
|
"resolve_speech_recognition_endpoint",
|
|
86
99
|
"invoke_llm", "generate_image", "generate_video", "recognize_speech", "understand_image",
|
|
100
|
+
"ObservabilityClient", "ObservabilityRuntime",
|
|
101
|
+
"ensure_observability_database",
|
|
102
|
+
"log", "log_info", "log_warning", "log_error",
|
|
103
|
+
"record_trace", "record_metric", "record_error",
|
|
87
104
|
]
|
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import time
|
|
5
|
-
from dataclasses import dataclass, field, replace
|
|
5
|
+
from dataclasses import dataclass, field, replace
|
|
6
6
|
from typing import Any, Iterator, Sequence
|
|
7
7
|
from urllib import error as urlerror
|
|
8
8
|
from urllib import parse, request
|
|
@@ -11,11 +11,11 @@ from licos_platform_sdk._runtime import (
|
|
|
11
11
|
ApiError,
|
|
12
12
|
ConfigurationError,
|
|
13
13
|
env,
|
|
14
|
-
normalize_base_url,
|
|
15
|
-
platform_base_url,
|
|
16
|
-
resolve_user_token,
|
|
17
|
-
should_refresh_user_token,
|
|
18
|
-
)
|
|
14
|
+
normalize_base_url,
|
|
15
|
+
platform_base_url,
|
|
16
|
+
resolve_user_token,
|
|
17
|
+
should_refresh_user_token,
|
|
18
|
+
)
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
MODEL_CATALOG_PATH = "/api/v1/llm-gateway/ai/model-catalog"
|
|
@@ -112,6 +112,16 @@ def resolve_llm_endpoint(
|
|
|
112
112
|
return _resolve_endpoint(runtime, "chat", model_group=model_group)
|
|
113
113
|
|
|
114
114
|
|
|
115
|
+
def resolve_vision_endpoint(
|
|
116
|
+
*,
|
|
117
|
+
base_url: str | None = None,
|
|
118
|
+
user_token: str | None = None,
|
|
119
|
+
user_id: str | None = None,
|
|
120
|
+
) -> ModelEndpoint:
|
|
121
|
+
runtime = _model_runtime(base_url=base_url, user_token=user_token, user_id=user_id)
|
|
122
|
+
return _resolve_endpoint(runtime, "chat", model_group="vision")
|
|
123
|
+
|
|
124
|
+
|
|
115
125
|
def resolve_image_generation_endpoint(
|
|
116
126
|
*,
|
|
117
127
|
base_url: str | None = None,
|
|
@@ -266,6 +276,9 @@ class VisionClient:
|
|
|
266
276
|
)
|
|
267
277
|
|
|
268
278
|
|
|
279
|
+
VisionUnderstandingClient = VisionClient
|
|
280
|
+
|
|
281
|
+
|
|
269
282
|
class ImageGenerationClient:
|
|
270
283
|
def __init__(
|
|
271
284
|
self,
|
|
@@ -442,7 +455,7 @@ def clear_model_catalog_cache_for_tests() -> None:
|
|
|
442
455
|
_CATALOG_CACHE.clear()
|
|
443
456
|
|
|
444
457
|
|
|
445
|
-
def _model_runtime(
|
|
458
|
+
def _model_runtime(
|
|
446
459
|
*,
|
|
447
460
|
base_url: str | None = None,
|
|
448
461
|
user_token: str | None = None,
|
|
@@ -450,13 +463,13 @@ def _model_runtime(
|
|
|
450
463
|
) -> ModelRuntime:
|
|
451
464
|
resolved_base_url = normalize_base_url(base_url) if base_url else platform_base_url()
|
|
452
465
|
owner_user_id = user_id or env("LICOS_USER_ID") or env("AGENT_USER_ID")
|
|
453
|
-
token = (user_token or "").strip() or resolve_user_token(resolved_base_url, owner_user_id)
|
|
454
|
-
return ModelRuntime(base_url=resolved_base_url, token=token, user_id=owner_user_id)
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
def _refresh_model_runtime(runtime: ModelRuntime) -> ModelRuntime:
|
|
458
|
-
token = resolve_user_token(runtime.base_url, runtime.user_id, force_refresh=True)
|
|
459
|
-
return replace(runtime, token=token)
|
|
466
|
+
token = (user_token or "").strip() or resolve_user_token(resolved_base_url, owner_user_id)
|
|
467
|
+
return ModelRuntime(base_url=resolved_base_url, token=token, user_id=owner_user_id)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _refresh_model_runtime(runtime: ModelRuntime) -> ModelRuntime:
|
|
471
|
+
token = resolve_user_token(runtime.base_url, runtime.user_id, force_refresh=True)
|
|
472
|
+
return replace(runtime, token=token)
|
|
460
473
|
|
|
461
474
|
|
|
462
475
|
def _fetch_model_catalogs(runtime: ModelRuntime, *, refresh: bool = False) -> list[dict[str, Any]]:
|
|
@@ -466,17 +479,17 @@ def _fetch_model_catalogs(runtime: ModelRuntime, *, refresh: bool = False) -> li
|
|
|
466
479
|
if cached and not refresh and time.time() - cached[0] <= ttl:
|
|
467
480
|
return cached[1]
|
|
468
481
|
|
|
469
|
-
try:
|
|
470
|
-
payload = _request_json(
|
|
471
|
-
"GET",
|
|
472
|
-
f"{runtime.base_url}{MODEL_CATALOG_PATH}",
|
|
473
|
-
token=runtime.token,
|
|
474
|
-
timeout=30,
|
|
475
|
-
)
|
|
476
|
-
except ApiError as exc:
|
|
477
|
-
if not refresh and should_refresh_user_token(exc):
|
|
478
|
-
return _fetch_model_catalogs(_refresh_model_runtime(runtime), refresh=True)
|
|
479
|
-
raise
|
|
482
|
+
try:
|
|
483
|
+
payload = _request_json(
|
|
484
|
+
"GET",
|
|
485
|
+
f"{runtime.base_url}{MODEL_CATALOG_PATH}",
|
|
486
|
+
token=runtime.token,
|
|
487
|
+
timeout=30,
|
|
488
|
+
)
|
|
489
|
+
except ApiError as exc:
|
|
490
|
+
if not refresh and should_refresh_user_token(exc):
|
|
491
|
+
return _fetch_model_catalogs(_refresh_model_runtime(runtime), refresh=True)
|
|
492
|
+
raise
|
|
480
493
|
catalogs = _catalogs_from_payload(payload)
|
|
481
494
|
if not catalogs:
|
|
482
495
|
raise ApiError("model catalog has no provider entries", details=payload)
|
|
@@ -596,35 +609,35 @@ def _first_string(value: Any) -> str | None:
|
|
|
596
609
|
return None
|
|
597
610
|
|
|
598
611
|
|
|
599
|
-
def _post_model_json(
|
|
612
|
+
def _post_model_json(
|
|
600
613
|
endpoint: ModelEndpoint,
|
|
601
614
|
runtime: ModelRuntime,
|
|
602
615
|
body: dict[str, Any],
|
|
603
616
|
*,
|
|
604
617
|
timeout: int | None = None,
|
|
605
|
-
) -> Any:
|
|
606
|
-
request_timeout = timeout or _request_timeout()
|
|
607
|
-
try:
|
|
608
|
-
return _request_json(
|
|
609
|
-
"POST",
|
|
610
|
-
endpoint.base_url,
|
|
611
|
-
token=runtime.token,
|
|
612
|
-
body=body,
|
|
613
|
-
headers=endpoint.required_headers,
|
|
614
|
-
timeout=request_timeout,
|
|
615
|
-
)
|
|
616
|
-
except ApiError as exc:
|
|
617
|
-
if should_refresh_user_token(exc):
|
|
618
|
-
refreshed = _refresh_model_runtime(runtime)
|
|
619
|
-
return _request_json(
|
|
620
|
-
"POST",
|
|
621
|
-
endpoint.base_url,
|
|
622
|
-
token=refreshed.token,
|
|
623
|
-
body=body,
|
|
624
|
-
headers=endpoint.required_headers,
|
|
625
|
-
timeout=request_timeout,
|
|
626
|
-
)
|
|
627
|
-
raise
|
|
618
|
+
) -> Any:
|
|
619
|
+
request_timeout = timeout or _request_timeout()
|
|
620
|
+
try:
|
|
621
|
+
return _request_json(
|
|
622
|
+
"POST",
|
|
623
|
+
endpoint.base_url,
|
|
624
|
+
token=runtime.token,
|
|
625
|
+
body=body,
|
|
626
|
+
headers=endpoint.required_headers,
|
|
627
|
+
timeout=request_timeout,
|
|
628
|
+
)
|
|
629
|
+
except ApiError as exc:
|
|
630
|
+
if should_refresh_user_token(exc):
|
|
631
|
+
refreshed = _refresh_model_runtime(runtime)
|
|
632
|
+
return _request_json(
|
|
633
|
+
"POST",
|
|
634
|
+
endpoint.base_url,
|
|
635
|
+
token=refreshed.token,
|
|
636
|
+
body=body,
|
|
637
|
+
headers=endpoint.required_headers,
|
|
638
|
+
timeout=request_timeout,
|
|
639
|
+
)
|
|
640
|
+
raise
|
|
628
641
|
|
|
629
642
|
|
|
630
643
|
def _submit_model_task(
|
|
@@ -657,46 +670,46 @@ def _submit_model_task(
|
|
|
657
670
|
)
|
|
658
671
|
|
|
659
672
|
|
|
660
|
-
def _stream_model_json(
|
|
673
|
+
def _stream_model_json(
|
|
661
674
|
endpoint: ModelEndpoint,
|
|
662
675
|
runtime: ModelRuntime,
|
|
663
676
|
body: dict[str, Any],
|
|
664
677
|
*,
|
|
665
678
|
timeout: int | None = None,
|
|
666
|
-
) -> Iterator[str]:
|
|
667
|
-
active_runtime = runtime
|
|
668
|
-
for attempt in range(2):
|
|
669
|
-
req = _json_request(
|
|
670
|
-
"POST",
|
|
671
|
-
endpoint.base_url,
|
|
672
|
-
token=active_runtime.token,
|
|
673
|
-
body=body,
|
|
674
|
-
headers=endpoint.required_headers,
|
|
675
|
-
)
|
|
676
|
-
try:
|
|
677
|
-
with request.urlopen(req, timeout=timeout or _request_timeout()) as response:
|
|
678
|
-
for raw_line in response:
|
|
679
|
-
line = raw_line.decode("utf-8", errors="replace").strip()
|
|
680
|
-
if not line:
|
|
681
|
-
continue
|
|
682
|
-
if line.startswith("data:"):
|
|
683
|
-
data = line[len("data:") :].strip()
|
|
684
|
-
if data == "[DONE]":
|
|
685
|
-
break
|
|
686
|
-
content = _extract_stream_content(data)
|
|
687
|
-
if content:
|
|
688
|
-
yield content
|
|
689
|
-
continue
|
|
690
|
-
yield line
|
|
691
|
-
return
|
|
692
|
-
except urlerror.HTTPError as exc:
|
|
693
|
-
error = _api_error_from_http(exc)
|
|
694
|
-
if attempt == 0 and should_refresh_user_token(error):
|
|
695
|
-
active_runtime = _refresh_model_runtime(active_runtime)
|
|
696
|
-
continue
|
|
697
|
-
raise error from exc
|
|
698
|
-
except urlerror.URLError as exc:
|
|
699
|
-
raise ApiError(f"model stream request failed: {exc}") from exc
|
|
679
|
+
) -> Iterator[str]:
|
|
680
|
+
active_runtime = runtime
|
|
681
|
+
for attempt in range(2):
|
|
682
|
+
req = _json_request(
|
|
683
|
+
"POST",
|
|
684
|
+
endpoint.base_url,
|
|
685
|
+
token=active_runtime.token,
|
|
686
|
+
body=body,
|
|
687
|
+
headers=endpoint.required_headers,
|
|
688
|
+
)
|
|
689
|
+
try:
|
|
690
|
+
with request.urlopen(req, timeout=timeout or _request_timeout()) as response:
|
|
691
|
+
for raw_line in response:
|
|
692
|
+
line = raw_line.decode("utf-8", errors="replace").strip()
|
|
693
|
+
if not line:
|
|
694
|
+
continue
|
|
695
|
+
if line.startswith("data:"):
|
|
696
|
+
data = line[len("data:") :].strip()
|
|
697
|
+
if data == "[DONE]":
|
|
698
|
+
break
|
|
699
|
+
content = _extract_stream_content(data)
|
|
700
|
+
if content:
|
|
701
|
+
yield content
|
|
702
|
+
continue
|
|
703
|
+
yield line
|
|
704
|
+
return
|
|
705
|
+
except urlerror.HTTPError as exc:
|
|
706
|
+
error = _api_error_from_http(exc)
|
|
707
|
+
if attempt == 0 and should_refresh_user_token(error):
|
|
708
|
+
active_runtime = _refresh_model_runtime(active_runtime)
|
|
709
|
+
continue
|
|
710
|
+
raise error from exc
|
|
711
|
+
except urlerror.URLError as exc:
|
|
712
|
+
raise ApiError(f"model stream request failed: {exc}") from exc
|
|
700
713
|
|
|
701
714
|
|
|
702
715
|
def _request_json(
|
|
@@ -769,28 +782,28 @@ def _await_async_model_result(
|
|
|
769
782
|
raise ApiError("async model response is missing task_id", details=submit_response)
|
|
770
783
|
query_url = _build_task_query_url(endpoint, task_id)
|
|
771
784
|
deadline = time.time() + (max_wait_seconds or _async_timeout())
|
|
772
|
-
last_status = _task_status(submit_response) or "UNKNOWN"
|
|
773
|
-
while time.time() < deadline:
|
|
774
|
-
try:
|
|
775
|
-
response = _request_json(
|
|
776
|
-
"GET",
|
|
777
|
-
query_url,
|
|
778
|
-
token=runtime.token,
|
|
779
|
-
headers=endpoint.required_headers,
|
|
780
|
-
timeout=timeout or _request_timeout(),
|
|
781
|
-
)
|
|
782
|
-
except ApiError as exc:
|
|
783
|
-
if should_refresh_user_token(exc):
|
|
784
|
-
refreshed = _refresh_model_runtime(runtime)
|
|
785
|
-
response = _request_json(
|
|
786
|
-
"GET",
|
|
787
|
-
query_url,
|
|
788
|
-
token=refreshed.token,
|
|
789
|
-
headers=endpoint.required_headers,
|
|
790
|
-
timeout=timeout or _request_timeout(),
|
|
791
|
-
)
|
|
792
|
-
else:
|
|
793
|
-
raise
|
|
785
|
+
last_status = _task_status(submit_response) or "UNKNOWN"
|
|
786
|
+
while time.time() < deadline:
|
|
787
|
+
try:
|
|
788
|
+
response = _request_json(
|
|
789
|
+
"GET",
|
|
790
|
+
query_url,
|
|
791
|
+
token=runtime.token,
|
|
792
|
+
headers=endpoint.required_headers,
|
|
793
|
+
timeout=timeout or _request_timeout(),
|
|
794
|
+
)
|
|
795
|
+
except ApiError as exc:
|
|
796
|
+
if should_refresh_user_token(exc):
|
|
797
|
+
refreshed = _refresh_model_runtime(runtime)
|
|
798
|
+
response = _request_json(
|
|
799
|
+
"GET",
|
|
800
|
+
query_url,
|
|
801
|
+
token=refreshed.token,
|
|
802
|
+
headers=endpoint.required_headers,
|
|
803
|
+
timeout=timeout or _request_timeout(),
|
|
804
|
+
)
|
|
805
|
+
else:
|
|
806
|
+
raise
|
|
794
807
|
last_status = _task_status(response) or last_status
|
|
795
808
|
if _is_successful_task_response(response):
|
|
796
809
|
return response
|
|
@@ -942,13 +955,13 @@ def _normalize_messages(messages: Sequence[Any] | str) -> list[dict[str, Any]]:
|
|
|
942
955
|
return result
|
|
943
956
|
|
|
944
957
|
|
|
945
|
-
def _selected_model(model: str | None, default: str) -> str:
|
|
946
|
-
if not isinstance(model, str):
|
|
947
|
-
return default
|
|
948
|
-
selected = model.strip()
|
|
949
|
-
if not selected or selected.lower() == "auto":
|
|
950
|
-
return default
|
|
951
|
-
return selected
|
|
958
|
+
def _selected_model(model: str | None, default: str) -> str:
|
|
959
|
+
if not isinstance(model, str):
|
|
960
|
+
return default
|
|
961
|
+
selected = model.strip()
|
|
962
|
+
if not selected or selected.lower() == "auto":
|
|
963
|
+
return default
|
|
964
|
+
return selected
|
|
952
965
|
|
|
953
966
|
|
|
954
967
|
def _image_count(count: int | None, parameters: dict[str, Any]) -> int:
|
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib import error as urlerror
|
|
11
|
+
from urllib import parse, request
|
|
12
|
+
|
|
13
|
+
from licos_platform_sdk._runtime import (
|
|
14
|
+
ApiError,
|
|
15
|
+
ConfigurationError,
|
|
16
|
+
auth_headers,
|
|
17
|
+
env,
|
|
18
|
+
normalize_base_url,
|
|
19
|
+
platform_base_url,
|
|
20
|
+
resolve_user_token,
|
|
21
|
+
should_refresh_user_token,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
OBSERVABILITY_PATH = "/api/v1/studio/observability"
|
|
26
|
+
DEFAULT_TIMEOUT_SECS = 15
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ObservabilityRuntime:
|
|
31
|
+
base_url: str
|
|
32
|
+
project_id: str
|
|
33
|
+
token: str
|
|
34
|
+
workspace_id: str | None = None
|
|
35
|
+
user_id: str | None = None
|
|
36
|
+
project_type: str | None = None
|
|
37
|
+
deployment_version: str = "dev"
|
|
38
|
+
hash_version: str = "dev"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ObservabilityClient:
|
|
42
|
+
"""Client for project observability records stored by the LICOS platform."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
base_url: str | None = None,
|
|
48
|
+
user_token: str | None = None,
|
|
49
|
+
user_id: str | None = None,
|
|
50
|
+
project_id: str | None = None,
|
|
51
|
+
workspace_id: str | None = None,
|
|
52
|
+
project_type: str | None = None,
|
|
53
|
+
deployment_version: str | None = None,
|
|
54
|
+
hash_version: str | None = None,
|
|
55
|
+
timeout: int | float = DEFAULT_TIMEOUT_SECS,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.runtime = _observability_runtime(
|
|
58
|
+
base_url=base_url,
|
|
59
|
+
user_token=user_token,
|
|
60
|
+
user_id=user_id,
|
|
61
|
+
project_id=project_id,
|
|
62
|
+
workspace_id=workspace_id,
|
|
63
|
+
project_type=project_type,
|
|
64
|
+
deployment_version=deployment_version,
|
|
65
|
+
hash_version=hash_version,
|
|
66
|
+
)
|
|
67
|
+
self.timeout = timeout
|
|
68
|
+
|
|
69
|
+
def ensure_database(self) -> Any:
|
|
70
|
+
"""Create or confirm the platform observability database for this project."""
|
|
71
|
+
return _request_data(
|
|
72
|
+
"POST",
|
|
73
|
+
self.runtime,
|
|
74
|
+
"/database",
|
|
75
|
+
query={"projectId": self.runtime.project_id},
|
|
76
|
+
timeout=self.timeout,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def log(
|
|
80
|
+
self,
|
|
81
|
+
level: str,
|
|
82
|
+
content: Any,
|
|
83
|
+
*,
|
|
84
|
+
log_time: datetime | str | None = None,
|
|
85
|
+
deployment_version: str | None = None,
|
|
86
|
+
) -> Any:
|
|
87
|
+
body = {
|
|
88
|
+
"logTime": _iso_time(log_time),
|
|
89
|
+
"deploymentVersion": deployment_version or self.runtime.deployment_version,
|
|
90
|
+
"level": _normalize_level(level),
|
|
91
|
+
"logContent": _to_text(content),
|
|
92
|
+
}
|
|
93
|
+
return _request_data(
|
|
94
|
+
"POST",
|
|
95
|
+
self.runtime,
|
|
96
|
+
"/logs",
|
|
97
|
+
query={"projectId": self.runtime.project_id},
|
|
98
|
+
body=body,
|
|
99
|
+
timeout=self.timeout,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def log_info(self, content: Any, **kwargs: Any) -> Any:
|
|
103
|
+
return self.log("info", content, **kwargs)
|
|
104
|
+
|
|
105
|
+
def log_warning(self, content: Any, **kwargs: Any) -> Any:
|
|
106
|
+
return self.log("warning", content, **kwargs)
|
|
107
|
+
|
|
108
|
+
def log_error(self, content: Any, *, error_code: str | None = None, **kwargs: Any) -> Any:
|
|
109
|
+
message = _error_content(content, error_code)
|
|
110
|
+
return self.log("error", message, **kwargs)
|
|
111
|
+
|
|
112
|
+
def record_trace(
|
|
113
|
+
self,
|
|
114
|
+
*,
|
|
115
|
+
trace_id: str,
|
|
116
|
+
log_id: str | None = None,
|
|
117
|
+
hash_version: str | None = None,
|
|
118
|
+
input: Any = None,
|
|
119
|
+
output: Any = None,
|
|
120
|
+
input_tokens: int | None = None,
|
|
121
|
+
output_tokens: int | None = None,
|
|
122
|
+
latency_seconds: int | float | None = None,
|
|
123
|
+
latency_first_resp_ms: int | float | None = None,
|
|
124
|
+
) -> Any:
|
|
125
|
+
body = {
|
|
126
|
+
"traceId": _required(trace_id, "trace_id"),
|
|
127
|
+
"logId": log_id,
|
|
128
|
+
"hashVersion": hash_version or self.runtime.hash_version,
|
|
129
|
+
"input": _to_text(input),
|
|
130
|
+
"output": _to_text(output),
|
|
131
|
+
"inputTokens": _int_or_zero(input_tokens),
|
|
132
|
+
"outputTokens": _int_or_zero(output_tokens),
|
|
133
|
+
"latencySeconds": _float_or_zero(latency_seconds),
|
|
134
|
+
"latencyFirstRespMs": _int_or_zero(latency_first_resp_ms),
|
|
135
|
+
}
|
|
136
|
+
return _request_data(
|
|
137
|
+
"POST",
|
|
138
|
+
self.runtime,
|
|
139
|
+
"/traces",
|
|
140
|
+
query={"projectId": self.runtime.project_id},
|
|
141
|
+
body=_compact(body),
|
|
142
|
+
timeout=self.timeout,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def record_metric(
|
|
146
|
+
self,
|
|
147
|
+
*,
|
|
148
|
+
route: str,
|
|
149
|
+
method: str,
|
|
150
|
+
latency_ms: int | float | None = None,
|
|
151
|
+
status_code: int | None = None,
|
|
152
|
+
window_start: datetime | str | None = None,
|
|
153
|
+
window_seconds: int = 60,
|
|
154
|
+
request_count: int = 1,
|
|
155
|
+
error_count: int | None = None,
|
|
156
|
+
latency_total_ms: int | float | None = None,
|
|
157
|
+
latency_count: int | None = None,
|
|
158
|
+
latency_min_ms: int | float | None = None,
|
|
159
|
+
latency_max_ms: int | float | None = None,
|
|
160
|
+
status2xx_count: int | None = None,
|
|
161
|
+
status3xx_count: int | None = None,
|
|
162
|
+
status4xx_count: int | None = None,
|
|
163
|
+
status5xx_count: int | None = None,
|
|
164
|
+
latency_buckets: dict[str, int] | None = None,
|
|
165
|
+
) -> Any:
|
|
166
|
+
status = status_code or 200
|
|
167
|
+
latency = _number_or_none(latency_ms)
|
|
168
|
+
body = {
|
|
169
|
+
"projectId": self.runtime.project_id,
|
|
170
|
+
"workspaceId": self.runtime.workspace_id,
|
|
171
|
+
"userId": self.runtime.user_id,
|
|
172
|
+
"projectType": self.runtime.project_type,
|
|
173
|
+
"route": _required(route, "route"),
|
|
174
|
+
"method": _required(method, "method").upper(),
|
|
175
|
+
"windowStart": _window_start(window_start),
|
|
176
|
+
"windowSeconds": int(window_seconds),
|
|
177
|
+
"requestCount": int(request_count),
|
|
178
|
+
"errorCount": int(error_count) if error_count is not None else (1 if status >= 400 else 0),
|
|
179
|
+
"latencyTotalMs": _number_or_zero(latency_total_ms if latency_total_ms is not None else latency),
|
|
180
|
+
"latencyCount": int(latency_count) if latency_count is not None else (1 if latency is not None else 0),
|
|
181
|
+
"latencyMinMs": _number_or_zero(latency_min_ms if latency_min_ms is not None else latency),
|
|
182
|
+
"latencyMaxMs": _number_or_zero(latency_max_ms if latency_max_ms is not None else latency),
|
|
183
|
+
"status2xxCount": int(status2xx_count) if status2xx_count is not None else (1 if 200 <= status < 300 else 0),
|
|
184
|
+
"status3xxCount": int(status3xx_count) if status3xx_count is not None else (1 if 300 <= status < 400 else 0),
|
|
185
|
+
"status4xxCount": int(status4xx_count) if status4xx_count is not None else (1 if 400 <= status < 500 else 0),
|
|
186
|
+
"status5xxCount": int(status5xx_count) if status5xx_count is not None else (1 if status >= 500 else 0),
|
|
187
|
+
"latencyBuckets": latency_buckets or _latency_bucket(latency),
|
|
188
|
+
}
|
|
189
|
+
return _request_data(
|
|
190
|
+
"POST",
|
|
191
|
+
self.runtime,
|
|
192
|
+
"/analysis/metrics",
|
|
193
|
+
query={"projectId": self.runtime.project_id},
|
|
194
|
+
body=_compact(body),
|
|
195
|
+
timeout=self.timeout,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def record_error(
|
|
199
|
+
self,
|
|
200
|
+
*,
|
|
201
|
+
route: str,
|
|
202
|
+
method: str,
|
|
203
|
+
status_code: int = 500,
|
|
204
|
+
error_code: str | None = None,
|
|
205
|
+
error_message: Any = "",
|
|
206
|
+
trace_id: str | None = None,
|
|
207
|
+
occurred_at: datetime | str | None = None,
|
|
208
|
+
) -> Any:
|
|
209
|
+
body = {
|
|
210
|
+
"projectId": self.runtime.project_id,
|
|
211
|
+
"workspaceId": self.runtime.workspace_id,
|
|
212
|
+
"userId": self.runtime.user_id,
|
|
213
|
+
"route": _required(route, "route"),
|
|
214
|
+
"method": _required(method, "method").upper(),
|
|
215
|
+
"statusCode": int(status_code),
|
|
216
|
+
"errorCode": error_code,
|
|
217
|
+
"errorMessage": _to_text(error_message),
|
|
218
|
+
"traceId": trace_id,
|
|
219
|
+
"occurredAt": _iso_time(occurred_at),
|
|
220
|
+
}
|
|
221
|
+
return _request_data(
|
|
222
|
+
"POST",
|
|
223
|
+
self.runtime,
|
|
224
|
+
"/analysis/errors",
|
|
225
|
+
query={"projectId": self.runtime.project_id},
|
|
226
|
+
body=_compact(body),
|
|
227
|
+
timeout=self.timeout,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def ensure_observability_database(**kwargs: Any) -> Any:
|
|
232
|
+
return ObservabilityClient(**kwargs).ensure_database()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def log(level: str, content: Any, **kwargs: Any) -> Any:
|
|
236
|
+
client_kwargs, call_kwargs = _split_client_kwargs(kwargs)
|
|
237
|
+
return ObservabilityClient(**client_kwargs).log(level, content, **call_kwargs)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def log_info(content: Any, **kwargs: Any) -> Any:
|
|
241
|
+
client_kwargs, call_kwargs = _split_client_kwargs(kwargs)
|
|
242
|
+
return ObservabilityClient(**client_kwargs).log_info(content, **call_kwargs)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def log_warning(content: Any, **kwargs: Any) -> Any:
|
|
246
|
+
client_kwargs, call_kwargs = _split_client_kwargs(kwargs)
|
|
247
|
+
return ObservabilityClient(**client_kwargs).log_warning(content, **call_kwargs)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def log_error(content: Any, **kwargs: Any) -> Any:
|
|
251
|
+
client_kwargs, call_kwargs = _split_client_kwargs(kwargs)
|
|
252
|
+
return ObservabilityClient(**client_kwargs).log_error(content, **call_kwargs)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def record_trace(**kwargs: Any) -> Any:
|
|
256
|
+
client_kwargs, call_kwargs = _split_client_kwargs(kwargs)
|
|
257
|
+
return ObservabilityClient(**client_kwargs).record_trace(**call_kwargs)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def record_metric(**kwargs: Any) -> Any:
|
|
261
|
+
client_kwargs, call_kwargs = _split_client_kwargs(kwargs)
|
|
262
|
+
return ObservabilityClient(**client_kwargs).record_metric(**call_kwargs)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def record_error(**kwargs: Any) -> Any:
|
|
266
|
+
client_kwargs, call_kwargs = _split_client_kwargs(kwargs)
|
|
267
|
+
return ObservabilityClient(**client_kwargs).record_error(**call_kwargs)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _observability_runtime(
|
|
271
|
+
*,
|
|
272
|
+
base_url: str | None = None,
|
|
273
|
+
user_token: str | None = None,
|
|
274
|
+
user_id: str | None = None,
|
|
275
|
+
project_id: str | None = None,
|
|
276
|
+
workspace_id: str | None = None,
|
|
277
|
+
project_type: str | None = None,
|
|
278
|
+
deployment_version: str | None = None,
|
|
279
|
+
hash_version: str | None = None,
|
|
280
|
+
) -> ObservabilityRuntime:
|
|
281
|
+
resolved_base_url = normalize_base_url(base_url) if base_url else platform_base_url()
|
|
282
|
+
owner_user_id = user_id or env("LICOS_USER_ID") or env("AGENT_USER_ID")
|
|
283
|
+
token = (user_token or "").strip() or resolve_user_token(resolved_base_url, owner_user_id)
|
|
284
|
+
resolved_project_id = project_id or env("LICOS_PROJECT_ID") or env("AGENT_PROJECT_ID")
|
|
285
|
+
if not resolved_project_id:
|
|
286
|
+
raise ConfigurationError("LICOS_PROJECT_ID or AGENT_PROJECT_ID is not configured")
|
|
287
|
+
resolved_hash = hash_version or _hash_version()
|
|
288
|
+
return ObservabilityRuntime(
|
|
289
|
+
base_url=resolved_base_url,
|
|
290
|
+
project_id=resolved_project_id,
|
|
291
|
+
token=token,
|
|
292
|
+
workspace_id=workspace_id or env("LICOS_WORKSPACE_ID") or env("AGENT_WORKSPACE_ID"),
|
|
293
|
+
user_id=owner_user_id,
|
|
294
|
+
project_type=project_type or env("LICOS_PROJECT_TYPE") or env("AGENT_PROJECT_TYPE"),
|
|
295
|
+
deployment_version=deployment_version or _deployment_version(resolved_hash),
|
|
296
|
+
hash_version=resolved_hash,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _refresh_runtime(runtime: ObservabilityRuntime) -> ObservabilityRuntime:
|
|
301
|
+
token = resolve_user_token(runtime.base_url, runtime.user_id, force_refresh=True)
|
|
302
|
+
return replace(runtime, token=token)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _request_data(
|
|
306
|
+
method: str,
|
|
307
|
+
runtime: ObservabilityRuntime,
|
|
308
|
+
path: str,
|
|
309
|
+
*,
|
|
310
|
+
query: dict[str, Any] | None = None,
|
|
311
|
+
body: dict[str, Any] | None = None,
|
|
312
|
+
timeout: int | float = DEFAULT_TIMEOUT_SECS,
|
|
313
|
+
refresh: bool = False,
|
|
314
|
+
) -> Any:
|
|
315
|
+
url = f"{runtime.base_url}{OBSERVABILITY_PATH}{path}"
|
|
316
|
+
if query:
|
|
317
|
+
url = f"{url}?{parse.urlencode(_compact(query))}"
|
|
318
|
+
data = json.dumps(body or {}).encode("utf-8") if body is not None else None
|
|
319
|
+
req = request.Request(url, method=method, data=data, headers=auth_headers(runtime)) # type: ignore[arg-type]
|
|
320
|
+
try:
|
|
321
|
+
with request.urlopen(req, timeout=timeout) as response:
|
|
322
|
+
return _decode_response(response.read(), status=getattr(response, "status", None))
|
|
323
|
+
except urlerror.HTTPError as exc:
|
|
324
|
+
api_error = _api_error_from_http_error(exc)
|
|
325
|
+
if not refresh and should_refresh_user_token(api_error):
|
|
326
|
+
return _request_data(
|
|
327
|
+
method,
|
|
328
|
+
_refresh_runtime(runtime),
|
|
329
|
+
path,
|
|
330
|
+
query=query,
|
|
331
|
+
body=body,
|
|
332
|
+
timeout=timeout,
|
|
333
|
+
refresh=True,
|
|
334
|
+
)
|
|
335
|
+
raise api_error from exc
|
|
336
|
+
except urlerror.URLError as exc:
|
|
337
|
+
raise ApiError(f"observability API request failed: {exc}") from exc
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _decode_response(raw: bytes, *, status: int | None = None) -> Any:
|
|
341
|
+
if not raw:
|
|
342
|
+
return None
|
|
343
|
+
try:
|
|
344
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
345
|
+
except json.JSONDecodeError as exc:
|
|
346
|
+
raise ApiError("parse observability API response failed", status=status) from exc
|
|
347
|
+
if not isinstance(payload, dict):
|
|
348
|
+
raise ApiError("observability API response is not an object", status=status, details=payload)
|
|
349
|
+
code = payload.get("code")
|
|
350
|
+
if code not in (None, 0) or payload.get("success") is False:
|
|
351
|
+
raise ApiError(
|
|
352
|
+
str(payload.get("message") or "observability API failed"),
|
|
353
|
+
status=status,
|
|
354
|
+
code=code if isinstance(code, int) else None,
|
|
355
|
+
details=payload,
|
|
356
|
+
)
|
|
357
|
+
return payload.get("data")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _api_error_from_http_error(exc: urlerror.HTTPError) -> ApiError:
|
|
361
|
+
detail = exc.read()
|
|
362
|
+
if detail:
|
|
363
|
+
try:
|
|
364
|
+
payload = json.loads(detail.decode("utf-8"))
|
|
365
|
+
except json.JSONDecodeError:
|
|
366
|
+
payload = None
|
|
367
|
+
if isinstance(payload, dict):
|
|
368
|
+
code = payload.get("code")
|
|
369
|
+
return ApiError(
|
|
370
|
+
str(payload.get("message") or f"observability API returned {exc.code}"),
|
|
371
|
+
status=exc.code,
|
|
372
|
+
code=code if isinstance(code, int) else None,
|
|
373
|
+
details=payload,
|
|
374
|
+
)
|
|
375
|
+
return ApiError(detail.decode("utf-8", errors="replace"), status=exc.code)
|
|
376
|
+
return ApiError(f"observability API returned {exc.code}", status=exc.code)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _split_client_kwargs(kwargs: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
380
|
+
client_keys = {
|
|
381
|
+
"base_url",
|
|
382
|
+
"user_token",
|
|
383
|
+
"user_id",
|
|
384
|
+
"project_id",
|
|
385
|
+
"workspace_id",
|
|
386
|
+
"project_type",
|
|
387
|
+
"deployment_version",
|
|
388
|
+
"hash_version",
|
|
389
|
+
"timeout",
|
|
390
|
+
}
|
|
391
|
+
client_kwargs: dict[str, Any] = {}
|
|
392
|
+
call_kwargs: dict[str, Any] = {}
|
|
393
|
+
for key, value in kwargs.items():
|
|
394
|
+
if key in client_keys:
|
|
395
|
+
client_kwargs[key] = value
|
|
396
|
+
else:
|
|
397
|
+
call_kwargs[key] = value
|
|
398
|
+
return client_kwargs, call_kwargs
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _required(value: str | None, name: str) -> str:
|
|
402
|
+
text = (value or "").strip()
|
|
403
|
+
if not text:
|
|
404
|
+
raise ValueError(f"{name} is required")
|
|
405
|
+
return text
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _compact(value: dict[str, Any]) -> dict[str, Any]:
|
|
409
|
+
return {key: item for key, item in value.items() if item is not None}
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _iso_time(value: datetime | str | None) -> str:
|
|
413
|
+
if value is None:
|
|
414
|
+
value = datetime.now(timezone.utc)
|
|
415
|
+
if isinstance(value, str):
|
|
416
|
+
return value
|
|
417
|
+
if value.tzinfo is None:
|
|
418
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
419
|
+
return value.isoformat().replace("+00:00", "Z")
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _window_start(value: datetime | str | None) -> str:
|
|
423
|
+
if value is not None:
|
|
424
|
+
return _iso_time(value)
|
|
425
|
+
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
|
|
426
|
+
return _iso_time(now)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _normalize_level(level: str) -> str:
|
|
430
|
+
text = (level or "info").strip().lower()
|
|
431
|
+
if text in {"warn", "warning"}:
|
|
432
|
+
return "warning"
|
|
433
|
+
if text in {"err", "error", "fatal"}:
|
|
434
|
+
return "error"
|
|
435
|
+
return "info"
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _to_text(value: Any) -> str:
|
|
439
|
+
if value is None:
|
|
440
|
+
return ""
|
|
441
|
+
if isinstance(value, str):
|
|
442
|
+
return value
|
|
443
|
+
try:
|
|
444
|
+
return json.dumps(value, ensure_ascii=False, default=str)
|
|
445
|
+
except TypeError:
|
|
446
|
+
return str(value)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _error_content(content: Any, error_code: str | None) -> str:
|
|
450
|
+
text = _to_text(content)
|
|
451
|
+
code = (error_code or "").strip()
|
|
452
|
+
return f"[{code}] {text}" if code else text
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _int_or_zero(value: int | float | None) -> int:
|
|
456
|
+
if value is None:
|
|
457
|
+
return 0
|
|
458
|
+
return int(value)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _float_or_zero(value: int | float | None) -> float:
|
|
462
|
+
if value is None:
|
|
463
|
+
return 0.0
|
|
464
|
+
return float(value)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _number_or_none(value: int | float | None) -> int | float | None:
|
|
468
|
+
if value is None:
|
|
469
|
+
return None
|
|
470
|
+
return value
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _number_or_zero(value: int | float | None) -> int | float:
|
|
474
|
+
if value is None:
|
|
475
|
+
return 0
|
|
476
|
+
return value
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _latency_bucket(latency_ms: int | float | None) -> dict[str, int]:
|
|
480
|
+
if latency_ms is None:
|
|
481
|
+
return {}
|
|
482
|
+
latency = float(latency_ms)
|
|
483
|
+
if latency < 100:
|
|
484
|
+
return {"0-100": 1}
|
|
485
|
+
if latency < 500:
|
|
486
|
+
return {"100-500": 1}
|
|
487
|
+
return {"500+": 1}
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _deployment_version(hash_version: str) -> str:
|
|
491
|
+
return (
|
|
492
|
+
env("LICOS_DEPLOYMENT_VERSION")
|
|
493
|
+
or env("DEPLOY_VERSION")
|
|
494
|
+
or env("LICOS_RELEASE_VERSION")
|
|
495
|
+
or hash_version
|
|
496
|
+
or "dev"
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _hash_version() -> str:
|
|
501
|
+
return (
|
|
502
|
+
env("LICOS_DEPLOYMENT_HASH")
|
|
503
|
+
or env("LICOS_DEPLOY_HASH")
|
|
504
|
+
or env("SOURCE_COMMIT")
|
|
505
|
+
or env("GIT_COMMIT")
|
|
506
|
+
or _git_commit_short()
|
|
507
|
+
or env("AGENT_PROJECT_ID")
|
|
508
|
+
or "dev"
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _git_commit_short() -> str | None:
|
|
513
|
+
cwd = os.environ.get("LICOS_PROJECT_PATH") or os.getcwd()
|
|
514
|
+
try:
|
|
515
|
+
result = subprocess.run(
|
|
516
|
+
["git", "rev-parse", "--short", "HEAD"],
|
|
517
|
+
cwd=cwd,
|
|
518
|
+
capture_output=True,
|
|
519
|
+
check=False,
|
|
520
|
+
text=True,
|
|
521
|
+
timeout=1,
|
|
522
|
+
)
|
|
523
|
+
except (OSError, subprocess.SubprocessError):
|
|
524
|
+
return None
|
|
525
|
+
text = result.stdout.strip()
|
|
526
|
+
return text if result.returncode == 0 and text else None
|
|
527
|
+
|
|
@@ -4,10 +4,10 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
import sys
|
|
6
6
|
import unittest
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
from typing import Any
|
|
9
|
-
from unittest import mock
|
|
10
|
-
from urllib import error as urlerror
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
from unittest import mock
|
|
10
|
+
from urllib import error as urlerror
|
|
11
11
|
|
|
12
12
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
13
13
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "licos-platform-sdk" / "src"))
|
|
@@ -16,7 +16,7 @@ from licos_dev_sdk import model
|
|
|
16
16
|
from licos_platform_sdk import _runtime
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
class _FakeResponse:
|
|
19
|
+
class _FakeResponse:
|
|
20
20
|
status = 200
|
|
21
21
|
|
|
22
22
|
def __init__(self, payload: dict[str, Any]) -> None:
|
|
@@ -28,19 +28,19 @@ class _FakeResponse:
|
|
|
28
28
|
def __exit__(self, *_args: Any) -> None:
|
|
29
29
|
return None
|
|
30
30
|
|
|
31
|
-
def read(self) -> bytes:
|
|
32
|
-
return json.dumps(self._payload).encode("utf-8")
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
class _FakeErrorBody:
|
|
36
|
-
def __init__(self, payload: dict[str, Any]) -> None:
|
|
37
|
-
self._payload = payload
|
|
38
|
-
|
|
39
|
-
def read(self) -> bytes:
|
|
40
|
-
return json.dumps(self._payload).encode("utf-8")
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def _catalog_payload() -> dict[str, Any]:
|
|
31
|
+
def read(self) -> bytes:
|
|
32
|
+
return json.dumps(self._payload).encode("utf-8")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _FakeErrorBody:
|
|
36
|
+
def __init__(self, payload: dict[str, Any]) -> None:
|
|
37
|
+
self._payload = payload
|
|
38
|
+
|
|
39
|
+
def read(self) -> bytes:
|
|
40
|
+
return json.dumps(self._payload).encode("utf-8")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _catalog_payload() -> dict[str, Any]:
|
|
44
44
|
return {
|
|
45
45
|
"code": 0,
|
|
46
46
|
"success": True,
|
|
@@ -111,62 +111,62 @@ class ModelSdkTests(unittest.TestCase):
|
|
|
111
111
|
self.assertEqual(result.text, "hello")
|
|
112
112
|
self.assertEqual(captured["exchange_headers"]["Authorization"], "Bearer ai-agent-token")
|
|
113
113
|
self.assertEqual(captured["exchange_body"], {"userId": "user-1"})
|
|
114
|
-
self.assertEqual(captured["catalog_headers"]["Authorization"], "Bearer user-token")
|
|
115
|
-
self.assertEqual(captured["chat_headers"]["Authorization"], "Bearer user-token")
|
|
116
|
-
self.assertEqual(captured["chat_body"]["model"], "chat-text")
|
|
117
|
-
|
|
118
|
-
def test_llm_explicit_model_overrides_catalog_default(self) -> None:
|
|
119
|
-
captured: dict[str, Any] = {}
|
|
120
|
-
|
|
121
|
-
def fake_urlopen(req: Any, timeout: int = 0) -> _FakeResponse:
|
|
122
|
-
if req.full_url == "http://platform.example/api/v1/internal/auth/ai-user-token":
|
|
123
|
-
return _FakeResponse({"code": 0, "success": True, "data": {"accessToken": "user-token"}})
|
|
124
|
-
if req.full_url == "http://platform.example/api/v1/llm-gateway/ai/model-catalog":
|
|
125
|
-
return _FakeResponse(_catalog_payload())
|
|
126
|
-
if req.full_url == "http://gateway.example/v1/chat/completions":
|
|
127
|
-
captured["chat_body"] = json.loads(req.data.decode("utf-8"))
|
|
128
|
-
return _FakeResponse({"choices": [{"message": {"content": "hello"}}]})
|
|
129
|
-
raise AssertionError(req.full_url)
|
|
130
|
-
|
|
131
|
-
with mock.patch.object(model.request, "urlopen", fake_urlopen):
|
|
132
|
-
result = model.LLMClient().invoke("Say hello", model="custom-chat-model")
|
|
133
|
-
|
|
134
|
-
self.assertEqual(result.text, "hello")
|
|
135
|
-
self.assertEqual(captured["chat_body"]["model"], "custom-chat-model")
|
|
136
|
-
|
|
137
|
-
def test_llm_invoke_refreshes_user_token_once_after_unauthorized(self) -> None:
|
|
138
|
-
tokens = iter(["old-token", "new-token"])
|
|
139
|
-
catalog_tokens: list[str] = []
|
|
140
|
-
chat_tokens: list[str] = []
|
|
141
|
-
|
|
142
|
-
def fake_urlopen(req: Any, timeout: int = 0) -> _FakeResponse:
|
|
143
|
-
if req.full_url == "http://platform.example/api/v1/internal/auth/ai-user-token":
|
|
144
|
-
return _FakeResponse({"code": 0, "success": True, "data": {"accessToken": next(tokens)}})
|
|
145
|
-
if req.full_url == "http://platform.example/api/v1/llm-gateway/ai/model-catalog":
|
|
146
|
-
catalog_tokens.append(dict(req.header_items())["Authorization"])
|
|
147
|
-
return _FakeResponse(_catalog_payload())
|
|
148
|
-
if req.full_url == "http://gateway.example/v1/chat/completions":
|
|
149
|
-
chat_tokens.append(dict(req.header_items())["Authorization"])
|
|
150
|
-
if len(chat_tokens) == 1:
|
|
151
|
-
raise urlerror.HTTPError(
|
|
152
|
-
req.full_url,
|
|
153
|
-
401,
|
|
154
|
-
"Unauthorized",
|
|
155
|
-
hdrs=None,
|
|
156
|
-
fp=_FakeErrorBody({"code": 10002, "message": "token invalid or expired", "success": False}),
|
|
157
|
-
)
|
|
158
|
-
return _FakeResponse({"choices": [{"message": {"content": "hello"}}]})
|
|
159
|
-
raise AssertionError(req.full_url)
|
|
160
|
-
|
|
161
|
-
with mock.patch.object(model.request, "urlopen", fake_urlopen):
|
|
162
|
-
result = model.LLMClient().invoke("Say hello", model="auto")
|
|
163
|
-
|
|
164
|
-
self.assertEqual(result.text, "hello")
|
|
165
|
-
self.assertEqual(catalog_tokens, ["Bearer old-token"])
|
|
166
|
-
self.assertEqual(chat_tokens, ["Bearer old-token", "Bearer new-token"])
|
|
167
|
-
|
|
168
|
-
def test_image_generation_defaults_to_one_image(self) -> None:
|
|
169
|
-
captured: dict[str, Any] = {}
|
|
114
|
+
self.assertEqual(captured["catalog_headers"]["Authorization"], "Bearer user-token")
|
|
115
|
+
self.assertEqual(captured["chat_headers"]["Authorization"], "Bearer user-token")
|
|
116
|
+
self.assertEqual(captured["chat_body"]["model"], "chat-text")
|
|
117
|
+
|
|
118
|
+
def test_llm_explicit_model_overrides_catalog_default(self) -> None:
|
|
119
|
+
captured: dict[str, Any] = {}
|
|
120
|
+
|
|
121
|
+
def fake_urlopen(req: Any, timeout: int = 0) -> _FakeResponse:
|
|
122
|
+
if req.full_url == "http://platform.example/api/v1/internal/auth/ai-user-token":
|
|
123
|
+
return _FakeResponse({"code": 0, "success": True, "data": {"accessToken": "user-token"}})
|
|
124
|
+
if req.full_url == "http://platform.example/api/v1/llm-gateway/ai/model-catalog":
|
|
125
|
+
return _FakeResponse(_catalog_payload())
|
|
126
|
+
if req.full_url == "http://gateway.example/v1/chat/completions":
|
|
127
|
+
captured["chat_body"] = json.loads(req.data.decode("utf-8"))
|
|
128
|
+
return _FakeResponse({"choices": [{"message": {"content": "hello"}}]})
|
|
129
|
+
raise AssertionError(req.full_url)
|
|
130
|
+
|
|
131
|
+
with mock.patch.object(model.request, "urlopen", fake_urlopen):
|
|
132
|
+
result = model.LLMClient().invoke("Say hello", model="custom-chat-model")
|
|
133
|
+
|
|
134
|
+
self.assertEqual(result.text, "hello")
|
|
135
|
+
self.assertEqual(captured["chat_body"]["model"], "custom-chat-model")
|
|
136
|
+
|
|
137
|
+
def test_llm_invoke_refreshes_user_token_once_after_unauthorized(self) -> None:
|
|
138
|
+
tokens = iter(["old-token", "new-token"])
|
|
139
|
+
catalog_tokens: list[str] = []
|
|
140
|
+
chat_tokens: list[str] = []
|
|
141
|
+
|
|
142
|
+
def fake_urlopen(req: Any, timeout: int = 0) -> _FakeResponse:
|
|
143
|
+
if req.full_url == "http://platform.example/api/v1/internal/auth/ai-user-token":
|
|
144
|
+
return _FakeResponse({"code": 0, "success": True, "data": {"accessToken": next(tokens)}})
|
|
145
|
+
if req.full_url == "http://platform.example/api/v1/llm-gateway/ai/model-catalog":
|
|
146
|
+
catalog_tokens.append(dict(req.header_items())["Authorization"])
|
|
147
|
+
return _FakeResponse(_catalog_payload())
|
|
148
|
+
if req.full_url == "http://gateway.example/v1/chat/completions":
|
|
149
|
+
chat_tokens.append(dict(req.header_items())["Authorization"])
|
|
150
|
+
if len(chat_tokens) == 1:
|
|
151
|
+
raise urlerror.HTTPError(
|
|
152
|
+
req.full_url,
|
|
153
|
+
401,
|
|
154
|
+
"Unauthorized",
|
|
155
|
+
hdrs=None,
|
|
156
|
+
fp=_FakeErrorBody({"code": 10002, "message": "token invalid or expired", "success": False}),
|
|
157
|
+
)
|
|
158
|
+
return _FakeResponse({"choices": [{"message": {"content": "hello"}}]})
|
|
159
|
+
raise AssertionError(req.full_url)
|
|
160
|
+
|
|
161
|
+
with mock.patch.object(model.request, "urlopen", fake_urlopen):
|
|
162
|
+
result = model.LLMClient().invoke("Say hello", model="auto")
|
|
163
|
+
|
|
164
|
+
self.assertEqual(result.text, "hello")
|
|
165
|
+
self.assertEqual(catalog_tokens, ["Bearer old-token"])
|
|
166
|
+
self.assertEqual(chat_tokens, ["Bearer old-token", "Bearer new-token"])
|
|
167
|
+
|
|
168
|
+
def test_image_generation_defaults_to_one_image(self) -> None:
|
|
169
|
+
captured: dict[str, Any] = {}
|
|
170
170
|
|
|
171
171
|
def fake_urlopen(req: Any, timeout: int = 0) -> _FakeResponse:
|
|
172
172
|
if req.full_url == "http://platform.example/api/v1/internal/auth/ai-user-token":
|
|
@@ -205,6 +205,22 @@ class ModelSdkTests(unittest.TestCase):
|
|
|
205
205
|
self.assertEqual(captured["body"]["messages"][0]["content"][1]["image_url"]["url"], "https://cdn.example/a.png")
|
|
206
206
|
self.assertEqual(result.text, "a blue sky")
|
|
207
207
|
|
|
208
|
+
def test_resolve_vision_endpoint_uses_vision_model_group(self) -> None:
|
|
209
|
+
def fake_urlopen(req: Any, timeout: int = 0) -> _FakeResponse:
|
|
210
|
+
if req.full_url == "http://platform.example/api/v1/internal/auth/ai-user-token":
|
|
211
|
+
return _FakeResponse({"code": 0, "success": True, "data": {"accessToken": "user-token"}})
|
|
212
|
+
if req.full_url == "http://platform.example/api/v1/llm-gateway/ai/model-catalog":
|
|
213
|
+
return _FakeResponse(_catalog_payload())
|
|
214
|
+
raise AssertionError(req.full_url)
|
|
215
|
+
|
|
216
|
+
with mock.patch.object(model.request, "urlopen", fake_urlopen):
|
|
217
|
+
endpoint = model.resolve_vision_endpoint()
|
|
218
|
+
|
|
219
|
+
self.assertEqual(endpoint.capability, "chat")
|
|
220
|
+
self.assertEqual(endpoint.model, "chat-vision")
|
|
221
|
+
self.assertEqual(endpoint.base_url, "http://gateway.example/v1/chat/completions")
|
|
222
|
+
self.assertIs(model.VisionUnderstandingClient, model.VisionClient)
|
|
223
|
+
|
|
208
224
|
|
|
209
225
|
if __name__ == "__main__":
|
|
210
226
|
unittest.main()
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
from unittest import mock
|
|
10
|
+
from urllib import error as urlerror
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "licos-platform-sdk" / "src"))
|
|
14
|
+
|
|
15
|
+
from licos_dev_sdk import observability
|
|
16
|
+
from licos_platform_sdk import _runtime
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _FakeResponse:
|
|
20
|
+
status = 200
|
|
21
|
+
|
|
22
|
+
def __init__(self, payload: dict[str, Any]) -> None:
|
|
23
|
+
self._payload = payload
|
|
24
|
+
|
|
25
|
+
def __enter__(self) -> "_FakeResponse":
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
def __exit__(self, *_args: Any) -> None:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
def read(self) -> bytes:
|
|
32
|
+
return json.dumps(self._payload).encode("utf-8")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _FakeErrorBody:
|
|
36
|
+
def __init__(self, payload: dict[str, Any]) -> None:
|
|
37
|
+
self._payload = payload
|
|
38
|
+
|
|
39
|
+
def read(self) -> bytes:
|
|
40
|
+
return json.dumps(self._payload).encode("utf-8")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ObservabilitySdkTests(unittest.TestCase):
|
|
44
|
+
def setUp(self) -> None:
|
|
45
|
+
self.env = mock.patch.dict(
|
|
46
|
+
os.environ,
|
|
47
|
+
{
|
|
48
|
+
"LICOS_PLATFORM_API_BASE_URL": "http://platform.example/api/v1",
|
|
49
|
+
"AGENT_USER_ID": "user-1",
|
|
50
|
+
"AGENT_WORKSPACE_ID": "workspace-1",
|
|
51
|
+
"AGENT_PROJECT_ID": "project-1",
|
|
52
|
+
"AGENT_PROJECT_TYPE": "AGENT",
|
|
53
|
+
"LICOS_AI_AGENT_TOKEN": "ai-agent-token",
|
|
54
|
+
"LICOS_DEPLOYMENT_VERSION": "v1",
|
|
55
|
+
"LICOS_DEPLOYMENT_HASH": "hash-1",
|
|
56
|
+
},
|
|
57
|
+
clear=True,
|
|
58
|
+
)
|
|
59
|
+
self.env.start()
|
|
60
|
+
self.addCleanup(self.env.stop)
|
|
61
|
+
_runtime._clear_token_cache_for_tests()
|
|
62
|
+
|
|
63
|
+
def test_log_uses_runtime_context_and_owner_token(self) -> None:
|
|
64
|
+
captured: dict[str, Any] = {}
|
|
65
|
+
|
|
66
|
+
def fake_urlopen(req: Any, timeout: int = 0) -> _FakeResponse:
|
|
67
|
+
if req.full_url == "http://platform.example/api/v1/internal/auth/ai-user-token":
|
|
68
|
+
captured["exchange_headers"] = dict(req.header_items())
|
|
69
|
+
captured["exchange_body"] = json.loads(req.data.decode("utf-8"))
|
|
70
|
+
return _FakeResponse({"code": 0, "success": True, "data": {"accessToken": "user-token"}})
|
|
71
|
+
if req.full_url == "http://platform.example/api/v1/studio/observability/logs?projectId=project-1":
|
|
72
|
+
captured["log_headers"] = dict(req.header_items())
|
|
73
|
+
captured["log_body"] = json.loads(req.data.decode("utf-8"))
|
|
74
|
+
return _FakeResponse({"code": 0, "success": True, "data": {"id": 1}})
|
|
75
|
+
raise AssertionError(req.full_url)
|
|
76
|
+
|
|
77
|
+
with mock.patch.object(observability.request, "urlopen", fake_urlopen):
|
|
78
|
+
result = observability.log_info("started", log_time="2026-05-22T10:00:00+08:00")
|
|
79
|
+
|
|
80
|
+
self.assertEqual(result, {"id": 1})
|
|
81
|
+
self.assertEqual(captured["exchange_headers"]["Authorization"], "Bearer ai-agent-token")
|
|
82
|
+
self.assertEqual(captured["exchange_body"], {"userId": "user-1"})
|
|
83
|
+
self.assertEqual(captured["log_headers"]["Authorization"], "Bearer user-token")
|
|
84
|
+
log_headers = {key.lower(): value for key, value in captured["log_headers"].items()}
|
|
85
|
+
self.assertEqual(log_headers["x-workspace-id"], "workspace-1")
|
|
86
|
+
self.assertEqual(captured["log_body"]["deploymentVersion"], "v1")
|
|
87
|
+
self.assertEqual(captured["log_body"]["level"], "info")
|
|
88
|
+
self.assertEqual(captured["log_body"]["logContent"], "started")
|
|
89
|
+
|
|
90
|
+
def test_trace_metric_and_error_payloads(self) -> None:
|
|
91
|
+
calls: list[tuple[str, dict[str, Any]]] = []
|
|
92
|
+
|
|
93
|
+
def fake_urlopen(req: Any, timeout: int = 0) -> _FakeResponse:
|
|
94
|
+
if req.full_url == "http://platform.example/api/v1/internal/auth/ai-user-token":
|
|
95
|
+
return _FakeResponse({"code": 0, "success": True, "data": {"accessToken": "user-token"}})
|
|
96
|
+
calls.append((req.full_url, json.loads(req.data.decode("utf-8"))))
|
|
97
|
+
return _FakeResponse({"code": 0, "success": True, "data": {"ok": True}})
|
|
98
|
+
|
|
99
|
+
with mock.patch.object(observability.request, "urlopen", fake_urlopen):
|
|
100
|
+
client = observability.ObservabilityClient()
|
|
101
|
+
client.record_trace(trace_id="trace-1", input={"q": "hi"}, output="ok", input_tokens=3, output_tokens=4, latency_seconds=1.2)
|
|
102
|
+
client.record_metric(route="/stream_run", method="post", latency_ms=640, status_code=500)
|
|
103
|
+
client.record_error(route="/stream_run", method="post", error_code="MODEL_TIMEOUT", error_message="timeout", trace_id="trace-1")
|
|
104
|
+
|
|
105
|
+
self.assertEqual(calls[0][0], "http://platform.example/api/v1/studio/observability/traces?projectId=project-1")
|
|
106
|
+
self.assertEqual(calls[0][1]["traceId"], "trace-1")
|
|
107
|
+
self.assertEqual(calls[0][1]["hashVersion"], "hash-1")
|
|
108
|
+
self.assertEqual(calls[0][1]["input"], '{"q": "hi"}')
|
|
109
|
+
self.assertEqual(calls[0][1]["inputTokens"], 3)
|
|
110
|
+
|
|
111
|
+
self.assertEqual(calls[1][0], "http://platform.example/api/v1/studio/observability/analysis/metrics?projectId=project-1")
|
|
112
|
+
self.assertEqual(calls[1][1]["workspaceId"], "workspace-1")
|
|
113
|
+
self.assertEqual(calls[1][1]["projectType"], "AGENT")
|
|
114
|
+
self.assertEqual(calls[1][1]["method"], "POST")
|
|
115
|
+
self.assertEqual(calls[1][1]["errorCount"], 1)
|
|
116
|
+
self.assertEqual(calls[1][1]["status5xxCount"], 1)
|
|
117
|
+
self.assertEqual(calls[1][1]["latencyBuckets"], {"500+": 1})
|
|
118
|
+
|
|
119
|
+
self.assertEqual(calls[2][0], "http://platform.example/api/v1/studio/observability/analysis/errors?projectId=project-1")
|
|
120
|
+
self.assertEqual(calls[2][1]["errorCode"], "MODEL_TIMEOUT")
|
|
121
|
+
self.assertEqual(calls[2][1]["traceId"], "trace-1")
|
|
122
|
+
|
|
123
|
+
def test_refreshes_owner_token_once_after_unauthorized(self) -> None:
|
|
124
|
+
tokens = iter(["old-token", "new-token"])
|
|
125
|
+
log_tokens: list[str] = []
|
|
126
|
+
|
|
127
|
+
def fake_urlopen(req: Any, timeout: int = 0) -> _FakeResponse:
|
|
128
|
+
if req.full_url == "http://platform.example/api/v1/internal/auth/ai-user-token":
|
|
129
|
+
return _FakeResponse({"code": 0, "success": True, "data": {"accessToken": next(tokens)}})
|
|
130
|
+
if req.full_url == "http://platform.example/api/v1/studio/observability/logs?projectId=project-1":
|
|
131
|
+
log_tokens.append(dict(req.header_items())["Authorization"])
|
|
132
|
+
if len(log_tokens) == 1:
|
|
133
|
+
raise urlerror.HTTPError(
|
|
134
|
+
req.full_url,
|
|
135
|
+
401,
|
|
136
|
+
"Unauthorized",
|
|
137
|
+
hdrs=None,
|
|
138
|
+
fp=_FakeErrorBody({"code": 10002, "message": "token invalid or expired", "success": False}),
|
|
139
|
+
)
|
|
140
|
+
return _FakeResponse({"code": 0, "success": True, "data": {"id": 1}})
|
|
141
|
+
raise AssertionError(req.full_url)
|
|
142
|
+
|
|
143
|
+
with mock.patch.object(observability.request, "urlopen", fake_urlopen):
|
|
144
|
+
observability.log_error("failed")
|
|
145
|
+
|
|
146
|
+
self.assertEqual(log_tokens, ["Bearer old-token", "Bearer new-token"])
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|