vtx-coding-agent 0.1.1__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.
- vtx/__init__.py +63 -0
- vtx/async_utils.py +40 -0
- vtx/builtin_skills/github/SKILL.md +139 -0
- vtx/builtin_skills/init/SKILL.md +74 -0
- vtx/builtin_skills/review/SKILL.md +73 -0
- vtx/builtin_skills/skill-builder/SKILL.md +133 -0
- vtx/cli.py +90 -0
- vtx/config.py +741 -0
- vtx/context/__init__.py +15 -0
- vtx/context/_xml.py +8 -0
- vtx/context/agent_mds.py +128 -0
- vtx/context/git.py +64 -0
- vtx/context/loader.py +41 -0
- vtx/context/skills.py +423 -0
- vtx/core/__init__.py +47 -0
- vtx/core/compaction.py +89 -0
- vtx/core/errors.py +17 -0
- vtx/core/handoff.py +51 -0
- vtx/core/scratchpad.py +54 -0
- vtx/core/types.py +197 -0
- vtx/defaults/__init__.py +0 -0
- vtx/defaults/config.yml +53 -0
- vtx/diff_display.py +12 -0
- vtx/events.py +224 -0
- vtx/gh_cli.py +82 -0
- vtx/git_branch.py +90 -0
- vtx/headless.py +127 -0
- vtx/llm/__init__.py +93 -0
- vtx/llm/base.py +217 -0
- vtx/llm/context_length.py +150 -0
- vtx/llm/dynamic_models.py +735 -0
- vtx/llm/model_fetcher.py +279 -0
- vtx/llm/models.py +78 -0
- vtx/llm/oauth/__init__.py +59 -0
- vtx/llm/oauth/copilot.py +358 -0
- vtx/llm/oauth/dynamic.py +236 -0
- vtx/llm/oauth/openai.py +400 -0
- vtx/llm/phase_parser.py +270 -0
- vtx/llm/provider.yaml +280 -0
- vtx/llm/provider_catalog.py +230 -0
- vtx/llm/providers/__init__.py +45 -0
- vtx/llm/providers/anthropic_sdk.py +256 -0
- vtx/llm/providers/mock.py +249 -0
- vtx/llm/providers/openai_sdk.py +246 -0
- vtx/llm/providers/sanitize.py +14 -0
- vtx/llm/sdk/__init__.py +13 -0
- vtx/llm/sdk/anthropic.py +382 -0
- vtx/llm/sdk/base.py +82 -0
- vtx/llm/sdk/openai.py +344 -0
- vtx/llm/tool_parser.py +161 -0
- vtx/loop.py +272 -0
- vtx/notify.py +109 -0
- vtx/permissions.py +114 -0
- vtx/prompts/__init__.py +45 -0
- vtx/prompts/builder.py +86 -0
- vtx/prompts/env.py +58 -0
- vtx/prompts/identity.py +166 -0
- vtx/prompts/tooling.py +36 -0
- vtx/py.typed +0 -0
- vtx/runtime.py +580 -0
- vtx/session.py +868 -0
- vtx/sounds/completion.wav +0 -0
- vtx/sounds/error.wav +0 -0
- vtx/sounds/permission.wav +0 -0
- vtx/themes.py +1104 -0
- vtx/tools/__init__.py +68 -0
- vtx/tools/_read_image.py +106 -0
- vtx/tools/_tool_utils.py +90 -0
- vtx/tools/base.py +36 -0
- vtx/tools/bash.py +371 -0
- vtx/tools/edit.py +261 -0
- vtx/tools/find.py +132 -0
- vtx/tools/read.py +238 -0
- vtx/tools/skill.py +278 -0
- vtx/tools/web.py +238 -0
- vtx/tools/write.py +88 -0
- vtx/tools_manager.py +216 -0
- vtx/turn.py +789 -0
- vtx/ui/__init__.py +0 -0
- vtx/ui/agent_runner.py +417 -0
- vtx/ui/app.py +665 -0
- vtx/ui/app_protocol.py +29 -0
- vtx/ui/autocomplete.py +440 -0
- vtx/ui/blocks.py +735 -0
- vtx/ui/chat.py +613 -0
- vtx/ui/clipboard.py +59 -0
- vtx/ui/commands/__init__.py +100 -0
- vtx/ui/commands/auth.py +306 -0
- vtx/ui/commands/base.py +122 -0
- vtx/ui/commands/models.py +144 -0
- vtx/ui/commands/sessions.py +388 -0
- vtx/ui/commands/settings.py +286 -0
- vtx/ui/completion_ui.py +313 -0
- vtx/ui/export.py +703 -0
- vtx/ui/floating_list.py +370 -0
- vtx/ui/formatting.py +287 -0
- vtx/ui/input.py +760 -0
- vtx/ui/latex.py +349 -0
- vtx/ui/launch.py +108 -0
- vtx/ui/path_complete.py +228 -0
- vtx/ui/prompt_history.py +102 -0
- vtx/ui/queue_ui.py +141 -0
- vtx/ui/selection_mode.py +18 -0
- vtx/ui/session_ui.py +235 -0
- vtx/ui/startup.py +124 -0
- vtx/ui/styles.py +327 -0
- vtx/ui/tool_output.py +34 -0
- vtx/ui/tree.py +437 -0
- vtx/ui/welcome.py +51 -0
- vtx/ui/widgets.py +558 -0
- vtx/update_check.py +49 -0
- vtx/version.py +22 -0
- vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
- vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
- vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
- vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
- vtx_coding_agent-0.1.1.dist-info/licenses/LICENSE +201 -0
vtx/llm/base.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from ipaddress import ip_address
|
|
6
|
+
from typing import ClassVar, Literal
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from vtx import config as vtx_config
|
|
12
|
+
|
|
13
|
+
from ..core.types import Message, StreamPart, ToolDefinition, Usage
|
|
14
|
+
|
|
15
|
+
DEFAULT_THINKING_LEVELS: list[str] = ["none", "minimal", "low", "medium", "high", "xhigh"]
|
|
16
|
+
LOCAL_API_KEY_PLACEHOLDER = "vtx-local"
|
|
17
|
+
AuthMode = Literal["auto", "required", "none"]
|
|
18
|
+
|
|
19
|
+
ENV_API_KEY_MAP: dict[str, str] = {
|
|
20
|
+
"openai": "OPENAI_API_KEY",
|
|
21
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
22
|
+
"deepseek": "DEEPSEEK_API_KEY",
|
|
23
|
+
"zhipu": "ZAI_API_KEY",
|
|
24
|
+
"airouter": "AIROUTER_API_KEY",
|
|
25
|
+
"opencode": "OPENCODE_API_KEY",
|
|
26
|
+
"kilo": "KILO_API_KEY",
|
|
27
|
+
"tokenrouter": "TOKENROUTER_API_KEY",
|
|
28
|
+
"openrouter": "OPENROUTER_API_KEY",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_env_api_key(provider: str) -> str | None:
|
|
33
|
+
env_var = ENV_API_KEY_MAP.get(provider)
|
|
34
|
+
return os.environ.get(env_var) if env_var else None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_local_base_url(base_url: str | None) -> bool:
|
|
38
|
+
if not base_url:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
parsed = urlparse(base_url if "://" in base_url else f"https://{base_url}")
|
|
42
|
+
hostname = parsed.hostname
|
|
43
|
+
if hostname is None:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
normalized = hostname.lower()
|
|
47
|
+
if normalized in {"localhost", "127.0.0.1", "0.0.0.0", "::1"}:
|
|
48
|
+
return True
|
|
49
|
+
if normalized.endswith(".local"):
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
addr = ip_address(normalized)
|
|
54
|
+
except ValueError:
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
return addr.is_loopback or addr.is_private or addr.is_link_local
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def make_http_client() -> httpx.AsyncClient | None:
|
|
61
|
+
# Returns None when verify is required so the SDK uses its own default client.
|
|
62
|
+
if not vtx_config.llm.tls.insecure_skip_verify:
|
|
63
|
+
return None
|
|
64
|
+
return httpx.AsyncClient(
|
|
65
|
+
verify=False, timeout=httpx.Timeout(vtx_config.llm.request_timeout_seconds)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def resolve_api_key(
|
|
70
|
+
explicit_api_key: str | None,
|
|
71
|
+
*,
|
|
72
|
+
env_vars: list[str] | tuple[str, ...] = (),
|
|
73
|
+
base_url: str | None = None,
|
|
74
|
+
auth_mode: AuthMode = "required",
|
|
75
|
+
) -> str | None:
|
|
76
|
+
if explicit_api_key:
|
|
77
|
+
return explicit_api_key
|
|
78
|
+
|
|
79
|
+
for env_var in env_vars:
|
|
80
|
+
value = os.environ.get(env_var)
|
|
81
|
+
if value:
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
if auth_mode == "none":
|
|
85
|
+
return LOCAL_API_KEY_PLACEHOLDER
|
|
86
|
+
if auth_mode == "auto" and is_local_base_url(base_url):
|
|
87
|
+
return LOCAL_API_KEY_PLACEHOLDER
|
|
88
|
+
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class ProviderConfig:
|
|
94
|
+
api_key: str | None = None
|
|
95
|
+
base_url: str | None = None
|
|
96
|
+
model: str = ""
|
|
97
|
+
max_tokens: int = 8192
|
|
98
|
+
temperature: float | None = None
|
|
99
|
+
thinking_level: str = "high"
|
|
100
|
+
provider: str | None = None
|
|
101
|
+
session_id: str | None = None
|
|
102
|
+
openai_compat_auth_mode: AuthMode = "auto"
|
|
103
|
+
anthropic_compat_auth_mode: AuthMode = "auto"
|
|
104
|
+
default_headers: dict[str, str] = field(default_factory=dict)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class LLMStream(AsyncIterator["StreamPart"]):
|
|
108
|
+
"""
|
|
109
|
+
Async iterator over stream parts with access to final usage/metadata.
|
|
110
|
+
|
|
111
|
+
Usage:
|
|
112
|
+
stream = await provider.stream(messages, tools)
|
|
113
|
+
async for part in stream:
|
|
114
|
+
match part:
|
|
115
|
+
case TextPart(text=t):
|
|
116
|
+
print(t, end="")
|
|
117
|
+
case ThinkPart(think=t):
|
|
118
|
+
print(f"[thinking] {t}")
|
|
119
|
+
case ToolCallStart(id=id, name=name):
|
|
120
|
+
print(f"Tool call: {name}")
|
|
121
|
+
...
|
|
122
|
+
|
|
123
|
+
# After iteration, access final stats
|
|
124
|
+
print(f"Usage: {stream.usage}")
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(self) -> None:
|
|
128
|
+
self._iterator: AsyncIterator[StreamPart] | None = None
|
|
129
|
+
self._usage: Usage | None = None
|
|
130
|
+
self._id: str | None = None
|
|
131
|
+
|
|
132
|
+
def set_iterator(self, iterator: AsyncIterator[StreamPart]) -> None:
|
|
133
|
+
self._iterator = iterator
|
|
134
|
+
|
|
135
|
+
def __aiter__(self) -> AsyncIterator[StreamPart]:
|
|
136
|
+
return self
|
|
137
|
+
|
|
138
|
+
async def __anext__(self) -> StreamPart:
|
|
139
|
+
if self._iterator is None:
|
|
140
|
+
raise StopAsyncIteration
|
|
141
|
+
return await self._iterator.__anext__()
|
|
142
|
+
|
|
143
|
+
async def aclose(self) -> None:
|
|
144
|
+
if self._iterator is None:
|
|
145
|
+
return
|
|
146
|
+
close = getattr(self._iterator, "aclose", None)
|
|
147
|
+
if close is not None:
|
|
148
|
+
await close()
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def usage(self) -> Usage | None:
|
|
152
|
+
return self._usage
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def id(self) -> str | None:
|
|
156
|
+
return self._id
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class BaseProvider(ABC):
|
|
160
|
+
name: str
|
|
161
|
+
thinking_levels: ClassVar[list[str]] = DEFAULT_THINKING_LEVELS
|
|
162
|
+
|
|
163
|
+
def __init__(self, config: ProviderConfig):
|
|
164
|
+
self.config = config
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def thinking_level(self) -> str:
|
|
168
|
+
return self.config.thinking_level
|
|
169
|
+
|
|
170
|
+
def set_thinking_level(self, level: str) -> None:
|
|
171
|
+
if level not in self.thinking_levels:
|
|
172
|
+
raise ValueError(
|
|
173
|
+
f"Invalid thinking level '{level}' for {self.name}. "
|
|
174
|
+
f"Valid levels: {self.thinking_levels}"
|
|
175
|
+
)
|
|
176
|
+
self.config.thinking_level = level
|
|
177
|
+
|
|
178
|
+
def cycle_thinking_level(self) -> str:
|
|
179
|
+
levels = self.thinking_levels
|
|
180
|
+
current_idx = (
|
|
181
|
+
levels.index(self.config.thinking_level) if self.config.thinking_level in levels else 0
|
|
182
|
+
)
|
|
183
|
+
next_idx = (current_idx + 1) % len(levels)
|
|
184
|
+
new_level = levels[next_idx]
|
|
185
|
+
self.config.thinking_level = new_level
|
|
186
|
+
return new_level
|
|
187
|
+
|
|
188
|
+
async def stream(
|
|
189
|
+
self,
|
|
190
|
+
messages: list[Message],
|
|
191
|
+
*,
|
|
192
|
+
system_prompt: str | None = None,
|
|
193
|
+
tools: list[ToolDefinition] | None = None,
|
|
194
|
+
temperature: float | None = None,
|
|
195
|
+
max_tokens: int | None = None,
|
|
196
|
+
) -> LLMStream:
|
|
197
|
+
return await self._stream_impl(
|
|
198
|
+
messages,
|
|
199
|
+
system_prompt=system_prompt,
|
|
200
|
+
tools=tools,
|
|
201
|
+
temperature=temperature,
|
|
202
|
+
max_tokens=max_tokens,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
@abstractmethod
|
|
206
|
+
async def _stream_impl(
|
|
207
|
+
self,
|
|
208
|
+
messages: list[Message],
|
|
209
|
+
*,
|
|
210
|
+
system_prompt: str | None = None,
|
|
211
|
+
tools: list[ToolDefinition] | None = None,
|
|
212
|
+
temperature: float | None = None,
|
|
213
|
+
max_tokens: int | None = None,
|
|
214
|
+
) -> LLMStream: ...
|
|
215
|
+
|
|
216
|
+
@abstractmethod
|
|
217
|
+
def should_retry_for_error(self, error: Exception) -> bool: ...
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Dynamic context length manager for models.
|
|
2
|
+
|
|
3
|
+
Fetches model context/output limits from the models.dev API and
|
|
4
|
+
caches them. Provides lookup by model ID with fuzzy matching fallback.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.error import URLError
|
|
16
|
+
from urllib.request import Request, urlopen
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
MODELS_DEV_API_URL = "https://models.dev/api.json"
|
|
21
|
+
CACHE_FILE = "models_dev_limits.json"
|
|
22
|
+
DEFAULT_CONTEXT_LENGTH = 128 * 1024 # 131072
|
|
23
|
+
DEFAULT_OUTPUT_TOKENS = 16 * 1024 # 16384
|
|
24
|
+
CACHE_TTL_SECONDS = 24 * 60 * 60 # 24 hours
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class TokenLimits:
|
|
29
|
+
context: int
|
|
30
|
+
output: int
|
|
31
|
+
supports_reasoning: bool = False
|
|
32
|
+
supports_vision: bool = False
|
|
33
|
+
supports_tools: bool = False
|
|
34
|
+
supports_audio: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ContextLengthManager:
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self._limits: dict[str, TokenLimits] = {}
|
|
40
|
+
self._loaded = False
|
|
41
|
+
self._lock = threading.Lock()
|
|
42
|
+
|
|
43
|
+
def _get_cache_path(self) -> Path:
|
|
44
|
+
from ..config import get_config_dir
|
|
45
|
+
|
|
46
|
+
return get_config_dir() / CACHE_FILE
|
|
47
|
+
|
|
48
|
+
def _load_from_cache(self) -> bool:
|
|
49
|
+
path = self._get_cache_path()
|
|
50
|
+
try:
|
|
51
|
+
import time
|
|
52
|
+
|
|
53
|
+
if path.exists() and (path.stat().st_mtime + CACHE_TTL_SECONDS) > time.time():
|
|
54
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
55
|
+
self._parse_limits(data)
|
|
56
|
+
return True
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
def _save_to_cache(self, data: dict[str, Any]) -> None:
|
|
62
|
+
try:
|
|
63
|
+
path = self._get_cache_path()
|
|
64
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
logger.debug("Failed to save models.dev cache: %s", exc)
|
|
68
|
+
|
|
69
|
+
def _fetch_and_parse(self) -> None:
|
|
70
|
+
try:
|
|
71
|
+
req = Request(MODELS_DEV_API_URL, headers={"User-Agent": "vtx/1.0"})
|
|
72
|
+
with urlopen(req, timeout=10) as resp:
|
|
73
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
74
|
+
self._parse_limits(data)
|
|
75
|
+
self._save_to_cache(data)
|
|
76
|
+
logger.info("Loaded model limits from models.dev")
|
|
77
|
+
except (URLError, Exception) as exc:
|
|
78
|
+
logger.debug("Failed to fetch model limits: %s", exc)
|
|
79
|
+
|
|
80
|
+
def _parse_limits(self, data: dict[str, Any]) -> None:
|
|
81
|
+
for _provider_name, provider_data in data.items():
|
|
82
|
+
if not isinstance(provider_data, dict):
|
|
83
|
+
continue
|
|
84
|
+
models = provider_data.get("models", {})
|
|
85
|
+
if not models:
|
|
86
|
+
continue
|
|
87
|
+
for model_id, model_info in models.items():
|
|
88
|
+
limit = model_info.get("limit", {})
|
|
89
|
+
if not limit:
|
|
90
|
+
continue
|
|
91
|
+
context = limit.get("context", 0)
|
|
92
|
+
output = limit.get("output", DEFAULT_OUTPUT_TOKENS)
|
|
93
|
+
if context > 0:
|
|
94
|
+
modalities = model_info.get("modalities", {})
|
|
95
|
+
input_mods = modalities.get("input", [])
|
|
96
|
+
output_mods = modalities.get("output", [])
|
|
97
|
+
self._limits[model_id] = TokenLimits(
|
|
98
|
+
context=context,
|
|
99
|
+
output=output,
|
|
100
|
+
supports_reasoning=bool(model_info.get("reasoning", False)),
|
|
101
|
+
supports_vision="image" in input_mods,
|
|
102
|
+
supports_tools=bool(model_info.get("tool_call", False)),
|
|
103
|
+
supports_audio="audio" in input_mods or "audio" in output_mods,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def ensure_loaded(self) -> None:
|
|
107
|
+
if self._loaded:
|
|
108
|
+
return
|
|
109
|
+
with self._lock:
|
|
110
|
+
if self._loaded:
|
|
111
|
+
return
|
|
112
|
+
if not self._load_from_cache():
|
|
113
|
+
self._fetch_and_parse()
|
|
114
|
+
self._loaded = True
|
|
115
|
+
|
|
116
|
+
def get_limits(self, model: str) -> TokenLimits:
|
|
117
|
+
self.ensure_loaded()
|
|
118
|
+
|
|
119
|
+
if model in self._limits:
|
|
120
|
+
return self._limits[model]
|
|
121
|
+
|
|
122
|
+
model_lower = model.lower()
|
|
123
|
+
for model_id, limits in self._limits.items():
|
|
124
|
+
if model_lower in model_id.lower() or model_id.lower() in model_lower:
|
|
125
|
+
return limits
|
|
126
|
+
|
|
127
|
+
return TokenLimits(context=DEFAULT_CONTEXT_LENGTH, output=DEFAULT_OUTPUT_TOKENS)
|
|
128
|
+
|
|
129
|
+
def register(self, model: str, context: int, output: int | None = None, **kwargs: Any) -> None:
|
|
130
|
+
self._limits[model] = TokenLimits(
|
|
131
|
+
context=context, output=output or DEFAULT_OUTPUT_TOKENS, **kwargs
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def get_context_length(self, model: str) -> int:
|
|
135
|
+
return self.get_limits(model).context
|
|
136
|
+
|
|
137
|
+
def get_max_output(self, model: str) -> int:
|
|
138
|
+
return self.get_limits(model).output
|
|
139
|
+
|
|
140
|
+
def supports_reasoning(self, model: str) -> bool:
|
|
141
|
+
return self.get_limits(model).supports_reasoning
|
|
142
|
+
|
|
143
|
+
def supports_vision(self, model: str) -> bool:
|
|
144
|
+
return self.get_limits(model).supports_vision
|
|
145
|
+
|
|
146
|
+
def supports_tools(self, model: str) -> bool:
|
|
147
|
+
return self.get_limits(model).supports_tools
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
context_length_manager = ContextLengthManager()
|