tau-coding-agent 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.
Files changed (283) hide show
  1. tau/__init__.py +0 -0
  2. tau/agent/__init__.py +11 -0
  3. tau/agent/prompt/__init__.py +10 -0
  4. tau/agent/prompt/builder.py +302 -0
  5. tau/agent/prompt/types.py +33 -0
  6. tau/agent/service.py +369 -0
  7. tau/agent/types.py +61 -0
  8. tau/auth/manager.py +247 -0
  9. tau/auth/storage.py +82 -0
  10. tau/auth/types.py +41 -0
  11. tau/builtins/__init__.py +4 -0
  12. tau/builtins/__pycache__/__init__.cpython-313.pyc +0 -0
  13. tau/builtins/__pycache__/__init__.cpython-314.pyc +0 -0
  14. tau/builtins/commands/__init__.py +41 -0
  15. tau/builtins/commands/__pycache__/__init__.cpython-313.pyc +0 -0
  16. tau/builtins/commands/__pycache__/__init__.cpython-314.pyc +0 -0
  17. tau/builtins/commands/__pycache__/clear.cpython-313.pyc +0 -0
  18. tau/builtins/commands/__pycache__/clear.cpython-314.pyc +0 -0
  19. tau/builtins/commands/__pycache__/compact.cpython-313.pyc +0 -0
  20. tau/builtins/commands/__pycache__/compact.cpython-314.pyc +0 -0
  21. tau/builtins/commands/__pycache__/reload.cpython-313.pyc +0 -0
  22. tau/builtins/commands/__pycache__/reload.cpython-314.pyc +0 -0
  23. tau/builtins/commands/__pycache__/session.cpython-313.pyc +0 -0
  24. tau/builtins/commands/__pycache__/session.cpython-314.pyc +0 -0
  25. tau/builtins/commands/clear.py +16 -0
  26. tau/builtins/commands/compact.py +28 -0
  27. tau/builtins/commands/reload.py +27 -0
  28. tau/builtins/commands/session.py +19 -0
  29. tau/builtins/extensions/footer/__init__.py +76 -0
  30. tau/builtins/extensions/footer/__pycache__/__init__.cpython-313.pyc +0 -0
  31. tau/builtins/extensions/footer/__pycache__/git.cpython-313.pyc +0 -0
  32. tau/builtins/extensions/footer/__pycache__/model.cpython-313.pyc +0 -0
  33. tau/builtins/extensions/footer/__pycache__/utils.cpython-313.pyc +0 -0
  34. tau/builtins/extensions/footer/git.py +26 -0
  35. tau/builtins/extensions/footer/model.py +69 -0
  36. tau/builtins/extensions/footer/utils.py +44 -0
  37. tau/builtins/extensions/header/__init__.py +18 -0
  38. tau/builtins/extensions/header/__pycache__/__init__.cpython-313.pyc +0 -0
  39. tau/builtins/models/__init__.py +0 -0
  40. tau/builtins/models/__pycache__/__init__.cpython-313.pyc +0 -0
  41. tau/builtins/models/__pycache__/text.cpython-313.pyc +0 -0
  42. tau/builtins/models/audio.py +43 -0
  43. tau/builtins/models/image.py +43 -0
  44. tau/builtins/models/text.py +482 -0
  45. tau/builtins/models/video.py +40 -0
  46. tau/builtins/prompts/commit.md +7 -0
  47. tau/builtins/prompts/docs.md +7 -0
  48. tau/builtins/prompts/explain.md +7 -0
  49. tau/builtins/prompts/fix.md +7 -0
  50. tau/builtins/prompts/refactor.md +7 -0
  51. tau/builtins/prompts/review.md +7 -0
  52. tau/builtins/prompts/test.md +7 -0
  53. tau/builtins/providers/__init__.py +0 -0
  54. tau/builtins/providers/__pycache__/__init__.cpython-313.pyc +0 -0
  55. tau/builtins/providers/__pycache__/text.cpython-313.pyc +0 -0
  56. tau/builtins/providers/audio.py +10 -0
  57. tau/builtins/providers/image.py +9 -0
  58. tau/builtins/providers/text.py +33 -0
  59. tau/builtins/providers/video.py +6 -0
  60. tau/builtins/skills/code-review/SKILL.md +4 -0
  61. tau/builtins/skills/debug/SKILL.md +4 -0
  62. tau/builtins/skills/git-commit/SKILL.md +4 -0
  63. tau/builtins/themes/dark.yaml +1 -0
  64. tau/builtins/themes/light.yaml +46 -0
  65. tau/builtins/tools/__init__.py +73 -0
  66. tau/builtins/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  67. tau/builtins/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  68. tau/builtins/tools/__pycache__/bash.cpython-313.pyc +0 -0
  69. tau/builtins/tools/__pycache__/bash.cpython-314.pyc +0 -0
  70. tau/builtins/tools/__pycache__/edit.cpython-313.pyc +0 -0
  71. tau/builtins/tools/__pycache__/edit.cpython-314.pyc +0 -0
  72. tau/builtins/tools/__pycache__/glob.cpython-313.pyc +0 -0
  73. tau/builtins/tools/__pycache__/glob.cpython-314.pyc +0 -0
  74. tau/builtins/tools/__pycache__/grep.cpython-313.pyc +0 -0
  75. tau/builtins/tools/__pycache__/grep.cpython-314.pyc +0 -0
  76. tau/builtins/tools/__pycache__/ls.cpython-313.pyc +0 -0
  77. tau/builtins/tools/__pycache__/ls.cpython-314.pyc +0 -0
  78. tau/builtins/tools/__pycache__/read.cpython-313.pyc +0 -0
  79. tau/builtins/tools/__pycache__/read.cpython-314.pyc +0 -0
  80. tau/builtins/tools/__pycache__/terminal.cpython-313.pyc +0 -0
  81. tau/builtins/tools/__pycache__/terminal.cpython-314.pyc +0 -0
  82. tau/builtins/tools/__pycache__/write.cpython-313.pyc +0 -0
  83. tau/builtins/tools/__pycache__/write.cpython-314.pyc +0 -0
  84. tau/builtins/tools/edit.py +215 -0
  85. tau/builtins/tools/glob.py +112 -0
  86. tau/builtins/tools/grep.py +146 -0
  87. tau/builtins/tools/ls.py +135 -0
  88. tau/builtins/tools/read.py +122 -0
  89. tau/builtins/tools/terminal.py +150 -0
  90. tau/builtins/tools/write.py +105 -0
  91. tau/commands/__init__.py +10 -0
  92. tau/commands/registry.py +71 -0
  93. tau/commands/types.py +33 -0
  94. tau/console/__init__.py +0 -0
  95. tau/console/cli.py +266 -0
  96. tau/console/commands/__init__.py +0 -0
  97. tau/console/commands/auth.py +193 -0
  98. tau/console/commands/packages.py +104 -0
  99. tau/console/commands/update.py +76 -0
  100. tau/core/__init__.py +0 -0
  101. tau/core/registry.py +102 -0
  102. tau/engine/__init__.py +47 -0
  103. tau/engine/service.py +768 -0
  104. tau/engine/types.py +163 -0
  105. tau/extensions/__init__.py +28 -0
  106. tau/extensions/api.py +928 -0
  107. tau/extensions/context.py +462 -0
  108. tau/extensions/events.py +70 -0
  109. tau/extensions/loader.py +386 -0
  110. tau/extensions/runtime.py +184 -0
  111. tau/extensions/settings.py +137 -0
  112. tau/hooks/__init__.py +112 -0
  113. tau/hooks/engine.py +237 -0
  114. tau/hooks/inference.py +21 -0
  115. tau/hooks/runtime.py +126 -0
  116. tau/hooks/service.py +121 -0
  117. tau/hooks/session.py +117 -0
  118. tau/hooks/tui.py +61 -0
  119. tau/hooks/types.py +72 -0
  120. tau/inference/__init__.py +180 -0
  121. tau/inference/api/__init__.py +0 -0
  122. tau/inference/api/audio/__init__.py +0 -0
  123. tau/inference/api/audio/base.py +29 -0
  124. tau/inference/api/audio/builtins.py +15 -0
  125. tau/inference/api/audio/elevenlabs_audio.py +183 -0
  126. tau/inference/api/audio/gemini_audio.py +95 -0
  127. tau/inference/api/audio/openai_audio.py +159 -0
  128. tau/inference/api/audio/registry.py +15 -0
  129. tau/inference/api/audio/sarvam_audio.py +163 -0
  130. tau/inference/api/audio/service.py +103 -0
  131. tau/inference/api/audio/utils.py +47 -0
  132. tau/inference/api/image/__init__.py +0 -0
  133. tau/inference/api/image/base.py +17 -0
  134. tau/inference/api/image/builtins.py +8 -0
  135. tau/inference/api/image/gemini_image.py +77 -0
  136. tau/inference/api/image/openai_image.py +103 -0
  137. tau/inference/api/image/openrouter.py +144 -0
  138. tau/inference/api/image/registry.py +15 -0
  139. tau/inference/api/image/service.py +71 -0
  140. tau/inference/api/registry.py +82 -0
  141. tau/inference/api/text/__init__.py +0 -0
  142. tau/inference/api/text/anthropic_claude_code.py +222 -0
  143. tau/inference/api/text/anthropic_messages.py +196 -0
  144. tau/inference/api/text/base.py +40 -0
  145. tau/inference/api/text/builtins.py +19 -0
  146. tau/inference/api/text/gemini_generate.py +234 -0
  147. tau/inference/api/text/github_copilot_chat.py +172 -0
  148. tau/inference/api/text/google_antigravity.py +522 -0
  149. tau/inference/api/text/mistral_chat.py +284 -0
  150. tau/inference/api/text/ollama_chat.py +200 -0
  151. tau/inference/api/text/openai_codex_responses.py +497 -0
  152. tau/inference/api/text/openai_completions.py +227 -0
  153. tau/inference/api/text/openai_responses.py +235 -0
  154. tau/inference/api/text/registry.py +50 -0
  155. tau/inference/api/text/service.py +297 -0
  156. tau/inference/api/text/types.py +7 -0
  157. tau/inference/api/text/utils.py +228 -0
  158. tau/inference/api/video/__init__.py +0 -0
  159. tau/inference/api/video/base.py +26 -0
  160. tau/inference/api/video/builtins.py +7 -0
  161. tau/inference/api/video/fal_video.py +119 -0
  162. tau/inference/api/video/openrouter_video.py +142 -0
  163. tau/inference/api/video/registry.py +15 -0
  164. tau/inference/api/video/service.py +72 -0
  165. tau/inference/model/__init__.py +0 -0
  166. tau/inference/model/registry.py +102 -0
  167. tau/inference/model/types.py +65 -0
  168. tau/inference/provider/__init__.py +0 -0
  169. tau/inference/provider/oauth/__init__.py +35 -0
  170. tau/inference/provider/oauth/anthropic_claude_code.py +286 -0
  171. tau/inference/provider/oauth/github_copilot.py +333 -0
  172. tau/inference/provider/oauth/google_antigravity.py +258 -0
  173. tau/inference/provider/oauth/openai_codex.py +309 -0
  174. tau/inference/provider/oauth/pkce.py +14 -0
  175. tau/inference/provider/oauth/types.py +46 -0
  176. tau/inference/provider/oauth/utils.py +154 -0
  177. tau/inference/provider/registry.py +141 -0
  178. tau/inference/provider/types.py +114 -0
  179. tau/inference/types.py +549 -0
  180. tau/inference/utils.py +219 -0
  181. tau/message/__init__.py +0 -0
  182. tau/message/types.py +482 -0
  183. tau/message/utils.py +178 -0
  184. tau/packages/__init__.py +11 -0
  185. tau/packages/manager.py +190 -0
  186. tau/packages/types.py +20 -0
  187. tau/packages/utils.py +67 -0
  188. tau/prompts/expand.py +58 -0
  189. tau/prompts/loader.py +69 -0
  190. tau/prompts/registry.py +45 -0
  191. tau/prompts/types.py +24 -0
  192. tau/rpc/__init__.py +8 -0
  193. tau/rpc/mode.py +783 -0
  194. tau/rpc/types.py +252 -0
  195. tau/runtime/service.py +759 -0
  196. tau/runtime/types.py +303 -0
  197. tau/session/branch_summarization.py +312 -0
  198. tau/session/compaction.py +646 -0
  199. tau/session/manager.py +652 -0
  200. tau/session/types.py +188 -0
  201. tau/session/utils.py +233 -0
  202. tau/settings/manager.py +1077 -0
  203. tau/settings/paths.py +150 -0
  204. tau/settings/storage.py +63 -0
  205. tau/settings/types.py +173 -0
  206. tau/settings/utils.py +25 -0
  207. tau/skills/loader.py +91 -0
  208. tau/skills/registry.py +70 -0
  209. tau/skills/types.py +25 -0
  210. tau/themes/loader.py +238 -0
  211. tau/themes/registry.py +108 -0
  212. tau/themes/types.py +19 -0
  213. tau/tool/__init__.py +3 -0
  214. tau/tool/registry.py +117 -0
  215. tau/tool/render.py +21 -0
  216. tau/tool/types.py +244 -0
  217. tau/trust/__init__.py +13 -0
  218. tau/trust/manager.py +80 -0
  219. tau/trust/types.py +14 -0
  220. tau/trust/utils.py +72 -0
  221. tau/tui/__init__.py +54 -0
  222. tau/tui/agent_hooks.py +346 -0
  223. tau/tui/ansi.py +330 -0
  224. tau/tui/app.py +540 -0
  225. tau/tui/autocomplete.py +33 -0
  226. tau/tui/capabilities.py +119 -0
  227. tau/tui/commands/__init__.py +3 -0
  228. tau/tui/commands/appearance.py +498 -0
  229. tau/tui/commands/auth.py +232 -0
  230. tau/tui/commands/context.py +38 -0
  231. tau/tui/commands/misc.py +82 -0
  232. tau/tui/commands/model.py +118 -0
  233. tau/tui/commands/session.py +464 -0
  234. tau/tui/component.py +268 -0
  235. tau/tui/components/__init__.py +0 -0
  236. tau/tui/components/autocomplete_manager.py +267 -0
  237. tau/tui/components/autocomplete_picker.py +143 -0
  238. tau/tui/components/box.py +90 -0
  239. tau/tui/components/command_palette.py +144 -0
  240. tau/tui/components/dynamic_border.py +19 -0
  241. tau/tui/components/file_picker.py +233 -0
  242. tau/tui/components/image.py +181 -0
  243. tau/tui/components/inline_selector.py +71 -0
  244. tau/tui/components/layout.py +1194 -0
  245. tau/tui/components/message_list.py +692 -0
  246. tau/tui/components/modal.py +97 -0
  247. tau/tui/components/model_palette.py +204 -0
  248. tau/tui/components/picker_overlay.py +174 -0
  249. tau/tui/components/prompt_overlay.py +236 -0
  250. tau/tui/components/resume_modal.py +372 -0
  251. tau/tui/components/select_list.py +222 -0
  252. tau/tui/components/settings_modal.py +274 -0
  253. tau/tui/components/settings_schema.py +203 -0
  254. tau/tui/components/spinner.py +119 -0
  255. tau/tui/components/text_input.py +396 -0
  256. tau/tui/components/text_prompt.py +82 -0
  257. tau/tui/components/tree_select_list.py +580 -0
  258. tau/tui/components/trust_screen.py +97 -0
  259. tau/tui/diff.py +114 -0
  260. tau/tui/fuzzy.py +99 -0
  261. tau/tui/input.py +496 -0
  262. tau/tui/input_handler.py +716 -0
  263. tau/tui/keybindings.py +87 -0
  264. tau/tui/markdown.py +286 -0
  265. tau/tui/message_renderers.py +31 -0
  266. tau/tui/overlay.py +326 -0
  267. tau/tui/renderer.py +378 -0
  268. tau/tui/terminal.py +499 -0
  269. tau/tui/theme.py +148 -0
  270. tau/tui/tui.py +544 -0
  271. tau/tui/ui_context.py +768 -0
  272. tau/tui/utils.py +20 -0
  273. tau/utils/__init__.py +0 -0
  274. tau/utils/http_proxy.py +221 -0
  275. tau/utils/image_processing.py +172 -0
  276. tau/utils/secrets.py +59 -0
  277. tau/utils/version_check.py +60 -0
  278. tau_coding_agent-0.1.0.dist-info/METADATA +177 -0
  279. tau_coding_agent-0.1.0.dist-info/RECORD +283 -0
  280. tau_coding_agent-0.1.0.dist-info/WHEEL +5 -0
  281. tau_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  282. tau_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  283. tau_coding_agent-0.1.0.dist-info/top_level.txt +1 -0
