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.
Files changed (117) hide show
  1. vtx/__init__.py +63 -0
  2. vtx/async_utils.py +40 -0
  3. vtx/builtin_skills/github/SKILL.md +139 -0
  4. vtx/builtin_skills/init/SKILL.md +74 -0
  5. vtx/builtin_skills/review/SKILL.md +73 -0
  6. vtx/builtin_skills/skill-builder/SKILL.md +133 -0
  7. vtx/cli.py +90 -0
  8. vtx/config.py +741 -0
  9. vtx/context/__init__.py +15 -0
  10. vtx/context/_xml.py +8 -0
  11. vtx/context/agent_mds.py +128 -0
  12. vtx/context/git.py +64 -0
  13. vtx/context/loader.py +41 -0
  14. vtx/context/skills.py +423 -0
  15. vtx/core/__init__.py +47 -0
  16. vtx/core/compaction.py +89 -0
  17. vtx/core/errors.py +17 -0
  18. vtx/core/handoff.py +51 -0
  19. vtx/core/scratchpad.py +54 -0
  20. vtx/core/types.py +197 -0
  21. vtx/defaults/__init__.py +0 -0
  22. vtx/defaults/config.yml +53 -0
  23. vtx/diff_display.py +12 -0
  24. vtx/events.py +224 -0
  25. vtx/gh_cli.py +82 -0
  26. vtx/git_branch.py +90 -0
  27. vtx/headless.py +127 -0
  28. vtx/llm/__init__.py +93 -0
  29. vtx/llm/base.py +217 -0
  30. vtx/llm/context_length.py +150 -0
  31. vtx/llm/dynamic_models.py +735 -0
  32. vtx/llm/model_fetcher.py +279 -0
  33. vtx/llm/models.py +78 -0
  34. vtx/llm/oauth/__init__.py +59 -0
  35. vtx/llm/oauth/copilot.py +358 -0
  36. vtx/llm/oauth/dynamic.py +236 -0
  37. vtx/llm/oauth/openai.py +400 -0
  38. vtx/llm/phase_parser.py +270 -0
  39. vtx/llm/provider.yaml +280 -0
  40. vtx/llm/provider_catalog.py +230 -0
  41. vtx/llm/providers/__init__.py +45 -0
  42. vtx/llm/providers/anthropic_sdk.py +256 -0
  43. vtx/llm/providers/mock.py +249 -0
  44. vtx/llm/providers/openai_sdk.py +246 -0
  45. vtx/llm/providers/sanitize.py +14 -0
  46. vtx/llm/sdk/__init__.py +13 -0
  47. vtx/llm/sdk/anthropic.py +382 -0
  48. vtx/llm/sdk/base.py +82 -0
  49. vtx/llm/sdk/openai.py +344 -0
  50. vtx/llm/tool_parser.py +161 -0
  51. vtx/loop.py +272 -0
  52. vtx/notify.py +109 -0
  53. vtx/permissions.py +114 -0
  54. vtx/prompts/__init__.py +45 -0
  55. vtx/prompts/builder.py +86 -0
  56. vtx/prompts/env.py +58 -0
  57. vtx/prompts/identity.py +166 -0
  58. vtx/prompts/tooling.py +36 -0
  59. vtx/py.typed +0 -0
  60. vtx/runtime.py +580 -0
  61. vtx/session.py +868 -0
  62. vtx/sounds/completion.wav +0 -0
  63. vtx/sounds/error.wav +0 -0
  64. vtx/sounds/permission.wav +0 -0
  65. vtx/themes.py +1104 -0
  66. vtx/tools/__init__.py +68 -0
  67. vtx/tools/_read_image.py +106 -0
  68. vtx/tools/_tool_utils.py +90 -0
  69. vtx/tools/base.py +36 -0
  70. vtx/tools/bash.py +371 -0
  71. vtx/tools/edit.py +261 -0
  72. vtx/tools/find.py +132 -0
  73. vtx/tools/read.py +238 -0
  74. vtx/tools/skill.py +278 -0
  75. vtx/tools/web.py +238 -0
  76. vtx/tools/write.py +88 -0
  77. vtx/tools_manager.py +216 -0
  78. vtx/turn.py +789 -0
  79. vtx/ui/__init__.py +0 -0
  80. vtx/ui/agent_runner.py +417 -0
  81. vtx/ui/app.py +665 -0
  82. vtx/ui/app_protocol.py +29 -0
  83. vtx/ui/autocomplete.py +440 -0
  84. vtx/ui/blocks.py +735 -0
  85. vtx/ui/chat.py +613 -0
  86. vtx/ui/clipboard.py +59 -0
  87. vtx/ui/commands/__init__.py +100 -0
  88. vtx/ui/commands/auth.py +306 -0
  89. vtx/ui/commands/base.py +122 -0
  90. vtx/ui/commands/models.py +144 -0
  91. vtx/ui/commands/sessions.py +388 -0
  92. vtx/ui/commands/settings.py +286 -0
  93. vtx/ui/completion_ui.py +313 -0
  94. vtx/ui/export.py +703 -0
  95. vtx/ui/floating_list.py +370 -0
  96. vtx/ui/formatting.py +287 -0
  97. vtx/ui/input.py +760 -0
  98. vtx/ui/latex.py +349 -0
  99. vtx/ui/launch.py +108 -0
  100. vtx/ui/path_complete.py +228 -0
  101. vtx/ui/prompt_history.py +102 -0
  102. vtx/ui/queue_ui.py +141 -0
  103. vtx/ui/selection_mode.py +18 -0
  104. vtx/ui/session_ui.py +235 -0
  105. vtx/ui/startup.py +124 -0
  106. vtx/ui/styles.py +327 -0
  107. vtx/ui/tool_output.py +34 -0
  108. vtx/ui/tree.py +437 -0
  109. vtx/ui/welcome.py +51 -0
  110. vtx/ui/widgets.py +558 -0
  111. vtx/update_check.py +49 -0
  112. vtx/version.py +22 -0
  113. vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
  114. vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
  115. vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
  116. vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
  117. 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()