polaris-python-ai 0.0.1.dev2__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.
polaris_ai/log.py ADDED
@@ -0,0 +1,408 @@
1
+ # Tencent is pleased to support the open source community by making polaris-python-ai available.
2
+ #
3
+ # Copyright (C) 2026 Tencent. All rights reserved.
4
+ #
5
+ # Licensed under the BSD 3-Clause License (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://opensource.org/licenses/BSD-3-Clause
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ """Optional Polaris-managed logging for AI adapters."""
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import logging
23
+ import os
24
+ import time
25
+ from contextlib import contextmanager
26
+ from contextvars import ContextVar
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+ from threading import RLock
30
+
31
+ from polaris.api._log_file import (
32
+ DEFAULT_BACKUP_COUNT,
33
+ DEFAULT_MAX_AGE_DAYS,
34
+ DEFAULT_MAX_BYTES,
35
+ ExclusiveRotatingFileHandler,
36
+ LogRetention,
37
+ reserve_log_files,
38
+ )
39
+
40
+ from polaris_ai._async import await_completion
41
+
42
+ LOG_CATEGORIES = ("base", "agentscope", "skill", "a2a", "mcp", "langgraph", "metrics")
43
+ DEFAULT_AI_LOG_DIR = "./polaris/log/ai"
44
+ DEFAULT_WARNING_THROTTLE_SECONDS = 30.0
45
+
46
+ _LEVELS = {
47
+ "debug": logging.DEBUG,
48
+ "info": logging.INFO,
49
+ "warn": logging.WARN,
50
+ "warning": logging.WARNING,
51
+ "error": logging.ERROR,
52
+ }
53
+ _bound_logger: ContextVar["PolarisAILogger | None"] = ContextVar(
54
+ "polaris_ai_logger", default=None,
55
+ )
56
+ _agentscope_owner_lock = RLock()
57
+ _agentscope_owner: "PolarisAILogger | None" = None
58
+ @dataclass(frozen=True)
59
+ class PolarisAILogOptions:
60
+ """Code-first options for Polaris AI extension logs.
61
+
62
+ Passing options enables Polaris-managed files. ``None`` leaves logging to
63
+ the host application's Python logging configuration.
64
+ """
65
+
66
+ level: str | None = None
67
+ file_path: str | Path | None = None
68
+ log_dir: str | Path | None = None
69
+ max_bytes: int = DEFAULT_MAX_BYTES
70
+ backup_count: int = DEFAULT_BACKUP_COUNT
71
+ max_age_days: int = DEFAULT_MAX_AGE_DAYS
72
+ capture_agentscope: bool | None = None
73
+ warning_throttle_seconds: float = DEFAULT_WARNING_THROTTLE_SECONDS
74
+
75
+
76
+ def _boolean(value: str | None, default: bool) -> bool:
77
+ if value is None or not value.strip():
78
+ return default
79
+ lowered = value.strip().lower()
80
+ if lowered in ("1", "true", "yes", "on"):
81
+ return True
82
+ if lowered in ("0", "false", "no", "off"):
83
+ return False
84
+ raise ValueError(f"Invalid boolean value: {value!r}")
85
+
86
+
87
+ def _validate_category(category: str):
88
+ if category not in LOG_CATEGORIES:
89
+ raise ValueError(f"Unknown AI log category: {category}")
90
+
91
+
92
+ def _resolved_level(options: PolarisAILogOptions) -> tuple[str, int]:
93
+ level = options.level
94
+ if level is None:
95
+ level = os.getenv("POLARIS_AI_LOG_LEVEL", "info")
96
+ if not isinstance(level, str) or level.lower() not in _LEVELS:
97
+ raise ValueError(f"Invalid AI log level: {level!r}")
98
+ level = level.lower()
99
+ return level, _LEVELS[level]
100
+
101
+
102
+ def _resolve_paths(
103
+ options: PolarisAILogOptions,
104
+ client_id: str,
105
+ ) -> tuple[dict[str, Path], tuple[Path, ...]]:
106
+ file_path = options.file_path
107
+ log_dir = options.log_dir
108
+ if file_path is not None and log_dir is not None:
109
+ raise ValueError("Specify either log_dir or file_path")
110
+
111
+ if file_path is None and log_dir is None:
112
+ configured = os.getenv("POLARIS_AI_LOG_PATH")
113
+ if configured:
114
+ candidate = Path(configured).expanduser()
115
+ if (
116
+ candidate.is_dir()
117
+ or not candidate.suffix
118
+ or configured.endswith(("/", "\\"))
119
+ ):
120
+ log_dir = candidate
121
+ else:
122
+ file_path = candidate
123
+ else:
124
+ log_dir = DEFAULT_AI_LOG_DIR
125
+
126
+ if log_dir is not None:
127
+ root = Path(log_dir).expanduser().resolve()
128
+ paths = {
129
+ category: (
130
+ root / category / f"polaris-ai-{category}-{client_id}.log"
131
+ ).resolve()
132
+ for category in LOG_CATEGORIES
133
+ }
134
+ families = tuple(
135
+ (root / category / f"polaris-ai-{category}.log").resolve()
136
+ for category in LOG_CATEGORIES
137
+ )
138
+ return paths, families
139
+
140
+ base = Path(file_path).expanduser().resolve()
141
+ root = base.parent
142
+ stem = base.stem
143
+ suffix = base.suffix or ".log"
144
+ paths = {
145
+ category: (root / category / f"{stem}-{category}{suffix}").resolve()
146
+ for category in LOG_CATEGORIES
147
+ }
148
+ paths["base"] = base
149
+ return paths, ()
150
+
151
+
152
+ class PolarisAILogger:
153
+ """Own the managed files and handlers for one AI runtime context."""
154
+
155
+ def __init__(
156
+ self,
157
+ client_id: str,
158
+ options: PolarisAILogOptions | None = None,
159
+ ):
160
+ if not isinstance(client_id, str) or not client_id.strip():
161
+ raise ValueError("A non-empty client_id is required")
162
+ if options is None:
163
+ options = PolarisAILogOptions()
164
+ if not isinstance(options, PolarisAILogOptions):
165
+ raise TypeError("options must be PolarisAILogOptions")
166
+ self.client_id = client_id
167
+ self.options = options
168
+ self.level, self._level = _resolved_level(options)
169
+ if (
170
+ type(options.max_bytes) is not int
171
+ or options.max_bytes <= 0
172
+ or type(options.backup_count) is not int
173
+ or options.backup_count < 0
174
+ or type(options.max_age_days) is not int
175
+ or options.max_age_days < 0
176
+ or isinstance(options.warning_throttle_seconds, bool)
177
+ or not isinstance(options.warning_throttle_seconds, (int, float))
178
+ or options.warning_throttle_seconds < 0
179
+ ):
180
+ raise ValueError("Invalid AI log rotation options")
181
+ self._paths, self._family_paths = _resolve_paths(options, self.client_id)
182
+ self._loggers: dict[str, logging.Logger] = {}
183
+ self._handlers: dict[str, logging.Handler] = {}
184
+ self._agentscope_handler: logging.Handler | None = None
185
+ self._lock = RLock()
186
+ self._closed = False
187
+ try:
188
+ self._locks = reserve_log_files(self._paths.values())
189
+ self._retention = LogRetention(
190
+ self._locks,
191
+ options.max_age_days,
192
+ options.backup_count,
193
+ self._family_paths,
194
+ )
195
+ capture = (
196
+ options.capture_agentscope
197
+ if options.capture_agentscope is not None
198
+ else _boolean(os.getenv("POLARIS_AI_LOG_CAPTURE_AGENTSCOPE"), False)
199
+ )
200
+ if capture:
201
+ self._attach_agentscope()
202
+ self._retention.start()
203
+ except BaseException:
204
+ self.close()
205
+ raise
206
+
207
+ def _create_category(self, category: str):
208
+ path = self._paths[category]
209
+ path.parent.mkdir(parents=True, exist_ok=True)
210
+ handler = ExclusiveRotatingFileHandler(
211
+ path,
212
+ "a",
213
+ self.options.max_bytes,
214
+ self.options.backup_count,
215
+ encoding="utf-8",
216
+ max_age_days=self.options.max_age_days,
217
+ )
218
+ handler.setLevel(self._level)
219
+ handler.setFormatter(logging.Formatter(
220
+ f"[%(asctime)s][%(levelname)s][%(process)d]"
221
+ f"[clientId={self.client_id}][%(name)s]: %(message)s",
222
+ ))
223
+ logger = logging.Logger(
224
+ f"polaris_ai.{self.client_id}.{category}",
225
+ level=self._level,
226
+ )
227
+ logger.addHandler(handler)
228
+ logger.propagate = False
229
+ with self._retention.lock:
230
+ self._retention.add_handler(handler)
231
+ self._handlers[category] = handler
232
+ self._loggers[category] = logger
233
+ return logger
234
+
235
+ def _logger_for(self, category):
236
+ _validate_category(category)
237
+ if self._closed:
238
+ raise RuntimeError("PolarisAI logger is closed")
239
+ with self._lock:
240
+ if category not in self._loggers:
241
+ return self._create_category(category)
242
+ return self._loggers[category]
243
+
244
+ def _attach_agentscope(self):
245
+ global _agentscope_owner
246
+ with _agentscope_owner_lock:
247
+ if _agentscope_owner is not None and _agentscope_owner is not self:
248
+ raise ValueError("AgentScope logging is already captured by another AI runtime")
249
+ handler = self._create_category("agentscope").handlers[0]
250
+ agentscope_logger = logging.getLogger("as")
251
+ if handler not in agentscope_logger.handlers:
252
+ agentscope_logger.addHandler(handler)
253
+ self._agentscope_handler = handler
254
+ _agentscope_owner = self
255
+
256
+ def get_logger(self, category="base"):
257
+ """Return a lazy category logger for this runtime."""
258
+ _validate_category(category)
259
+ return _CategoryLogger(
260
+ self,
261
+ category,
262
+ self.options.warning_throttle_seconds,
263
+ )
264
+
265
+ def close(self):
266
+ """Close owned handlers and release every reserved path."""
267
+ global _agentscope_owner
268
+ with self._lock:
269
+ if self._closed:
270
+ return
271
+ self._closed = True
272
+ try:
273
+ with _agentscope_owner_lock:
274
+ if self._agentscope_handler is not None:
275
+ logging.getLogger("as").removeHandler(self._agentscope_handler)
276
+ self._agentscope_handler = None
277
+ if _agentscope_owner is self:
278
+ _agentscope_owner = None
279
+ finally:
280
+ try:
281
+ if hasattr(self, "_retention"):
282
+ self._retention.close()
283
+ finally:
284
+ for category, logger in self._loggers.items():
285
+ handler = self._handlers.get(category)
286
+ if handler is not None:
287
+ logger.removeHandler(handler)
288
+ handler.close()
289
+ self._handlers.clear()
290
+ self._loggers.clear()
291
+ for lock in getattr(self, "_locks", {}).values():
292
+ lock.close()
293
+
294
+ async def async_close(self):
295
+ await await_completion(asyncio.to_thread(self.close))
296
+
297
+ def __enter__(self):
298
+ return self
299
+
300
+ def __exit__(self, *_):
301
+ self.close()
302
+
303
+
304
+ class _ClosedLogger:
305
+ """Swallow late diagnostics after a managed runtime has closed."""
306
+
307
+ @staticmethod
308
+ def debug(*_args, **_kwargs):
309
+ return None
310
+
311
+ @staticmethod
312
+ def info(*_args, **_kwargs):
313
+ return None
314
+
315
+ @staticmethod
316
+ def warning(*_args, **_kwargs):
317
+ return None
318
+
319
+ @staticmethod
320
+ def error(*_args, **_kwargs):
321
+ return None
322
+
323
+
324
+ _CLOSED_LOGGER = _ClosedLogger()
325
+
326
+
327
+ class _CategoryLogger:
328
+ """Resolve a category logger at emission time without changing std logging."""
329
+
330
+ def __init__(
331
+ self,
332
+ manager: PolarisAILogger | None,
333
+ category: str,
334
+ warning_throttle_seconds: float = DEFAULT_WARNING_THROTTLE_SECONDS,
335
+ ):
336
+ _validate_category(category)
337
+ self._manager = manager
338
+ self.category = category
339
+ self._warning_interval = warning_throttle_seconds
340
+ self._warning_lock = RLock()
341
+ self._warning_state: dict[object, tuple[float, int]] = {}
342
+
343
+ def _target(self):
344
+ manager = self._manager
345
+ if manager is not None:
346
+ try:
347
+ return manager._logger_for(self.category)
348
+ except RuntimeError:
349
+ if manager._closed:
350
+ return _CLOSED_LOGGER
351
+ raise
352
+ bound = _bound_logger.get()
353
+ if bound is not None:
354
+ try:
355
+ return bound._logger_for(self.category)
356
+ except RuntimeError:
357
+ if bound._closed:
358
+ return _CLOSED_LOGGER
359
+ raise
360
+ return logging.getLogger(f"polaris_ai.{self.category}")
361
+
362
+ def debug(self, msg, *args, **kwargs):
363
+ self._target().debug(msg, *args, **kwargs)
364
+
365
+ def info(self, msg, *args, **kwargs):
366
+ self._target().info(msg, *args, **kwargs)
367
+
368
+ def warning(self, msg, *args, **kwargs):
369
+ self._target().warning(msg, *args, **kwargs)
370
+
371
+ def warning_throttled(self, key, msg, *args, **kwargs):
372
+ """Emit a warning at most once per interval and report suppressed count."""
373
+ now = time.monotonic()
374
+ with self._warning_lock:
375
+ last, suppressed = self._warning_state.get(key, (0.0, 0))
376
+ if last and now - last < self._warning_interval:
377
+ self._warning_state[key] = (last, suppressed + 1)
378
+ return
379
+ self._warning_state[key] = (now, 0)
380
+ if suppressed:
381
+ msg = f"{msg} [suppressed {suppressed} similar warnings]"
382
+ self._target().warning(msg, *args, **kwargs)
383
+
384
+ def error(self, msg, *args, **kwargs):
385
+ self._target().error(msg, *args, **kwargs)
386
+
387
+
388
+ def get_ai_logger(category="base"):
389
+ """Return a category logger that uses managed AI logging when configured."""
390
+ return _CategoryLogger(None, category)
391
+
392
+
393
+ def configure_ai_logging(
394
+ client_id: str,
395
+ options: PolarisAILogOptions | None = None,
396
+ ) -> PolarisAILogger:
397
+ """Create an owned AI log manager for a runtime or application."""
398
+ return PolarisAILogger(client_id, options)
399
+
400
+
401
+ @contextmanager
402
+ def use_ai_logger(instance: PolarisAILogger | None):
403
+ """Bind a managed logger to the current async task or thread."""
404
+ token = _bound_logger.set(instance)
405
+ try:
406
+ yield
407
+ finally:
408
+ _bound_logger.reset(token)
polaris_ai/metrics.py ADDED
@@ -0,0 +1,154 @@
1
+ # Tencent is pleased to support the open source community by making polaris-python-ai available.
2
+ #
3
+ # Copyright (C) 2026 Tencent. All rights reserved.
4
+ #
5
+ # Licensed under the BSD 3-Clause License (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://opensource.org/licenses/BSD-3-Clause
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software distributed
12
+ # under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
13
+ # CONDITIONS OF ANY KIND, either express or implied. See the License for the
14
+ # specific language governing permissions and limitations under the License.
15
+
16
+ """Framework-independent call result reporting for AI adapters."""
17
+
18
+ import time
19
+
20
+ from polaris.pkg.model.service import (
21
+ RetStatus,
22
+ ServiceCallResult,
23
+ ServiceInfo,
24
+ )
25
+
26
+ from polaris_ai.log import get_ai_logger
27
+
28
+
29
+ class PolarisCallReporter:
30
+ """Report one logical AI call without changing its business result."""
31
+
32
+ def __init__(
33
+ self,
34
+ consumer,
35
+ namespace: str,
36
+ *,
37
+ caller_service: str = "",
38
+ labels: str = "",
39
+ caller_ip: str = "",
40
+ logger=None,
41
+ ):
42
+ if consumer is None:
43
+ raise ValueError("A ConsumerAPI is required")
44
+ self.consumer = consumer
45
+ self.namespace = namespace.strip() or "default"
46
+ self.caller_service = caller_service.strip()
47
+ self.labels = labels
48
+ self.caller_ip = caller_ip
49
+ self.logger = logger if logger is not None else get_ai_logger("metrics")
50
+
51
+ def _build_result(
52
+ self,
53
+ *,
54
+ service: str,
55
+ host: str,
56
+ port: int,
57
+ method: str,
58
+ instance_type: str,
59
+ started_at: float,
60
+ success: bool,
61
+ ret_code: int | None = None,
62
+ timeout: bool = False,
63
+ ):
64
+ if not service or not host:
65
+ return None
66
+ status = RetStatus.RetSuccess if success else (
67
+ RetStatus.RetTimeout if timeout else RetStatus.RetFail
68
+ )
69
+ return ServiceCallResult(
70
+ ret_status=status,
71
+ ret_code=ret_code,
72
+ delay=max(0, int((time.monotonic() - started_at) * 1000)),
73
+ method=method,
74
+ caller_service=(
75
+ ServiceInfo(namespace=self.namespace, service=self.caller_service)
76
+ if self.caller_service else None
77
+ ),
78
+ caller_ip=self.caller_ip or None,
79
+ labels=self.labels or None,
80
+ instance_type=instance_type,
81
+ namespace=self.namespace,
82
+ service=service,
83
+ host=host,
84
+ port=port,
85
+ )
86
+
87
+ async def report(
88
+ self,
89
+ *,
90
+ service: str,
91
+ host: str,
92
+ port: int,
93
+ method: str,
94
+ instance_type: str,
95
+ started_at: float,
96
+ success: bool,
97
+ ret_code: int | None = None,
98
+ timeout: bool = False,
99
+ ):
100
+ result = self._build_result(
101
+ service=service,
102
+ host=host,
103
+ port=port,
104
+ method=method,
105
+ instance_type=instance_type,
106
+ started_at=started_at,
107
+ success=success,
108
+ ret_code=ret_code,
109
+ timeout=timeout,
110
+ )
111
+ if result is None:
112
+ return
113
+ try:
114
+ await self.consumer.async_update_service_call_result(result)
115
+ except Exception as error:
116
+ self.logger.warning_throttled(
117
+ ("report", instance_type, service),
118
+ "Failed to report %s call for %s: %s", instance_type, service, error,
119
+ )
120
+
121
+ def report_sync(
122
+ self,
123
+ *,
124
+ service: str,
125
+ host: str,
126
+ port: int,
127
+ method: str,
128
+ instance_type: str,
129
+ started_at: float,
130
+ success: bool,
131
+ ret_code: int | None = None,
132
+ timeout: bool = False,
133
+ ):
134
+ """Synchronously report one synchronous framework call."""
135
+ result = self._build_result(
136
+ service=service,
137
+ host=host,
138
+ port=port,
139
+ method=method,
140
+ instance_type=instance_type,
141
+ started_at=started_at,
142
+ success=success,
143
+ ret_code=ret_code,
144
+ timeout=timeout,
145
+ )
146
+ if result is None:
147
+ return
148
+ try:
149
+ self.consumer.sync_update_service_call_result(result)
150
+ except Exception as error:
151
+ self.logger.warning_throttled(
152
+ ("report-sync", instance_type, service),
153
+ "Failed to report %s call for %s: %s", instance_type, service, error,
154
+ )