tau/tui/utils.py ADDED
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+
7
+ def project_name() -> str:
8
+ """Best-effort project name: git repo root dir name, else cwd dir name."""
9
+ try:
10
+ result = subprocess.run(
11
+ ["git", "rev-parse", "--show-toplevel"],
12
+ capture_output=True,
13
+ text=True,
14
+ timeout=1,
15
+ )
16
+ if result.returncode == 0:
17
+ return Path(result.stdout.strip()).name
18
+ except (OSError, subprocess.SubprocessError):
19
+ pass
20
+ return Path.cwd().name
tau/utils/__init__.py ADDED
File without changes
@@ -0,0 +1,221 @@
1
+ """HTTP proxy resolution from settings or environment variables.
2
+
3
+ Supports proxy configuration from:
4
+ 1. settings.json (http_proxy.http_proxy, http_proxy.https_proxy, http_proxy.no_proxy)
5
+ 2. Environment variables: HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, NO_PROXY (and lowercase variants)
6
+ 3. npm config variants: npm_config_http_proxy, npm_config_https_proxy, etc.
7
+
8
+ Settings.json takes precedence over environment variables.
9
+
10
+ Example:
11
+ from tau.utils.http_proxy import get_proxy_url_for_target
12
+
13
+ proxy_url = get_proxy_url_for_target("https://api.anthropic.com")
14
+ if proxy_url:
15
+ print(f"Using proxy: {proxy_url}")
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from typing import TYPE_CHECKING, Optional
22
+ from urllib.parse import urlparse
23
+
24
+ if TYPE_CHECKING:
25
+ from tau.settings.manager import SettingsManager
26
+
27
+ UNSUPPORTED_PROXY_PROTOCOL_MESSAGE = (
28
+ "Unsupported proxy protocol. SOCKS and PAC proxy URLs are not supported; "
29
+ "use an HTTP or HTTPS proxy URL."
30
+ )
31
+
32
+ DEFAULT_PROXY_PORTS = {
33
+ "ftp": 21,
34
+ "gopher": 70,
35
+ "http": 80,
36
+ "https": 443,
37
+ "ws": 80,
38
+ "wss": 443,
39
+ }
40
+
41
+
42
+ def _get_proxy_env(key: str) -> str:
43
+ """Get proxy environment variable (case-insensitive)."""
44
+ return os.environ.get(key.lower(), "") or os.environ.get(key.upper(), "")
45
+
46
+
47
+ def _parse_proxy_target_url(target_url: str) -> Optional[tuple[str, str, int]]:
48
+ """Parse target URL into (scheme, hostname, port) or None if invalid."""
49
+ try:
50
+ parsed = urlparse(target_url)
51
+ if not parsed.scheme or not parsed.hostname:
52
+ return None
53
+ scheme = parsed.scheme
54
+ hostname = parsed.hostname
55
+ port = parsed.port or DEFAULT_PROXY_PORTS.get(scheme, 0)
56
+ return (scheme, hostname, port)
57
+ except Exception:
58
+ return None
59
+
60
+
61
+ def _should_proxy_hostname(hostname: str, port: int, no_proxy: Optional[str] = None) -> bool:
62
+ """Check if hostname should be proxied (respecting NO_PROXY).
63
+
64
+ Args:
65
+ hostname: Hostname to check
66
+ port: Port number
67
+ no_proxy: Optional NO_PROXY string. If not provided, checks environment variables.
68
+ """
69
+ if no_proxy is None:
70
+ no_proxy = _get_proxy_env("no_proxy").lower()
71
+ else:
72
+ no_proxy = no_proxy.lower()
73
+
74
+ if not no_proxy:
75
+ return True
76
+ if no_proxy == "*":
77
+ return False
78
+
79
+ # NO_PROXY is comma or space-separated list of hosts/patterns to exclude
80
+ for entry in no_proxy.split(","):
81
+ entry = entry.strip()
82
+ if not entry:
83
+ continue
84
+
85
+ # Parse host:port format
86
+ if ":" in entry:
87
+ proxy_host, proxy_port_str = entry.rsplit(":", 1)
88
+ try:
89
+ proxy_port = int(proxy_port_str)
90
+ if proxy_port != port:
91
+ continue # Port mismatch, check next entry
92
+ except ValueError:
93
+ proxy_host = entry
94
+ proxy_port = None
95
+ else:
96
+ proxy_host = entry
97
+ proxy_port = None
98
+
99
+ # Match hostname (supports wildcards like *.example.com)
100
+ if proxy_host.startswith("*."):
101
+ # Wildcard match: *.example.com matches sub.example.com
102
+ if hostname.endswith(proxy_host[1:]): # Remove leading * and keep .
103
+ return False
104
+ elif proxy_host == hostname:
105
+ return False
106
+
107
+ return True
108
+
109
+
110
+ def get_proxy_url_for_target(target_url: str, settings_manager: Optional[SettingsManager] = None) -> Optional[str]:
111
+ """
112
+ Get HTTP proxy URL for a target URL from settings or environment variables.
113
+
114
+ Checks in order:
115
+ 1. Settings (from settings.json http_proxy.url) - takes precedence
116
+ 2. Environment variables (HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, etc.)
117
+
118
+ Args:
119
+ target_url: Target URL to find proxy for (e.g., "https://api.anthropic.com")
120
+ settings_manager: Optional SettingsManager to check for http_proxy settings
121
+
122
+ Returns:
123
+ Proxy URL (e.g., "http://proxy.example.com:8080") or None if no proxy
124
+
125
+ Raises:
126
+ ValueError: If proxy URL uses unsupported protocol (SOCKS, PAC)
127
+ """
128
+ parsed = _parse_proxy_target_url(target_url)
129
+ if not parsed:
130
+ return None
131
+
132
+ protocol, hostname, port = parsed
133
+
134
+ # Check settings first (takes precedence over env vars)
135
+ if settings_manager:
136
+ settings_proxy = settings_manager.get_proxy_url()
137
+ if settings_proxy:
138
+ # Check NO_PROXY exclusions from settings
139
+ no_proxy = settings_manager.get_no_proxy()
140
+ if not _should_proxy_hostname(hostname, port, no_proxy):
141
+ return None
142
+ _validate_proxy_url(settings_proxy)
143
+ return settings_proxy
144
+
145
+ # Fall back to environment variables
146
+ if not _should_proxy_hostname(hostname, port):
147
+ return None
148
+
149
+ proxy = _get_proxy_env(f"{protocol}_proxy") or _get_proxy_env("all_proxy")
150
+ if not proxy:
151
+ return None
152
+
153
+ if "://" not in proxy:
154
+ # Add scheme if missing
155
+ proxy = f"{protocol}://{proxy}"
156
+
157
+ _validate_proxy_url(proxy)
158
+ return proxy
159
+
160
+
161
+ def _validate_proxy_url(proxy: str) -> None:
162
+ """Validate proxy URL format and protocol."""
163
+ try:
164
+ parsed = urlparse(proxy)
165
+ except Exception as e:
166
+ raise ValueError(f"Invalid proxy URL {proxy!r}: {e}")
167
+
168
+ if parsed.scheme not in ("http", "https"):
169
+ raise ValueError(f"{UNSUPPORTED_PROXY_PROTOCOL_MESSAGE} Got {parsed.scheme!r}")
170
+
171
+
172
+ def get_proxies_for_client(
173
+ api_base_url: str, settings_manager: Optional[SettingsManager] = None
174
+ ) -> Optional[dict[str, str]]:
175
+ """
176
+ Get proxy configuration dict for httpx.AsyncClient or requests.
177
+
178
+ Returns a dict suitable for httpx:
179
+ async with httpx.AsyncClient(proxies=proxies) as client:
180
+ ...
181
+
182
+ Args:
183
+ api_base_url: Base URL of the API (e.g., "https://api.anthropic.com")
184
+ settings_manager: Optional SettingsManager to check for http_proxy settings
185
+
186
+ Returns:
187
+ {"http://": proxy_url, "https://": proxy_url} or None if no proxy needed
188
+
189
+ Raises:
190
+ ValueError: If proxy URL uses unsupported protocol
191
+ """
192
+ proxy_url = get_proxy_url_for_target(api_base_url, settings_manager)
193
+ if not proxy_url:
194
+ return None
195
+
196
+ # httpx expects separate keys for http and https
197
+ return {
198
+ "http://": proxy_url,
199
+ "https://": proxy_url,
200
+ }
201
+
202
+
203
+ def get_proxy_headers(settings_manager: Optional[SettingsManager] = None) -> Optional[dict[str, str]]:
204
+ """
205
+ Get custom proxy headers for proxy authentication.
206
+
207
+ Some corporate proxies require custom headers for authentication (e.g., "Proxy-Authorization").
208
+
209
+ Args:
210
+ settings_manager: Optional SettingsManager to check for custom proxy headers
211
+
212
+ Returns:
213
+ Dict of custom headers to send to proxy, or None if none configured
214
+
215
+ Example:
216
+ headers = get_proxy_headers(settings_manager)
217
+ proxy_headers = {**(headers or {}), "X-Custom-Auth": "token"}
218
+ """
219
+ if not settings_manager:
220
+ return None
221
+ return settings_manager.get_proxy_headers()
@@ -0,0 +1,172 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ from dataclasses import dataclass
5
+
6
+ # Provider limits — 4.5 MB gives headroom below Anthropic's 5 MB hard limit
7
+ _DEFAULT_MAX_WIDTH = 2000
8
+ _DEFAULT_MAX_HEIGHT = 2000
9
+ _DEFAULT_MAX_BYTES = int(4.5 * 1024 * 1024)
10
+
11
+ _DEFAULT_JPEG_QUALITY = 80
12
+ _JPEG_QUALITY_STEPS = [80, 85, 70, 55, 40]
13
+ _SCALE_STEP = 0.75
14
+
15
+
16
+ @dataclass
17
+ class ProcessedImage:
18
+ data: bytes
19
+ mime_type: str
20
+ original_width: int
21
+ original_height: int
22
+ display_width: int
23
+ display_height: int
24
+
25
+ @property
26
+ def was_resized(self) -> bool:
27
+ return (self.display_width, self.display_height) != (self.original_width, self.original_height)
28
+
29
+ def dimension_note(self) -> str | None:
30
+ """Coordinate-mapping hint for the LLM when the image was scaled down."""
31
+ if not self.was_resized:
32
+ return None
33
+ scale_x = self.original_width / max(1, self.display_width)
34
+ scale_y = self.original_height / max(1, self.display_height)
35
+ scale = round((scale_x + scale_y) / 2, 2)
36
+ return (
37
+ f"[Image: original {self.original_width}x{self.original_height}, "
38
+ f"displayed at {self.display_width}x{self.display_height}. "
39
+ f"Multiply coordinates by {scale} to map to original image.]"
40
+ )
41
+
42
+
43
+ def process_image(
44
+ data: bytes,
45
+ max_width: int = _DEFAULT_MAX_WIDTH,
46
+ max_height: int = _DEFAULT_MAX_HEIGHT,
47
+ max_bytes: int = _DEFAULT_MAX_BYTES,
48
+ auto_resize: bool = True,
49
+ to_png: bool = False,
50
+ jpeg_quality: int = _DEFAULT_JPEG_QUALITY,
51
+ ) -> ProcessedImage:
52
+ """
53
+ Full image processing pipeline:
54
+ 1. EXIF orientation correction
55
+ 2. Resize to fit max_width × max_height (if auto_resize)
56
+ 3. Try both PNG and JPEG, pick whichever is smaller and fits max_bytes;
57
+ if still too large, reduce JPEG quality then scale dimensions down
58
+ 4. Optionally force PNG output (required for Kitty graphics protocol)
59
+
60
+ Returns a ProcessedImage with the processed bytes and dimension metadata.
61
+ """
62
+ from PIL import Image, ImageOps
63
+
64
+ img = Image.open(io.BytesIO(data))
65
+
66
+ # 1. EXIF orientation — one call handles all 8 rotations
67
+ img = ImageOps.exif_transpose(img)
68
+
69
+ original_width, original_height = img.size
70
+
71
+ # 2. Resize to fit provider limits
72
+ if auto_resize:
73
+ img = _resize_to_fit(img, max_width, max_height)
74
+
75
+ display_width, display_height = img.size
76
+
77
+ # 3. Encode, picking the best format within the byte budget
78
+ encoded, mime_type = _encode_best(img, max_bytes, jpeg_quality, force_png=to_png)
79
+
80
+ return ProcessedImage(
81
+ data=encoded,
82
+ mime_type=mime_type,
83
+ original_width=original_width,
84
+ original_height=original_height,
85
+ display_width=display_width,
86
+ display_height=display_height,
87
+ )
88
+
89
+
90
+ def convert_to_png(data: bytes) -> bytes:
91
+ """Convert any supported image format to PNG bytes (for Kitty protocol)."""
92
+ from PIL import Image, ImageOps
93
+ img = ImageOps.exif_transpose(Image.open(io.BytesIO(data)))
94
+ return _encode_png(img)
95
+
96
+
97
+ # ── Helpers ───────────────────────────────────────────────────────────────────
98
+
99
+ def _resize_to_fit(img: "Image.Image", max_w: int, max_h: int) -> "Image.Image":
100
+ from PIL import Image
101
+ w, h = img.size
102
+ if w <= max_w and h <= max_h:
103
+ return img
104
+ scale = min(max_w / w, max_h / h)
105
+ return img.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS)
106
+
107
+
108
+ def _as_rgb(img: "Image.Image") -> "Image.Image":
109
+ if img.mode not in ("RGB", "L"):
110
+ return img.convert("RGB")
111
+ return img
112
+
113
+
114
+ def _encode_png(img: "Image.Image") -> bytes:
115
+ from PIL import Image
116
+ if img.mode not in ("RGB", "RGBA", "L", "LA", "P"):
117
+ img = img.convert("RGBA")
118
+ buf = io.BytesIO()
119
+ img.save(buf, format="PNG", optimize=True)
120
+ return buf.getvalue()
121
+
122
+
123
+ def _encode_jpeg(img: "Image.Image", quality: int) -> bytes:
124
+ buf = io.BytesIO()
125
+ _as_rgb(img).save(buf, format="JPEG", quality=quality)
126
+ return buf.getvalue()
127
+
128
+
129
+ def _encode_best(
130
+ img: "Image.Image",
131
+ max_bytes: int,
132
+ jpeg_quality: int,
133
+ force_png: bool,
134
+ ) -> tuple[bytes, str]:
135
+ """
136
+ Try PNG and JPEG at each candidate size and pick whichever is smaller
137
+ and fits within max_bytes. Mirrors tryEncodings strategy.
138
+ """
139
+ from PIL import Image
140
+
141
+ qualities = list(dict.fromkeys([jpeg_quality] + _JPEG_QUALITY_STEPS))
142
+
143
+ current = img
144
+ while True:
145
+ png = _encode_png(current)
146
+ jpegs = [_encode_jpeg(current, q) for q in qualities]
147
+
148
+ # Collect all candidates that fit, pick the smallest
149
+ candidates: list[tuple[bytes, str]] = []
150
+ if not force_png:
151
+ for j in jpegs:
152
+ if len(j) <= max_bytes:
153
+ candidates.append((j, "image/jpeg"))
154
+ if len(png) <= max_bytes:
155
+ candidates.append((png, "image/png"))
156
+
157
+ if candidates:
158
+ best = min(candidates, key=lambda c: len(c[0]))
159
+ return best
160
+
161
+ # Nothing fit — scale down and retry
162
+ w, h = current.size
163
+ if w <= 1 and h <= 1:
164
+ break
165
+ nw = max(1, int(w * _SCALE_STEP))
166
+ nh = max(1, int(h * _SCALE_STEP))
167
+ if nw == w and nh == h:
168
+ break
169
+ current = img.resize((nw, nh), Image.LANCZOS)
170
+
171
+ # Last resort: return whatever PNG we have
172
+ return _encode_png(img), "image/png"
tau/utils/secrets.py ADDED
@@ -0,0 +1,59 @@
1
+ """Resolve secret references (API keys, tokens, proxy creds) to their values.
2
+
3
+ A "secret reference" is a string that may be one of three forms:
4
+
5
+ - ``"literal-value"`` → returned unchanged
6
+ - ``"$ENV_VAR"`` → read from ``os.environ`` (empty string if unset)
7
+ - ``"!shell command"`` → run in a shell; its trimmed stdout is the value
8
+
9
+ Resolution is **memoized**: a ``$VAR`` / ``!command`` reference is read/executed
10
+ only the first time it is seen, and the resolved value is kept in memory for the
11
+ life of the process. Subsequent lookups return the cached value, so callers on a
12
+ hot path (per-request headers, proxy config) never re-run the command.
13
+
14
+ Failed resolutions (empty result) are *not* cached, so fixing the environment or
15
+ the command and reloading re-resolves rather than being stuck on the empty value.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import subprocess
21
+
22
+ _cache: dict[str, str] = {}
23
+
24
+
25
+ def resolve_secret(value: str | None) -> str:
26
+ """Resolve a single secret reference to its value (memoized). See module docs."""
27
+ if not value:
28
+ return ""
29
+ cached = _cache.get(value)
30
+ if cached is not None:
31
+ return cached
32
+
33
+ if value.startswith("$"):
34
+ resolved = os.environ.get(value[1:], "")
35
+ elif value.startswith("!"):
36
+ resolved = subprocess.run(
37
+ value[1:], shell=True, capture_output=True, text=True
38
+ ).stdout.strip()
39
+ else:
40
+ resolved = value
41
+
42
+ if resolved: # don't cache failures — allow a later retry after a fix
43
+ _cache[value] = resolved
44
+ return resolved
45
+
46
+
47
+ def resolve_secrets(values: dict[str, str] | None) -> dict[str, str]:
48
+ """Resolve every string value in a mapping (e.g. a header dict)."""
49
+ if not values:
50
+ return {}
51
+ return {
52
+ k: resolve_secret(v) if isinstance(v, str) else v
53
+ for k, v in values.items()
54
+ }
55
+
56
+
57
+ def clear_cache() -> None:
58
+ """Forget all memoized resolutions (e.g. for tests or a hard reload)."""
59
+ _cache.clear()
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ _TIMEOUT = 5.0
4
+
5
+
6
+ def _pypi_url() -> str:
7
+ """PyPI JSON endpoint for this app's distribution (keyed on the app name)."""
8
+ from tau.settings.paths import get_package_name
9
+
10
+ return f"https://pypi.org/pypi/{get_package_name()}/json"
11
+
12
+
13
+ def _is_newer(latest: str, current: str) -> bool:
14
+ """True if ``latest`` is a strictly newer release than ``current``.
15
+
16
+ Uses PEP 440 comparison (handles rc/dev/post suffixes correctly). Falls back
17
+ to a naive dotted-int compare if packaging is unavailable or a version string
18
+ is non-standard; on total failure, conservatively reports "not newer".
19
+ """
20
+ try:
21
+ from packaging.version import InvalidVersion, Version
22
+
23
+ try:
24
+ return Version(latest) > Version(current)
25
+ except InvalidVersion:
26
+ pass
27
+ except ImportError:
28
+ pass
29
+
30
+ def _parse(v: str) -> tuple[int, ...]:
31
+ parts = []
32
+ for x in v.strip().split("."):
33
+ try:
34
+ parts.append(int(x))
35
+ except ValueError:
36
+ pass
37
+ return tuple(parts)
38
+
39
+ try:
40
+ return _parse(latest) > _parse(current)
41
+ except Exception:
42
+ return False
43
+
44
+
45
+ async def check_for_new_version(current_version: str) -> str | None:
46
+ """Return the latest PyPI version string if newer than current, else None."""
47
+ try:
48
+ import httpx
49
+
50
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
51
+ resp = await client.get(_pypi_url(), headers={"Accept": "application/json"})
52
+ if not resp.is_success:
53
+ return None
54
+ data = resp.json()
55
+ latest = data.get("info", {}).get("version", "")
56
+ if latest and _is_newer(latest, current_version):
57
+ return latest
58
+ except Exception:
59
+ return None
60
+ return None