licos-dev-sdk 0.2.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: licos-dev-sdk
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: LICOS Dev SDK - file generation and model capability clients
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: graphviz>=0.20
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "licos-dev-sdk"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "LICOS Dev SDK - file generation and model capability clients"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -59,6 +59,17 @@ def __getattr__(name: str):
59
59
  "generate_video": ("model", "generate_video"),
60
60
  "recognize_speech": ("model", "recognize_speech"),
61
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"),
62
73
  }
63
74
  if name in _map:
64
75
  mod_name, attr = _map[name]
@@ -86,4 +97,8 @@ __all__ = [
86
97
  "resolve_image_generation_endpoint", "resolve_video_generation_endpoint",
87
98
  "resolve_speech_recognition_endpoint",
88
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",
89
104
  ]
@@ -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
+
@@ -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