iac-code 0.1.0__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.
- iac_code/__init__.py +2 -0
- iac_code/acp/__init__.py +97 -0
- iac_code/acp/convert.py +423 -0
- iac_code/acp/http_sse.py +448 -0
- iac_code/acp/mcp.py +54 -0
- iac_code/acp/metrics.py +71 -0
- iac_code/acp/server.py +662 -0
- iac_code/acp/session.py +446 -0
- iac_code/acp/slash_registry.py +125 -0
- iac_code/acp/state.py +99 -0
- iac_code/acp/tools.py +112 -0
- iac_code/acp/types.py +13 -0
- iac_code/acp/version.py +26 -0
- iac_code/agent/__init__.py +19 -0
- iac_code/agent/agent_loop.py +640 -0
- iac_code/agent/agent_tool.py +269 -0
- iac_code/agent/agent_types.py +87 -0
- iac_code/agent/message.py +153 -0
- iac_code/agent/system_prompt.py +313 -0
- iac_code/cli/__init__.py +3 -0
- iac_code/cli/headless.py +114 -0
- iac_code/cli/main.py +246 -0
- iac_code/cli/output_formats.py +125 -0
- iac_code/commands/__init__.py +93 -0
- iac_code/commands/auth.py +1055 -0
- iac_code/commands/clear.py +34 -0
- iac_code/commands/compact.py +43 -0
- iac_code/commands/debug.py +45 -0
- iac_code/commands/effort.py +116 -0
- iac_code/commands/exit.py +10 -0
- iac_code/commands/help.py +49 -0
- iac_code/commands/model.py +130 -0
- iac_code/commands/registry.py +245 -0
- iac_code/commands/resume.py +49 -0
- iac_code/commands/tasks.py +41 -0
- iac_code/config.py +304 -0
- iac_code/i18n/__init__.py +141 -0
- iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +1355 -0
- iac_code/memory/__init__.py +1 -0
- iac_code/memory/memory_manager.py +92 -0
- iac_code/memory/memory_tools.py +88 -0
- iac_code/providers/__init__.py +1 -0
- iac_code/providers/anthropic_provider.py +284 -0
- iac_code/providers/base.py +128 -0
- iac_code/providers/dashscope_provider.py +47 -0
- iac_code/providers/deepseek_provider.py +36 -0
- iac_code/providers/manager.py +399 -0
- iac_code/providers/openai_provider.py +344 -0
- iac_code/providers/retry.py +58 -0
- iac_code/providers/stream_watchdog.py +47 -0
- iac_code/providers/thinking.py +164 -0
- iac_code/services/__init__.py +1 -0
- iac_code/services/agent_factory.py +127 -0
- iac_code/services/cloud_credentials.py +22 -0
- iac_code/services/context_manager.py +221 -0
- iac_code/services/providers/__init__.py +1 -0
- iac_code/services/providers/aliyun.py +232 -0
- iac_code/services/session_index.py +281 -0
- iac_code/services/session_storage.py +245 -0
- iac_code/services/telemetry/__init__.py +66 -0
- iac_code/services/telemetry/attributes.py +84 -0
- iac_code/services/telemetry/client.py +330 -0
- iac_code/services/telemetry/config.py +76 -0
- iac_code/services/telemetry/constants.py +75 -0
- iac_code/services/telemetry/content_serializer.py +124 -0
- iac_code/services/telemetry/events.py +42 -0
- iac_code/services/telemetry/fallback.py +59 -0
- iac_code/services/telemetry/identity.py +73 -0
- iac_code/services/telemetry/metrics.py +62 -0
- iac_code/services/telemetry/names.py +199 -0
- iac_code/services/telemetry/sanitize.py +88 -0
- iac_code/services/telemetry/sink.py +67 -0
- iac_code/services/telemetry/tracing.py +38 -0
- iac_code/services/telemetry/types.py +13 -0
- iac_code/services/token_budget.py +54 -0
- iac_code/services/token_counter.py +76 -0
- iac_code/skills/__init__.py +1 -0
- iac_code/skills/bundled/__init__.py +94 -0
- iac_code/skills/bundled/iac_aliyun/SKILL.md +192 -0
- iac_code/skills/bundled/iac_aliyun/__init__.py +16 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md +167 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/oss.md +69 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/rds.md +95 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/redis.md +100 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/slb.md +60 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/vpc.md +54 -0
- iac_code/skills/bundled/iac_aliyun/references/ros-template.md +155 -0
- iac_code/skills/bundled/iac_aliyun/references/template-parameters.md +206 -0
- iac_code/skills/bundled/iac_aliyun/references/terraform-template.md +101 -0
- iac_code/skills/bundled/iac_aliyun/scripts/tf2ros.py +77 -0
- iac_code/skills/bundled/simplify.py +28 -0
- iac_code/skills/discovery.py +136 -0
- iac_code/skills/frontmatter.py +119 -0
- iac_code/skills/listing.py +92 -0
- iac_code/skills/loader.py +42 -0
- iac_code/skills/processor.py +81 -0
- iac_code/skills/renderer.py +157 -0
- iac_code/skills/skill_definition.py +82 -0
- iac_code/skills/skill_tool.py +261 -0
- iac_code/state/__init__.py +5 -0
- iac_code/state/app_state.py +122 -0
- iac_code/tasks/__init__.py +1 -0
- iac_code/tasks/notification_queue.py +28 -0
- iac_code/tasks/task_state.py +66 -0
- iac_code/tasks/task_tools.py +114 -0
- iac_code/tools/__init__.py +8 -0
- iac_code/tools/base.py +226 -0
- iac_code/tools/bash.py +133 -0
- iac_code/tools/cloud/__init__.py +0 -0
- iac_code/tools/cloud/aliyun/__init__.py +0 -0
- iac_code/tools/cloud/aliyun/aliyun_api.py +510 -0
- iac_code/tools/cloud/aliyun/aliyun_doc_search.py +145 -0
- iac_code/tools/cloud/aliyun/endpoints.yml +343 -0
- iac_code/tools/cloud/aliyun/ros_client.py +56 -0
- iac_code/tools/cloud/aliyun/ros_stack.py +633 -0
- iac_code/tools/cloud/aliyun/ros_stack_instances.py +247 -0
- iac_code/tools/cloud/base_api.py +162 -0
- iac_code/tools/cloud/base_stack.py +242 -0
- iac_code/tools/cloud/registry.py +20 -0
- iac_code/tools/cloud/types.py +105 -0
- iac_code/tools/edit_file.py +121 -0
- iac_code/tools/glob.py +103 -0
- iac_code/tools/grep.py +254 -0
- iac_code/tools/list_files.py +104 -0
- iac_code/tools/read_file.py +127 -0
- iac_code/tools/result_storage.py +39 -0
- iac_code/tools/tool_executor.py +165 -0
- iac_code/tools/web_fetch.py +177 -0
- iac_code/tools/write_file.py +88 -0
- iac_code/types/__init__.py +40 -0
- iac_code/types/permissions.py +26 -0
- iac_code/types/skill_source.py +11 -0
- iac_code/types/stream_events.py +227 -0
- iac_code/ui/__init__.py +5 -0
- iac_code/ui/banner.py +110 -0
- iac_code/ui/components/__init__.py +0 -0
- iac_code/ui/components/dialog.py +142 -0
- iac_code/ui/components/divider.py +20 -0
- iac_code/ui/components/fuzzy_picker.py +308 -0
- iac_code/ui/components/progress_bar.py +54 -0
- iac_code/ui/components/search_box.py +165 -0
- iac_code/ui/components/select.py +319 -0
- iac_code/ui/components/status_icon.py +42 -0
- iac_code/ui/components/tabs.py +128 -0
- iac_code/ui/core/__init__.py +0 -0
- iac_code/ui/core/in_place_render.py +129 -0
- iac_code/ui/core/input_history.py +118 -0
- iac_code/ui/core/key_event.py +41 -0
- iac_code/ui/core/prompt_input.py +507 -0
- iac_code/ui/core/raw_input.py +302 -0
- iac_code/ui/core/screen.py +80 -0
- iac_code/ui/dialogs/__init__.py +0 -0
- iac_code/ui/dialogs/global_search.py +178 -0
- iac_code/ui/dialogs/history_search.py +100 -0
- iac_code/ui/dialogs/model_picker.py +280 -0
- iac_code/ui/dialogs/quick_open.py +108 -0
- iac_code/ui/dialogs/resume_picker.py +749 -0
- iac_code/ui/keybindings/__init__.py +0 -0
- iac_code/ui/keybindings/manager.py +124 -0
- iac_code/ui/renderer.py +1535 -0
- iac_code/ui/repl.py +772 -0
- iac_code/ui/spinner.py +112 -0
- iac_code/ui/suggestions/__init__.py +0 -0
- iac_code/ui/suggestions/aggregator.py +171 -0
- iac_code/ui/suggestions/command_provider.py +43 -0
- iac_code/ui/suggestions/directory_provider.py +95 -0
- iac_code/ui/suggestions/file_provider.py +121 -0
- iac_code/ui/suggestions/shell_history_provider.py +108 -0
- iac_code/ui/suggestions/token_extractor.py +77 -0
- iac_code/ui/suggestions/types.py +45 -0
- iac_code/ui/transcript_view.py +199 -0
- iac_code/utils/__init__.py +0 -0
- iac_code/utils/background_housekeeping.py +53 -0
- iac_code/utils/cleanup.py +68 -0
- iac_code/utils/json_utils.py +60 -0
- iac_code/utils/log.py +150 -0
- iac_code/utils/project_paths.py +74 -0
- iac_code/utils/tool_input_parser.py +62 -0
- iac_code-0.1.0.dist-info/LICENSE +201 -0
- iac_code-0.1.0.dist-info/METADATA +64 -0
- iac_code-0.1.0.dist-info/RECORD +184 -0
- iac_code-0.1.0.dist-info/WHEEL +5 -0
- iac_code-0.1.0.dist-info/entry_points.txt +2 -0
- iac_code-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""FallbackStore — on-disk persistence of failed event batches."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Iterable, Iterator
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
_FAILED_PREFIX = "failed_events."
|
|
11
|
+
_FAILED_SUFFIX = ".jsonl"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FallbackStore:
|
|
15
|
+
"""JSONL-backed store for event batches that failed default-backend export."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, base_dir: Path) -> None:
|
|
18
|
+
self._base_dir = base_dir
|
|
19
|
+
|
|
20
|
+
def _ensure_dir(self) -> Path:
|
|
21
|
+
self._base_dir.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
return self._base_dir
|
|
23
|
+
|
|
24
|
+
def write(self, session_id: str, events: Iterable[dict]) -> Path:
|
|
25
|
+
"""Write one JSONL file per batch. One line per event."""
|
|
26
|
+
batch_uuid = uuid.uuid4().hex[:12]
|
|
27
|
+
path = self._ensure_dir() / f"{_FAILED_PREFIX}{session_id}.{batch_uuid}{_FAILED_SUFFIX}"
|
|
28
|
+
with path.open("w", encoding="utf-8") as fh:
|
|
29
|
+
for event in events:
|
|
30
|
+
fh.write(json.dumps(event, ensure_ascii=False) + "\n")
|
|
31
|
+
return path
|
|
32
|
+
|
|
33
|
+
def list_pending(self) -> Iterator[Path]:
|
|
34
|
+
"""Yield every failed-batch file currently on disk."""
|
|
35
|
+
if not self._base_dir.exists():
|
|
36
|
+
return
|
|
37
|
+
for p in self._base_dir.iterdir():
|
|
38
|
+
if p.is_file() and p.name.startswith(_FAILED_PREFIX) and p.suffix == _FAILED_SUFFIX:
|
|
39
|
+
yield p
|
|
40
|
+
|
|
41
|
+
def remove(self, path: Path) -> None:
|
|
42
|
+
"""Delete a batch file after successful re-export. Silent on missing."""
|
|
43
|
+
try:
|
|
44
|
+
path.unlink()
|
|
45
|
+
except FileNotFoundError:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
def read(self, path: Path) -> list[dict]:
|
|
49
|
+
"""Parse a JSONL batch file. Unparseable lines skipped."""
|
|
50
|
+
out: list[dict] = []
|
|
51
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
52
|
+
line = line.strip()
|
|
53
|
+
if not line:
|
|
54
|
+
continue
|
|
55
|
+
try:
|
|
56
|
+
out.append(json.loads(line))
|
|
57
|
+
except json.JSONDecodeError:
|
|
58
|
+
continue
|
|
59
|
+
return out
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Identity generation for telemetry.
|
|
2
|
+
|
|
3
|
+
user.id = iac_user_<uuid4>, persisted to a settings.yml path
|
|
4
|
+
session.id = iac_sess_<uuid4>, per Identity instance (per process)
|
|
5
|
+
tenant.id = iac_tenant_<user-defined>, from IAC_CODE_TENANT_ID
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import uuid
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from iac_code.config import _load_yaml, _save_yaml
|
|
15
|
+
|
|
16
|
+
USER_ID_PREFIX = "iac_user_"
|
|
17
|
+
SESSION_ID_PREFIX = "iac_sess_"
|
|
18
|
+
TENANT_ID_PREFIX = "iac_tenant_"
|
|
19
|
+
|
|
20
|
+
_USER_ID_KEY = "userID"
|
|
21
|
+
_TENANT_ENV_VAR = "IAC_CODE_TENANT_ID"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Identity:
|
|
25
|
+
"""Owns user.id / session.id / tenant.id.
|
|
26
|
+
|
|
27
|
+
`settings_path` is injected so tests can pass a tmp path instead of the
|
|
28
|
+
real ~/.iac-code/settings.yml.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, settings_path: Path, session_id: str | None = None) -> None:
|
|
32
|
+
self._settings_path = settings_path
|
|
33
|
+
self._user_id: str | None = None
|
|
34
|
+
self._session_id: str | None = f"{SESSION_ID_PREFIX}{session_id}" if session_id else None
|
|
35
|
+
self._was_first_run = False
|
|
36
|
+
|
|
37
|
+
def get_user_id(self) -> str:
|
|
38
|
+
"""Return the persistent user.id; generate + persist on first miss."""
|
|
39
|
+
if self._user_id is not None:
|
|
40
|
+
return self._user_id
|
|
41
|
+
settings = _load_yaml(self._settings_path)
|
|
42
|
+
existing = settings.get(_USER_ID_KEY)
|
|
43
|
+
if isinstance(existing, str) and existing.startswith(USER_ID_PREFIX):
|
|
44
|
+
self._user_id = existing
|
|
45
|
+
return existing
|
|
46
|
+
new_id = f"{USER_ID_PREFIX}{uuid.uuid4()}"
|
|
47
|
+
settings[_USER_ID_KEY] = new_id
|
|
48
|
+
_save_yaml(self._settings_path, settings)
|
|
49
|
+
self._user_id = new_id
|
|
50
|
+
self._was_first_run = True
|
|
51
|
+
return new_id
|
|
52
|
+
|
|
53
|
+
def get_session_id(self) -> str:
|
|
54
|
+
"""Return per-instance session.id; generate on first call."""
|
|
55
|
+
if self._session_id is None:
|
|
56
|
+
self._session_id = f"{SESSION_ID_PREFIX}{uuid.uuid4()}"
|
|
57
|
+
return self._session_id
|
|
58
|
+
|
|
59
|
+
def get_tenant_id(self) -> str | None:
|
|
60
|
+
"""Return tenant.id if IAC_CODE_TENANT_ID is set, else None.
|
|
61
|
+
|
|
62
|
+
Read fresh each call so monkeypatching in tests works reliably.
|
|
63
|
+
"""
|
|
64
|
+
raw = os.environ.get(_TENANT_ENV_VAR, "").strip()
|
|
65
|
+
if not raw:
|
|
66
|
+
return None
|
|
67
|
+
if raw.startswith(TENANT_ID_PREFIX):
|
|
68
|
+
return raw
|
|
69
|
+
return f"{TENANT_ID_PREFIX}{raw}"
|
|
70
|
+
|
|
71
|
+
def was_first_run(self) -> bool:
|
|
72
|
+
"""True iff get_user_id() minted a new id on this instance."""
|
|
73
|
+
return self._was_first_run
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""MetricsRegistry — wraps OTel Counter/Histogram instruments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from loguru import logger
|
|
8
|
+
from opentelemetry.metrics import Meter
|
|
9
|
+
|
|
10
|
+
from iac_code.services.telemetry.names import Metrics as M # noqa: N817
|
|
11
|
+
|
|
12
|
+
METRIC_NAMES: tuple[str, ...] = (
|
|
13
|
+
M.SESSION_COUNT,
|
|
14
|
+
M.ACTIVE_TIME_TOTAL,
|
|
15
|
+
M.TOKEN_USAGE,
|
|
16
|
+
M.API_REQUEST_COUNT,
|
|
17
|
+
M.API_REQUEST_DURATION,
|
|
18
|
+
M.TOOL_USE_COUNT,
|
|
19
|
+
M.TEMPLATE_GENERATED_COUNT,
|
|
20
|
+
M.TEMPLATE_VALIDATED_COUNT,
|
|
21
|
+
M.DEPLOYMENT_COUNT,
|
|
22
|
+
M.DEPLOYMENT_DURATION,
|
|
23
|
+
M.RESOURCE_TYPE_OBSERVED_COUNT,
|
|
24
|
+
M.ALIYUN_API_CALLED_COUNT,
|
|
25
|
+
M.ALIYUN_API_CALLED_DURATION,
|
|
26
|
+
M.TERRAFORM_PROVIDER_OBSERVED_COUNT,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_HISTOGRAM_NAMES: frozenset[str] = frozenset(
|
|
30
|
+
{
|
|
31
|
+
M.API_REQUEST_DURATION,
|
|
32
|
+
M.DEPLOYMENT_DURATION,
|
|
33
|
+
M.ALIYUN_API_CALLED_DURATION,
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MetricsRegistry:
|
|
39
|
+
"""Holds instrument objects and dispatches add/record calls."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, instruments: dict[str, Any] | None = None) -> None:
|
|
42
|
+
self._instruments: dict[str, Any] = dict(instruments) if instruments else {}
|
|
43
|
+
|
|
44
|
+
def register_all(self, meter: Meter) -> None:
|
|
45
|
+
"""Create a Counter or Histogram per known metric name."""
|
|
46
|
+
self._instruments.clear()
|
|
47
|
+
for name in METRIC_NAMES:
|
|
48
|
+
if name in _HISTOGRAM_NAMES:
|
|
49
|
+
self._instruments[name] = meter.create_histogram(name=name, description=name)
|
|
50
|
+
else:
|
|
51
|
+
self._instruments[name] = meter.create_counter(name=name, description=name)
|
|
52
|
+
|
|
53
|
+
def add(self, name: str, value: int | float, attributes: dict[str, Any]) -> None:
|
|
54
|
+
"""Route to the correct instrument method; silently drop unknown names."""
|
|
55
|
+
logger.info("[metric] {} value={} {}", name, value, attributes)
|
|
56
|
+
inst = self._instruments.get(name)
|
|
57
|
+
if inst is None:
|
|
58
|
+
return
|
|
59
|
+
if name in _HISTOGRAM_NAMES:
|
|
60
|
+
inst.record(value, attributes)
|
|
61
|
+
else:
|
|
62
|
+
inst.add(value, attributes)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Centralized constants for all telemetry signal names."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# =====================================================================
|
|
6
|
+
# ARMS LLM semantic conventions (gen_ai.*)
|
|
7
|
+
# https://help.aliyun.com/zh/arms/application-monitoring/developer-reference/llm-trace-field-definition-description
|
|
8
|
+
# =====================================================================
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GenAiSpanKind:
|
|
12
|
+
"""gen_ai.span.kind enumeration values."""
|
|
13
|
+
|
|
14
|
+
ENTRY = "ENTRY"
|
|
15
|
+
LLM = "LLM"
|
|
16
|
+
TOOL = "TOOL"
|
|
17
|
+
STEP = "STEP"
|
|
18
|
+
AGENT = "AGENT"
|
|
19
|
+
CHAIN = "CHAIN"
|
|
20
|
+
TASK = "TASK"
|
|
21
|
+
RETRIEVER = "RETRIEVER"
|
|
22
|
+
EMBEDDING = "EMBEDDING"
|
|
23
|
+
RERANKER = "RERANKER"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class GenAiOperationName:
|
|
27
|
+
"""gen_ai.operation.name enumeration values."""
|
|
28
|
+
|
|
29
|
+
ENTER = "enter"
|
|
30
|
+
CHAT = "chat"
|
|
31
|
+
TEXT_COMPLETION = "text_completion"
|
|
32
|
+
GENERATE_CONTENT = "generate_content"
|
|
33
|
+
EXECUTE_TOOL = "execute_tool"
|
|
34
|
+
INVOKE_AGENT = "invoke_agent"
|
|
35
|
+
CREATE_AGENT = "create_agent"
|
|
36
|
+
REACT = "react"
|
|
37
|
+
RETRIEVAL = "retrieval"
|
|
38
|
+
EMBEDDINGS = "embeddings"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GenAiAttr:
|
|
42
|
+
"""gen_ai.* span attribute key constants."""
|
|
43
|
+
|
|
44
|
+
# --- Common (all spans) ---
|
|
45
|
+
SPAN_KIND = "gen_ai.span.kind"
|
|
46
|
+
OPERATION_NAME = "gen_ai.operation.name"
|
|
47
|
+
SESSION_ID = "gen_ai.session.id"
|
|
48
|
+
USER_ID = "gen_ai.user.id"
|
|
49
|
+
FRAMEWORK = "gen_ai.framework"
|
|
50
|
+
|
|
51
|
+
# --- Provider & Model ---
|
|
52
|
+
PROVIDER_NAME = "gen_ai.provider.name"
|
|
53
|
+
REQUEST_MODEL = "gen_ai.request.model"
|
|
54
|
+
RESPONSE_MODEL = "gen_ai.response.model"
|
|
55
|
+
RESPONSE_ID = "gen_ai.response.id"
|
|
56
|
+
CONVERSATION_ID = "gen_ai.conversation.id"
|
|
57
|
+
|
|
58
|
+
# --- Request parameters ---
|
|
59
|
+
REQUEST_MAX_TOKENS = "gen_ai.request.max_tokens"
|
|
60
|
+
REQUEST_TEMPERATURE = "gen_ai.request.temperature"
|
|
61
|
+
REQUEST_TOP_P = "gen_ai.request.top_p"
|
|
62
|
+
REQUEST_TOP_K = "gen_ai.request.top_k"
|
|
63
|
+
REQUEST_FREQUENCY_PENALTY = "gen_ai.request.frequency_penalty"
|
|
64
|
+
REQUEST_PRESENCE_PENALTY = "gen_ai.request.presence_penalty"
|
|
65
|
+
REQUEST_STOP_SEQUENCES = "gen_ai.request.stop_sequences"
|
|
66
|
+
REQUEST_SEED = "gen_ai.request.seed"
|
|
67
|
+
REQUEST_CHOICE_COUNT = "gen_ai.request.choice.count"
|
|
68
|
+
|
|
69
|
+
# --- Response ---
|
|
70
|
+
RESPONSE_FINISH_REASONS = "gen_ai.response.finish_reasons"
|
|
71
|
+
RESPONSE_TIME_TO_FIRST_TOKEN = "gen_ai.response.time_to_first_token"
|
|
72
|
+
USER_TIME_TO_FIRST_TOKEN = "gen_ai.user.time_to_first_token"
|
|
73
|
+
RESPONSE_REASONING_TIME = "gen_ai.response.reasoning_time"
|
|
74
|
+
OUTPUT_TYPE = "gen_ai.output.type"
|
|
75
|
+
|
|
76
|
+
# --- Usage ---
|
|
77
|
+
USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"
|
|
78
|
+
USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
|
|
79
|
+
USAGE_TOTAL_TOKENS = "gen_ai.usage.total_tokens"
|
|
80
|
+
USAGE_CACHE_CREATION_INPUT_TOKENS = "gen_ai.usage.cache_creation.input_tokens"
|
|
81
|
+
USAGE_CACHE_READ_INPUT_TOKENS = "gen_ai.usage.cache_read.input_tokens"
|
|
82
|
+
|
|
83
|
+
# --- Content (debug mode only) ---
|
|
84
|
+
INPUT_MESSAGES = "gen_ai.input.messages"
|
|
85
|
+
OUTPUT_MESSAGES = "gen_ai.output.messages"
|
|
86
|
+
SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions"
|
|
87
|
+
TOOL_DEFINITIONS = "gen_ai.tool.definitions"
|
|
88
|
+
|
|
89
|
+
# --- Tool ---
|
|
90
|
+
TOOL_NAME = "gen_ai.tool.name"
|
|
91
|
+
TOOL_TYPE = "gen_ai.tool.type"
|
|
92
|
+
TOOL_CALL_ID = "gen_ai.tool.call.id"
|
|
93
|
+
TOOL_DESCRIPTION = "gen_ai.tool.description"
|
|
94
|
+
TOOL_CALL_ARGUMENTS = "gen_ai.tool.call.arguments"
|
|
95
|
+
TOOL_CALL_RESULT = "gen_ai.tool.call.result"
|
|
96
|
+
|
|
97
|
+
# --- Agent ---
|
|
98
|
+
AGENT_NAME = "gen_ai.agent.name"
|
|
99
|
+
AGENT_ID = "gen_ai.agent.id"
|
|
100
|
+
AGENT_DESCRIPTION = "gen_ai.agent.description"
|
|
101
|
+
DATA_SOURCE_ID = "gen_ai.data_source.id"
|
|
102
|
+
|
|
103
|
+
# --- ReAct Step ---
|
|
104
|
+
REACT_FINISH_REASON = "gen_ai.react.finish_reason"
|
|
105
|
+
REACT_ROUND = "gen_ai.react.round"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ArmsResourceAttr:
|
|
109
|
+
"""ARMS-specific resource attribute keys."""
|
|
110
|
+
|
|
111
|
+
SERVICE_FEATURE = "acs.arms.service.feature"
|
|
112
|
+
CMS_WORKSPACE = "acs.cms.workspace"
|
|
113
|
+
SERVICE_ID = "acs.arms.service.id"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
ARMS_FEATURE_GENAI_APP = "genai_app"
|
|
117
|
+
FRAMEWORK_IAC_CODE = "iac-code"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# =====================================================================
|
|
121
|
+
# iac-code application signals (iac.*)
|
|
122
|
+
# =====================================================================
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Events:
|
|
126
|
+
"""All iac.* event names (OTel Logs signal)."""
|
|
127
|
+
|
|
128
|
+
# --- Lifecycle (5) ---
|
|
129
|
+
INIT = "iac.init"
|
|
130
|
+
SESSION_STARTED = "iac.session.started"
|
|
131
|
+
SESSION_EXITED = "iac.session.exited"
|
|
132
|
+
SESSION_CANCELLED = "iac.session.cancelled"
|
|
133
|
+
AUTH_CONFIGURED = "iac.auth.configured"
|
|
134
|
+
|
|
135
|
+
# --- API / LLM (5) ---
|
|
136
|
+
API_REQUEST_STARTED = "iac.api.request.started"
|
|
137
|
+
API_REQUEST_SUCCEEDED = "iac.api.request.succeeded"
|
|
138
|
+
API_REQUEST_FAILED = "iac.api.request.failed"
|
|
139
|
+
API_REQUEST_RETRIED = "iac.api.request.retried"
|
|
140
|
+
MODEL_FALLBACK_TRIGGERED = "iac.model.fallback.triggered"
|
|
141
|
+
|
|
142
|
+
# --- Tool (4) ---
|
|
143
|
+
TOOL_USE_SUCCEEDED = "iac.tool.use.succeeded"
|
|
144
|
+
TOOL_USE_FAILED = "iac.tool.use.failed"
|
|
145
|
+
TOOL_USE_GRANTED_IN_PROMPT = "iac.tool.use.granted_in_prompt"
|
|
146
|
+
TOOL_USE_REJECTED_IN_PROMPT = "iac.tool.use.rejected_in_prompt"
|
|
147
|
+
|
|
148
|
+
# --- IaC core (9) ---
|
|
149
|
+
TEMPLATE_GENERATED = "iac.template.generated"
|
|
150
|
+
TEMPLATE_VALIDATED = "iac.template.validated"
|
|
151
|
+
DEPLOYMENT_STARTED = "iac.deployment.started"
|
|
152
|
+
DEPLOYMENT_SUCCEEDED = "iac.deployment.succeeded"
|
|
153
|
+
DEPLOYMENT_FAILED = "iac.deployment.failed"
|
|
154
|
+
DEPLOYMENT_CANCELLED = "iac.deployment.cancelled"
|
|
155
|
+
DOC_SEARCHED = "iac.doc.searched"
|
|
156
|
+
SKILL_INVOKED = "iac.skill.invoked"
|
|
157
|
+
SKILL_COMPLETED = "iac.skill.completed"
|
|
158
|
+
|
|
159
|
+
# --- Aliyun API (1) ---
|
|
160
|
+
ALIYUN_API_CALLED = "iac.aliyun.api.called"
|
|
161
|
+
|
|
162
|
+
# --- Memory (2) ---
|
|
163
|
+
MEMORY_COMPACT_SUCCEEDED = "iac.memory.compact.succeeded"
|
|
164
|
+
MEMORY_COMPACT_FAILED = "iac.memory.compact.failed"
|
|
165
|
+
|
|
166
|
+
# --- Crash / error (3) ---
|
|
167
|
+
EXCEPTION_UNCAUGHT = "iac.exception.uncaught"
|
|
168
|
+
EXCEPTION_UNHANDLED = "iac.exception.unhandled"
|
|
169
|
+
QUERY_FAILED = "iac.query.failed"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class Metrics:
|
|
173
|
+
"""All iac.* metric names."""
|
|
174
|
+
|
|
175
|
+
SESSION_COUNT = "iac.session.count"
|
|
176
|
+
ACTIVE_TIME_TOTAL = "iac.active_time.total"
|
|
177
|
+
TOKEN_USAGE = "iac.token.usage"
|
|
178
|
+
API_REQUEST_COUNT = "iac.api.request.count"
|
|
179
|
+
API_REQUEST_DURATION = "iac.api.request.duration"
|
|
180
|
+
TOOL_USE_COUNT = "iac.tool.use.count"
|
|
181
|
+
TEMPLATE_GENERATED_COUNT = "iac.template.generated.count"
|
|
182
|
+
TEMPLATE_VALIDATED_COUNT = "iac.template.validated.count"
|
|
183
|
+
DEPLOYMENT_COUNT = "iac.deployment.count"
|
|
184
|
+
DEPLOYMENT_DURATION = "iac.deployment.duration"
|
|
185
|
+
RESOURCE_TYPE_OBSERVED_COUNT = "iac.resource_type.observed.count"
|
|
186
|
+
ALIYUN_API_CALLED_COUNT = "iac.aliyun.api.called.count"
|
|
187
|
+
ALIYUN_API_CALLED_DURATION = "iac.aliyun.api.called.duration"
|
|
188
|
+
TERRAFORM_PROVIDER_OBSERVED_COUNT = "iac.terraform.provider.observed.count"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class Spans:
|
|
192
|
+
"""Span name constants (ARMS LLM convention: '{operation} {identifier}')."""
|
|
193
|
+
|
|
194
|
+
ENTRY = "enter_ai_application_system"
|
|
195
|
+
LLM_CHAT = "chat"
|
|
196
|
+
TOOL_EXECUTE = "execute_tool"
|
|
197
|
+
REACT_STEP = "react step"
|
|
198
|
+
SKILL_EXECUTE = "iac.skill.execute"
|
|
199
|
+
TEMPLATE_VALIDATE = "iac.template.validate"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Sanitization helpers for telemetry text fields."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from iac_code.services.telemetry.config import is_essential_traffic_only
|
|
9
|
+
from iac_code.services.telemetry.constants import (
|
|
10
|
+
BUNDLED_SKILLS,
|
|
11
|
+
CUSTOM_ROS_RESOURCE_PLACEHOLDER,
|
|
12
|
+
CUSTOM_SKILL_PLACEHOLDER,
|
|
13
|
+
CUSTOM_TF_PROVIDER_PLACEHOLDER,
|
|
14
|
+
CUSTOM_TF_RESOURCE_PLACEHOLDER,
|
|
15
|
+
KNOWN_MODELS,
|
|
16
|
+
MCP_TOOL_PLACEHOLDER,
|
|
17
|
+
OTHER_MODEL_PLACEHOLDER,
|
|
18
|
+
ROS_ALLOWED_PREFIXES,
|
|
19
|
+
TERRAFORM_OFFICIAL_PROVIDERS,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_CONTROL_CHARS_RE = re.compile(r"[\n\r\t\x00-\x1f]+")
|
|
23
|
+
_MAX_ERROR_MSG_BYTES = 512
|
|
24
|
+
_TRUNCATION_MARKER = "... (truncated)"
|
|
25
|
+
_DEV_VERSION_SUFFIX_RE = re.compile(r"-\d{8}$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sanitize_error_message(raw: str | None) -> str | None:
|
|
29
|
+
"""Clean and truncate an error message. None under essential-traffic mode."""
|
|
30
|
+
if raw is None:
|
|
31
|
+
return None
|
|
32
|
+
if is_essential_traffic_only():
|
|
33
|
+
return None
|
|
34
|
+
cleaned = _CONTROL_CHARS_RE.sub(" ", raw).strip()
|
|
35
|
+
encoded = cleaned.encode("utf-8")
|
|
36
|
+
if len(encoded) > _MAX_ERROR_MSG_BYTES:
|
|
37
|
+
keep = _MAX_ERROR_MSG_BYTES - len(_TRUNCATION_MARKER.encode("utf-8"))
|
|
38
|
+
cleaned = encoded[:keep].decode("utf-8", errors="ignore") + _TRUNCATION_MARKER
|
|
39
|
+
return cleaned
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def sanitize_skill_name(raw: str | None) -> str | None:
|
|
43
|
+
if raw is None:
|
|
44
|
+
return None
|
|
45
|
+
return raw if raw in BUNDLED_SKILLS else CUSTOM_SKILL_PLACEHOLDER
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def sanitize_resource_type(raw: str, kind: Literal["ros", "terraform"]) -> str:
|
|
49
|
+
"""Keep official resource types; replace custom/unknown with placeholder."""
|
|
50
|
+
if kind == "ros":
|
|
51
|
+
if raw.startswith(ROS_ALLOWED_PREFIXES):
|
|
52
|
+
return raw
|
|
53
|
+
return CUSTOM_ROS_RESOURCE_PLACEHOLDER
|
|
54
|
+
# Terraform: resource types are of the form `<provider>_<resource>`.
|
|
55
|
+
if "_" not in raw:
|
|
56
|
+
return CUSTOM_TF_RESOURCE_PLACEHOLDER
|
|
57
|
+
provider = raw.split("_", 1)[0]
|
|
58
|
+
if provider in TERRAFORM_OFFICIAL_PROVIDERS:
|
|
59
|
+
return raw
|
|
60
|
+
return CUSTOM_TF_RESOURCE_PLACEHOLDER
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def sanitize_terraform_provider(raw: str) -> str:
|
|
64
|
+
return raw if raw in TERRAFORM_OFFICIAL_PROVIDERS else CUSTOM_TF_PROVIDER_PLACEHOLDER
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sanitize_model_name(raw: str) -> str:
|
|
68
|
+
"""Trim dev suffix (-YYYYMMDD) and map to known set or 'other'."""
|
|
69
|
+
base = _DEV_VERSION_SUFFIX_RE.sub("", raw)
|
|
70
|
+
return base if base in KNOWN_MODELS else OTHER_MODEL_PLACEHOLDER
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def sanitize_tool_name(raw: str) -> str:
|
|
74
|
+
"""MCP tools are collapsed to a single placeholder."""
|
|
75
|
+
if raw.startswith("mcp__"):
|
|
76
|
+
return MCP_TOOL_PLACEHOLDER
|
|
77
|
+
return raw
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def bucket_resource_count(n: int) -> str:
|
|
81
|
+
"""bucket for iac.deployment.duration histogram."""
|
|
82
|
+
if n <= 5:
|
|
83
|
+
return "1-5"
|
|
84
|
+
if n <= 20:
|
|
85
|
+
return "6-20"
|
|
86
|
+
if n <= 50:
|
|
87
|
+
return "21-50"
|
|
88
|
+
return "50+"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""AnalyticsSink — routes events through the privacy gate to the Events pipeline.
|
|
2
|
+
|
|
3
|
+
Design:
|
|
4
|
+
- Before `activate()`, events are queued in-memory (bounded by maxlen=10k).
|
|
5
|
+
- After `activate()`, events go directly to the EventEmitter.
|
|
6
|
+
- `drain_sync()` / `drain_soon()` flushes the pre-queue once activated.
|
|
7
|
+
- The privacy gate blocks emission when no-telemetry / essential-traffic is on.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from collections import deque
|
|
14
|
+
from threading import Lock
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from loguru import logger
|
|
18
|
+
|
|
19
|
+
from iac_code.services.telemetry.config import is_telemetry_disabled
|
|
20
|
+
from iac_code.services.telemetry.events import EventEmitter
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AnalyticsSink:
|
|
24
|
+
"""Privacy-gated event router with pre-activation queue."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, emitter: EventEmitter, queue_max: int = 10_000) -> None:
|
|
27
|
+
self._emitter = emitter
|
|
28
|
+
self._queue: deque[tuple[str, dict[str, Any]]] = deque(maxlen=queue_max)
|
|
29
|
+
self._lock = Lock()
|
|
30
|
+
self._active = False
|
|
31
|
+
|
|
32
|
+
def log_event(self, event_name: str, metadata: dict[str, Any]) -> None:
|
|
33
|
+
"""Queue the event if not yet active, else gate+emit directly."""
|
|
34
|
+
with self._lock:
|
|
35
|
+
if not self._active:
|
|
36
|
+
self._queue.append((event_name, metadata))
|
|
37
|
+
return
|
|
38
|
+
self._dispatch(event_name, metadata)
|
|
39
|
+
|
|
40
|
+
def activate(self) -> None:
|
|
41
|
+
"""Mark the sink active. Idempotent. Does NOT drain — call drain_*()."""
|
|
42
|
+
with self._lock:
|
|
43
|
+
self._active = True
|
|
44
|
+
|
|
45
|
+
def drain_sync(self) -> None:
|
|
46
|
+
"""Synchronously flush the pre-activation queue."""
|
|
47
|
+
with self._lock:
|
|
48
|
+
queued = list(self._queue)
|
|
49
|
+
self._queue.clear()
|
|
50
|
+
for name, meta in queued:
|
|
51
|
+
self._dispatch(name, meta)
|
|
52
|
+
|
|
53
|
+
def drain_soon(self) -> None:
|
|
54
|
+
"""Schedule an async drain on the running loop, if any. Else sync."""
|
|
55
|
+
try:
|
|
56
|
+
loop = asyncio.get_running_loop()
|
|
57
|
+
loop.call_soon(self.drain_sync)
|
|
58
|
+
return
|
|
59
|
+
except RuntimeError:
|
|
60
|
+
pass
|
|
61
|
+
self.drain_sync()
|
|
62
|
+
|
|
63
|
+
def _dispatch(self, event_name: str, metadata: dict[str, Any]) -> None:
|
|
64
|
+
logger.info("[event] {} {}", event_name, metadata)
|
|
65
|
+
if is_telemetry_disabled():
|
|
66
|
+
return
|
|
67
|
+
self._emitter.emit(event_name, metadata)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""SpanFactory — wraps the OTel Tracer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from typing import Any, Iterator
|
|
7
|
+
|
|
8
|
+
from loguru import logger
|
|
9
|
+
from opentelemetry import trace
|
|
10
|
+
from opentelemetry.trace import Tracer
|
|
11
|
+
|
|
12
|
+
_TRACER_NAME = "com.iac-code.tracing"
|
|
13
|
+
_TRACER_VERSION = "1.0.0"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SpanFactory:
|
|
17
|
+
"""Context-manager span producer."""
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self._tracer: Tracer | None = None
|
|
21
|
+
|
|
22
|
+
def attach(self, tracer: Tracer) -> None:
|
|
23
|
+
self._tracer = tracer
|
|
24
|
+
|
|
25
|
+
def _get_tracer(self) -> Tracer:
|
|
26
|
+
if self._tracer is None:
|
|
27
|
+
return trace.get_tracer(_TRACER_NAME, _TRACER_VERSION)
|
|
28
|
+
return self._tracer
|
|
29
|
+
|
|
30
|
+
@contextmanager
|
|
31
|
+
def start(self, name: str, attributes: dict[str, Any] | None = None) -> Iterator[Any]:
|
|
32
|
+
"""Start a span as a context manager."""
|
|
33
|
+
tracer = self._get_tracer()
|
|
34
|
+
with tracer.start_as_current_span(name, attributes=attributes or {}) as span:
|
|
35
|
+
yield span
|
|
36
|
+
attrs = getattr(span, "attributes", None)
|
|
37
|
+
if attrs:
|
|
38
|
+
logger.debug("[span] {} {}", name, {k: v for k, v in attrs.items()})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Type definitions for telemetry payloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Union
|
|
6
|
+
|
|
7
|
+
# Primitive values allowed directly in event/metric metadata.
|
|
8
|
+
# Strings are NOT in this list by design — callers must sanitize text explicitly.
|
|
9
|
+
AllowedMetadataValue = Union[bool, int, float, None, list, str]
|
|
10
|
+
# Note: `str` is included because sanitize_*() returns str. Callers must only pass
|
|
11
|
+
# strings that came from a sanitize_*() function or from a well-known enum constant.
|
|
12
|
+
|
|
13
|
+
EventMetadata = dict[str, AllowedMetadataValue]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Token budget management for controlling LLM usage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class TokenBudget:
|
|
11
|
+
"""Tracks token consumption against a total budget."""
|
|
12
|
+
|
|
13
|
+
total: int | None = None # None means unlimited
|
|
14
|
+
used: int = field(default=0, init=False)
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def remaining(self) -> int | float:
|
|
18
|
+
if self.total is None:
|
|
19
|
+
return float("inf")
|
|
20
|
+
return max(0, self.total - self.used)
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def is_exhausted(self) -> bool:
|
|
24
|
+
if self.total is None:
|
|
25
|
+
return False
|
|
26
|
+
return self.used >= self.total
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def usage_percent(self) -> float:
|
|
30
|
+
if self.total is None or self.total == 0:
|
|
31
|
+
return 0.0
|
|
32
|
+
return (self.used / self.total) * 100.0
|
|
33
|
+
|
|
34
|
+
def consume(self, tokens: int) -> None:
|
|
35
|
+
self.used += tokens
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def parse_shorthand(text: str) -> int:
|
|
39
|
+
cleaned = text.strip().lstrip("+")
|
|
40
|
+
match = re.match(r"^(\d+(?:\.\d+)?)\s*([kmKM])?$", cleaned)
|
|
41
|
+
if not match:
|
|
42
|
+
raise ValueError(f"Invalid token shorthand: '{text}'")
|
|
43
|
+
value = float(match.group(1))
|
|
44
|
+
suffix = (match.group(2) or "").lower()
|
|
45
|
+
multipliers = {"k": 1_000, "m": 1_000_000}
|
|
46
|
+
return int(value * multipliers.get(suffix, 1))
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def unlimited(cls) -> TokenBudget:
|
|
50
|
+
return cls(total=None)
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_shorthand(cls, text: str) -> TokenBudget:
|
|
54
|
+
return cls(total=cls.parse_shorthand(text))
|