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.
- tau/__init__.py +0 -0
- tau/agent/__init__.py +11 -0
- tau/agent/prompt/__init__.py +10 -0
- tau/agent/prompt/builder.py +302 -0
- tau/agent/prompt/types.py +33 -0
- tau/agent/service.py +369 -0
- tau/agent/types.py +61 -0
- tau/auth/manager.py +247 -0
- tau/auth/storage.py +82 -0
- tau/auth/types.py +41 -0
- tau/builtins/__init__.py +4 -0
- tau/builtins/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/__pycache__/__init__.cpython-314.pyc +0 -0
- tau/builtins/commands/__init__.py +41 -0
- tau/builtins/commands/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/__init__.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/clear.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/clear.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/compact.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/compact.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/reload.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/reload.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/session.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/session.cpython-314.pyc +0 -0
- tau/builtins/commands/clear.py +16 -0
- tau/builtins/commands/compact.py +28 -0
- tau/builtins/commands/reload.py +27 -0
- tau/builtins/commands/session.py +19 -0
- tau/builtins/extensions/footer/__init__.py +76 -0
- tau/builtins/extensions/footer/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/__pycache__/git.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/__pycache__/model.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/__pycache__/utils.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/git.py +26 -0
- tau/builtins/extensions/footer/model.py +69 -0
- tau/builtins/extensions/footer/utils.py +44 -0
- tau/builtins/extensions/header/__init__.py +18 -0
- tau/builtins/extensions/header/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/models/__init__.py +0 -0
- tau/builtins/models/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/models/__pycache__/text.cpython-313.pyc +0 -0
- tau/builtins/models/audio.py +43 -0
- tau/builtins/models/image.py +43 -0
- tau/builtins/models/text.py +482 -0
- tau/builtins/models/video.py +40 -0
- tau/builtins/prompts/commit.md +7 -0
- tau/builtins/prompts/docs.md +7 -0
- tau/builtins/prompts/explain.md +7 -0
- tau/builtins/prompts/fix.md +7 -0
- tau/builtins/prompts/refactor.md +7 -0
- tau/builtins/prompts/review.md +7 -0
- tau/builtins/prompts/test.md +7 -0
- tau/builtins/providers/__init__.py +0 -0
- tau/builtins/providers/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/providers/__pycache__/text.cpython-313.pyc +0 -0
- tau/builtins/providers/audio.py +10 -0
- tau/builtins/providers/image.py +9 -0
- tau/builtins/providers/text.py +33 -0
- tau/builtins/providers/video.py +6 -0
- tau/builtins/skills/code-review/SKILL.md +4 -0
- tau/builtins/skills/debug/SKILL.md +4 -0
- tau/builtins/skills/git-commit/SKILL.md +4 -0
- tau/builtins/themes/dark.yaml +1 -0
- tau/builtins/themes/light.yaml +46 -0
- tau/builtins/tools/__init__.py +73 -0
- tau/builtins/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/bash.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/bash.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/edit.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/edit.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/glob.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/glob.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/grep.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/grep.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/ls.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/ls.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/read.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/read.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/terminal.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/terminal.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/write.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/write.cpython-314.pyc +0 -0
- tau/builtins/tools/edit.py +215 -0
- tau/builtins/tools/glob.py +112 -0
- tau/builtins/tools/grep.py +146 -0
- tau/builtins/tools/ls.py +135 -0
- tau/builtins/tools/read.py +122 -0
- tau/builtins/tools/terminal.py +150 -0
- tau/builtins/tools/write.py +105 -0
- tau/commands/__init__.py +10 -0
- tau/commands/registry.py +71 -0
- tau/commands/types.py +33 -0
- tau/console/__init__.py +0 -0
- tau/console/cli.py +266 -0
- tau/console/commands/__init__.py +0 -0
- tau/console/commands/auth.py +193 -0
- tau/console/commands/packages.py +104 -0
- tau/console/commands/update.py +76 -0
- tau/core/__init__.py +0 -0
- tau/core/registry.py +102 -0
- tau/engine/__init__.py +47 -0
- tau/engine/service.py +768 -0
- tau/engine/types.py +163 -0
- tau/extensions/__init__.py +28 -0
- tau/extensions/api.py +928 -0
- tau/extensions/context.py +462 -0
- tau/extensions/events.py +70 -0
- tau/extensions/loader.py +386 -0
- tau/extensions/runtime.py +184 -0
- tau/extensions/settings.py +137 -0
- tau/hooks/__init__.py +112 -0
- tau/hooks/engine.py +237 -0
- tau/hooks/inference.py +21 -0
- tau/hooks/runtime.py +126 -0
- tau/hooks/service.py +121 -0
- tau/hooks/session.py +117 -0
- tau/hooks/tui.py +61 -0
- tau/hooks/types.py +72 -0
- tau/inference/__init__.py +180 -0
- tau/inference/api/__init__.py +0 -0
- tau/inference/api/audio/__init__.py +0 -0
- tau/inference/api/audio/base.py +29 -0
- tau/inference/api/audio/builtins.py +15 -0
- tau/inference/api/audio/elevenlabs_audio.py +183 -0
- tau/inference/api/audio/gemini_audio.py +95 -0
- tau/inference/api/audio/openai_audio.py +159 -0
- tau/inference/api/audio/registry.py +15 -0
- tau/inference/api/audio/sarvam_audio.py +163 -0
- tau/inference/api/audio/service.py +103 -0
- tau/inference/api/audio/utils.py +47 -0
- tau/inference/api/image/__init__.py +0 -0
- tau/inference/api/image/base.py +17 -0
- tau/inference/api/image/builtins.py +8 -0
- tau/inference/api/image/gemini_image.py +77 -0
- tau/inference/api/image/openai_image.py +103 -0
- tau/inference/api/image/openrouter.py +144 -0
- tau/inference/api/image/registry.py +15 -0
- tau/inference/api/image/service.py +71 -0
- tau/inference/api/registry.py +82 -0
- tau/inference/api/text/__init__.py +0 -0
- tau/inference/api/text/anthropic_claude_code.py +222 -0
- tau/inference/api/text/anthropic_messages.py +196 -0
- tau/inference/api/text/base.py +40 -0
- tau/inference/api/text/builtins.py +19 -0
- tau/inference/api/text/gemini_generate.py +234 -0
- tau/inference/api/text/github_copilot_chat.py +172 -0
- tau/inference/api/text/google_antigravity.py +522 -0
- tau/inference/api/text/mistral_chat.py +284 -0
- tau/inference/api/text/ollama_chat.py +200 -0
- tau/inference/api/text/openai_codex_responses.py +497 -0
- tau/inference/api/text/openai_completions.py +227 -0
- tau/inference/api/text/openai_responses.py +235 -0
- tau/inference/api/text/registry.py +50 -0
- tau/inference/api/text/service.py +297 -0
- tau/inference/api/text/types.py +7 -0
- tau/inference/api/text/utils.py +228 -0
- tau/inference/api/video/__init__.py +0 -0
- tau/inference/api/video/base.py +26 -0
- tau/inference/api/video/builtins.py +7 -0
- tau/inference/api/video/fal_video.py +119 -0
- tau/inference/api/video/openrouter_video.py +142 -0
- tau/inference/api/video/registry.py +15 -0
- tau/inference/api/video/service.py +72 -0
- tau/inference/model/__init__.py +0 -0
- tau/inference/model/registry.py +102 -0
- tau/inference/model/types.py +65 -0
- tau/inference/provider/__init__.py +0 -0
- tau/inference/provider/oauth/__init__.py +35 -0
- tau/inference/provider/oauth/anthropic_claude_code.py +286 -0
- tau/inference/provider/oauth/github_copilot.py +333 -0
- tau/inference/provider/oauth/google_antigravity.py +258 -0
- tau/inference/provider/oauth/openai_codex.py +309 -0
- tau/inference/provider/oauth/pkce.py +14 -0
- tau/inference/provider/oauth/types.py +46 -0
- tau/inference/provider/oauth/utils.py +154 -0
- tau/inference/provider/registry.py +141 -0
- tau/inference/provider/types.py +114 -0
- tau/inference/types.py +549 -0
- tau/inference/utils.py +219 -0
- tau/message/__init__.py +0 -0
- tau/message/types.py +482 -0
- tau/message/utils.py +178 -0
- tau/packages/__init__.py +11 -0
- tau/packages/manager.py +190 -0
- tau/packages/types.py +20 -0
- tau/packages/utils.py +67 -0
- tau/prompts/expand.py +58 -0
- tau/prompts/loader.py +69 -0
- tau/prompts/registry.py +45 -0
- tau/prompts/types.py +24 -0
- tau/rpc/__init__.py +8 -0
- tau/rpc/mode.py +783 -0
- tau/rpc/types.py +252 -0
- tau/runtime/service.py +759 -0
- tau/runtime/types.py +303 -0
- tau/session/branch_summarization.py +312 -0
- tau/session/compaction.py +646 -0
- tau/session/manager.py +652 -0
- tau/session/types.py +188 -0
- tau/session/utils.py +233 -0
- tau/settings/manager.py +1077 -0
- tau/settings/paths.py +150 -0
- tau/settings/storage.py +63 -0
- tau/settings/types.py +173 -0
- tau/settings/utils.py +25 -0
- tau/skills/loader.py +91 -0
- tau/skills/registry.py +70 -0
- tau/skills/types.py +25 -0
- tau/themes/loader.py +238 -0
- tau/themes/registry.py +108 -0
- tau/themes/types.py +19 -0
- tau/tool/__init__.py +3 -0
- tau/tool/registry.py +117 -0
- tau/tool/render.py +21 -0
- tau/tool/types.py +244 -0
- tau/trust/__init__.py +13 -0
- tau/trust/manager.py +80 -0
- tau/trust/types.py +14 -0
- tau/trust/utils.py +72 -0
- tau/tui/__init__.py +54 -0
- tau/tui/agent_hooks.py +346 -0
- tau/tui/ansi.py +330 -0
- tau/tui/app.py +540 -0
- tau/tui/autocomplete.py +33 -0
- tau/tui/capabilities.py +119 -0
- tau/tui/commands/__init__.py +3 -0
- tau/tui/commands/appearance.py +498 -0
- tau/tui/commands/auth.py +232 -0
- tau/tui/commands/context.py +38 -0
- tau/tui/commands/misc.py +82 -0
- tau/tui/commands/model.py +118 -0
- tau/tui/commands/session.py +464 -0
- tau/tui/component.py +268 -0
- tau/tui/components/__init__.py +0 -0
- tau/tui/components/autocomplete_manager.py +267 -0
- tau/tui/components/autocomplete_picker.py +143 -0
- tau/tui/components/box.py +90 -0
- tau/tui/components/command_palette.py +144 -0
- tau/tui/components/dynamic_border.py +19 -0
- tau/tui/components/file_picker.py +233 -0
- tau/tui/components/image.py +181 -0
- tau/tui/components/inline_selector.py +71 -0
- tau/tui/components/layout.py +1194 -0
- tau/tui/components/message_list.py +692 -0
- tau/tui/components/modal.py +97 -0
- tau/tui/components/model_palette.py +204 -0
- tau/tui/components/picker_overlay.py +174 -0
- tau/tui/components/prompt_overlay.py +236 -0
- tau/tui/components/resume_modal.py +372 -0
- tau/tui/components/select_list.py +222 -0
- tau/tui/components/settings_modal.py +274 -0
- tau/tui/components/settings_schema.py +203 -0
- tau/tui/components/spinner.py +119 -0
- tau/tui/components/text_input.py +396 -0
- tau/tui/components/text_prompt.py +82 -0
- tau/tui/components/tree_select_list.py +580 -0
- tau/tui/components/trust_screen.py +97 -0
- tau/tui/diff.py +114 -0
- tau/tui/fuzzy.py +99 -0
- tau/tui/input.py +496 -0
- tau/tui/input_handler.py +716 -0
- tau/tui/keybindings.py +87 -0
- tau/tui/markdown.py +286 -0
- tau/tui/message_renderers.py +31 -0
- tau/tui/overlay.py +326 -0
- tau/tui/renderer.py +378 -0
- tau/tui/terminal.py +499 -0
- tau/tui/theme.py +148 -0
- tau/tui/tui.py +544 -0
- tau/tui/ui_context.py +768 -0
- tau/tui/utils.py +20 -0
- tau/utils/__init__.py +0 -0
- tau/utils/http_proxy.py +221 -0
- tau/utils/image_processing.py +172 -0
- tau/utils/secrets.py +59 -0
- tau/utils/version_check.py +60 -0
- tau_coding_agent-0.1.0.dist-info/METADATA +177 -0
- tau_coding_agent-0.1.0.dist-info/RECORD +283 -0
- tau_coding_agent-0.1.0.dist-info/WHEEL +5 -0
- tau_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- tau_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
tau/utils/http_proxy.py
ADDED
|
@@ -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
|