deepy-cli 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.
- deepy/__init__.py +9 -0
- deepy/__main__.py +7 -0
- deepy/cli.py +413 -0
- deepy/config/__init__.py +21 -0
- deepy/config/settings.py +237 -0
- deepy/data/__init__.py +1 -0
- deepy/data/tools/AskUserQuestion.md +10 -0
- deepy/data/tools/WebFetch.md +9 -0
- deepy/data/tools/WebSearch.md +9 -0
- deepy/data/tools/__init__.py +1 -0
- deepy/data/tools/bash.md +7 -0
- deepy/data/tools/edit.md +13 -0
- deepy/data/tools/modify.md +17 -0
- deepy/data/tools/read.md +8 -0
- deepy/data/tools/write.md +12 -0
- deepy/errors.py +63 -0
- deepy/llm/__init__.py +13 -0
- deepy/llm/agent.py +31 -0
- deepy/llm/context.py +109 -0
- deepy/llm/events.py +187 -0
- deepy/llm/model_capabilities.py +7 -0
- deepy/llm/provider.py +81 -0
- deepy/llm/replay.py +120 -0
- deepy/llm/runner.py +412 -0
- deepy/llm/thinking.py +30 -0
- deepy/prompts/__init__.py +6 -0
- deepy/prompts/compact.py +100 -0
- deepy/prompts/rules.py +24 -0
- deepy/prompts/runtime_context.py +98 -0
- deepy/prompts/system.py +72 -0
- deepy/prompts/tool_docs.py +21 -0
- deepy/sessions/__init__.py +17 -0
- deepy/sessions/jsonl.py +306 -0
- deepy/sessions/manager.py +202 -0
- deepy/skills.py +202 -0
- deepy/status.py +65 -0
- deepy/tools/__init__.py +6 -0
- deepy/tools/agents.py +343 -0
- deepy/tools/builtin.py +2113 -0
- deepy/tools/file_state.py +85 -0
- deepy/tools/result.py +54 -0
- deepy/tools/shell_utils.py +83 -0
- deepy/ui/__init__.py +5 -0
- deepy/ui/app.py +118 -0
- deepy/ui/ask_user_question.py +182 -0
- deepy/ui/exit_summary.py +142 -0
- deepy/ui/loading_text.py +87 -0
- deepy/ui/markdown.py +152 -0
- deepy/ui/message_view.py +546 -0
- deepy/ui/prompt_buffer.py +176 -0
- deepy/ui/prompt_input.py +286 -0
- deepy/ui/session_list.py +140 -0
- deepy/ui/session_picker.py +179 -0
- deepy/ui/slash_commands.py +67 -0
- deepy/ui/styles.py +21 -0
- deepy/ui/terminal.py +959 -0
- deepy/ui/thinking_state.py +29 -0
- deepy/ui/welcome.py +195 -0
- deepy/update_check.py +195 -0
- deepy/usage.py +192 -0
- deepy/utils/__init__.py +15 -0
- deepy/utils/debug_logger.py +62 -0
- deepy/utils/error_logger.py +107 -0
- deepy/utils/json.py +29 -0
- deepy/utils/notify.py +66 -0
- deepy_cli-0.1.1.dist-info/METADATA +205 -0
- deepy_cli-0.1.1.dist-info/RECORD +69 -0
- deepy_cli-0.1.1.dist-info/WHEEL +4 -0
- deepy_cli-0.1.1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Iterable, Mapping
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def find_expanded_thinking_id(messages: Iterable[Any]) -> str | None:
|
|
7
|
+
expanded: str | None = None
|
|
8
|
+
for message in messages:
|
|
9
|
+
if _field(message, "role") != "assistant":
|
|
10
|
+
continue
|
|
11
|
+
if _is_thinking_message(message):
|
|
12
|
+
message_id = _field(message, "id")
|
|
13
|
+
expanded = message_id if isinstance(message_id, str) else None
|
|
14
|
+
else:
|
|
15
|
+
expanded = None
|
|
16
|
+
return expanded
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _is_thinking_message(message: Any) -> bool:
|
|
20
|
+
meta = _field(message, "meta")
|
|
21
|
+
if isinstance(meta, Mapping):
|
|
22
|
+
return meta.get("asThinking") is True
|
|
23
|
+
return getattr(meta, "asThinking", False) is True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _field(value: Any, name: str) -> Any:
|
|
27
|
+
if isinstance(value, Mapping):
|
|
28
|
+
return value.get(name)
|
|
29
|
+
return getattr(value, name, None)
|
deepy/ui/welcome.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from rich.console import Group
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from deepy.skills import SkillInfo
|
|
13
|
+
from deepy.update_check import VersionUpdate
|
|
14
|
+
from deepy.ui.styles import STYLE_ACCENT, STYLE_ASSISTANT, STYLE_INFO, STYLE_MUTED
|
|
15
|
+
from deepy.ui.slash_commands import (
|
|
16
|
+
BUILTIN_SLASH_COMMANDS,
|
|
17
|
+
format_slash_command_description,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class WelcomeTip:
|
|
23
|
+
label: str
|
|
24
|
+
description: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class WelcomeSetting:
|
|
29
|
+
label: str
|
|
30
|
+
value: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
CORE_WELCOME_COMMANDS = (
|
|
34
|
+
"/resume",
|
|
35
|
+
"/new",
|
|
36
|
+
"/skills",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
CORE_SHORTCUT_TIPS = (
|
|
40
|
+
WelcomeTip("/", "Command menu"),
|
|
41
|
+
WelcomeTip("Esc", "Interrupt turn"),
|
|
42
|
+
WelcomeTip("Ctrl+D twice", "Quit"),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
COMPACT_COMMAND_DESCRIPTIONS = {
|
|
46
|
+
"/skills": "List skills",
|
|
47
|
+
"/new": "New session",
|
|
48
|
+
"/resume": "Resume session",
|
|
49
|
+
"/exit": "Quit",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
DEEPY_ASCII_LOGO = (
|
|
53
|
+
" .-''''-.",
|
|
54
|
+
" .-' >_ '-.",
|
|
55
|
+
" / .----. \\",
|
|
56
|
+
" | | >_ | o |",
|
|
57
|
+
" | '----' |",
|
|
58
|
+
" \\ Deepy /",
|
|
59
|
+
" '-.______.--'",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def format_home_relative_path(value: str | Path, home: str | Path | None = None) -> str:
|
|
64
|
+
path = Path(os.path.abspath(os.path.expanduser(str(value))))
|
|
65
|
+
home_value = str(Path.home()) if home is None else str(home)
|
|
66
|
+
home_path = Path(os.path.abspath(os.path.expanduser(home_value)))
|
|
67
|
+
try:
|
|
68
|
+
relative = path.relative_to(home_path)
|
|
69
|
+
except ValueError:
|
|
70
|
+
return str(path)
|
|
71
|
+
return "~" if str(relative) == "." else f"~/{relative}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_welcome_tips(skills: list[SkillInfo]) -> list[WelcomeTip]:
|
|
75
|
+
del skills
|
|
76
|
+
slash_tips = [
|
|
77
|
+
WelcomeTip(
|
|
78
|
+
label=item.label,
|
|
79
|
+
description=COMPACT_COMMAND_DESCRIPTIONS.get(
|
|
80
|
+
item.label,
|
|
81
|
+
format_slash_command_description(item.description),
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
for item in BUILTIN_SLASH_COMMANDS
|
|
85
|
+
if item.label in CORE_WELCOME_COMMANDS
|
|
86
|
+
]
|
|
87
|
+
return [*slash_tips, *CORE_SHORTCUT_TIPS]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def build_welcome_settings(
|
|
91
|
+
*,
|
|
92
|
+
model: str,
|
|
93
|
+
thinking_enabled: bool,
|
|
94
|
+
reasoning_effort: str,
|
|
95
|
+
project_root: str | Path,
|
|
96
|
+
current_version: str,
|
|
97
|
+
version_update: VersionUpdate | None = None,
|
|
98
|
+
home: str | Path | None = None,
|
|
99
|
+
) -> list[WelcomeSetting]:
|
|
100
|
+
settings = [
|
|
101
|
+
WelcomeSetting("Version", _format_version_value(current_version, version_update)),
|
|
102
|
+
WelcomeSetting("Model", model),
|
|
103
|
+
WelcomeSetting("Thinking", "on" if thinking_enabled else "off"),
|
|
104
|
+
WelcomeSetting("Reasoning", reasoning_effort),
|
|
105
|
+
WelcomeSetting("CWD", format_home_relative_path(project_root, home=home)),
|
|
106
|
+
]
|
|
107
|
+
if version_update is not None:
|
|
108
|
+
settings.append(WelcomeSetting("Update", version_update.install_hint))
|
|
109
|
+
return settings
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _format_version_value(current_version: str, version_update: VersionUpdate | None) -> str:
|
|
113
|
+
if version_update is None:
|
|
114
|
+
return current_version
|
|
115
|
+
return (
|
|
116
|
+
f"{current_version} -> {version_update.latest_version} available "
|
|
117
|
+
f"from {version_update.source}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def build_deepy_ascii_logo() -> Text:
|
|
122
|
+
logo = Text()
|
|
123
|
+
for index, line in enumerate(DEEPY_ASCII_LOGO):
|
|
124
|
+
if index:
|
|
125
|
+
logo.append("\n")
|
|
126
|
+
logo.append(line, style=f"bold {STYLE_ACCENT}" if "Deepy" in line else STYLE_INFO)
|
|
127
|
+
return logo
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _build_section(title: str, rows: list[WelcomeSetting | WelcomeTip]) -> Table:
|
|
131
|
+
section = Table.grid()
|
|
132
|
+
section.add_column()
|
|
133
|
+
section.add_row(Text(title, style="bold bright_white"))
|
|
134
|
+
section.add_row(Text(""))
|
|
135
|
+
|
|
136
|
+
body = Table.grid(padding=(0, 2))
|
|
137
|
+
body.add_column(style=STYLE_ACCENT, no_wrap=True)
|
|
138
|
+
body.add_column(style="bright_white")
|
|
139
|
+
for row in rows:
|
|
140
|
+
body.add_row(row.label, row.description if isinstance(row, WelcomeTip) else row.value)
|
|
141
|
+
section.add_row(body)
|
|
142
|
+
return section
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def build_welcome_panel(
|
|
146
|
+
*,
|
|
147
|
+
model: str,
|
|
148
|
+
thinking_enabled: bool,
|
|
149
|
+
reasoning_effort: str,
|
|
150
|
+
project_root: str | Path,
|
|
151
|
+
skills: list[SkillInfo],
|
|
152
|
+
current_version: str,
|
|
153
|
+
version_update: VersionUpdate | None = None,
|
|
154
|
+
home: str | Path | None = None,
|
|
155
|
+
) -> Panel:
|
|
156
|
+
settings = build_welcome_settings(
|
|
157
|
+
model=model,
|
|
158
|
+
thinking_enabled=thinking_enabled,
|
|
159
|
+
reasoning_effort=reasoning_effort,
|
|
160
|
+
project_root=project_root,
|
|
161
|
+
current_version=current_version,
|
|
162
|
+
version_update=version_update,
|
|
163
|
+
home=home,
|
|
164
|
+
)
|
|
165
|
+
tips = build_welcome_tips(skills)
|
|
166
|
+
|
|
167
|
+
hero = Table.grid(padding=(0, 3), expand=False)
|
|
168
|
+
hero.add_column()
|
|
169
|
+
hero.add_column(ratio=1)
|
|
170
|
+
|
|
171
|
+
intro = Text()
|
|
172
|
+
intro.append("Deepy\n", style=f"bold {STYLE_ASSISTANT}")
|
|
173
|
+
intro.append("Terminal coding agent for DeepSeek.\n", style="bright_white")
|
|
174
|
+
intro.append("Read, edit, run tools, and keep project context.", style=STYLE_MUTED)
|
|
175
|
+
|
|
176
|
+
hero.add_row(build_deepy_ascii_logo(), intro)
|
|
177
|
+
|
|
178
|
+
body = Table.grid(padding=(0, 4), expand=False)
|
|
179
|
+
body.add_column()
|
|
180
|
+
body.add_column()
|
|
181
|
+
body.add_row(
|
|
182
|
+
_build_section("Session", settings),
|
|
183
|
+
_build_section("Commands", tips[:10]),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return Panel(
|
|
187
|
+
Group(
|
|
188
|
+
hero,
|
|
189
|
+
Text(""),
|
|
190
|
+
body,
|
|
191
|
+
),
|
|
192
|
+
title="Deepy is ready",
|
|
193
|
+
border_style=STYLE_INFO,
|
|
194
|
+
expand=False,
|
|
195
|
+
)
|
deepy/update_check.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.request
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from deepy.utils import json as json_utils
|
|
10
|
+
|
|
11
|
+
DEFAULT_PYPI_PACKAGE = "deepy-cli"
|
|
12
|
+
DEFAULT_GITHUB_REPO = "kirineko/deepy"
|
|
13
|
+
DEFAULT_TIMEOUT_SECONDS = 0.8
|
|
14
|
+
|
|
15
|
+
UrlOpen = Callable[..., Any]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class VersionCandidate:
|
|
20
|
+
version: str
|
|
21
|
+
source: str
|
|
22
|
+
url: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class VersionUpdate:
|
|
27
|
+
current_version: str
|
|
28
|
+
latest_version: str
|
|
29
|
+
source: str
|
|
30
|
+
url: str
|
|
31
|
+
install_hint: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def check_for_version_update(
|
|
35
|
+
current_version: str,
|
|
36
|
+
*,
|
|
37
|
+
pypi_package: str = DEFAULT_PYPI_PACKAGE,
|
|
38
|
+
github_repo: str = DEFAULT_GITHUB_REPO,
|
|
39
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
40
|
+
urlopen: UrlOpen = urllib.request.urlopen,
|
|
41
|
+
) -> VersionUpdate | None:
|
|
42
|
+
candidates = [
|
|
43
|
+
candidate
|
|
44
|
+
for candidate in (
|
|
45
|
+
fetch_latest_pypi_version(pypi_package, timeout_seconds=timeout_seconds, urlopen=urlopen),
|
|
46
|
+
fetch_latest_github_version(github_repo, timeout_seconds=timeout_seconds, urlopen=urlopen),
|
|
47
|
+
)
|
|
48
|
+
if candidate is not None
|
|
49
|
+
]
|
|
50
|
+
latest = _latest_candidate(candidates)
|
|
51
|
+
if latest is None or compare_versions(latest.version, current_version) <= 0:
|
|
52
|
+
return None
|
|
53
|
+
return VersionUpdate(
|
|
54
|
+
current_version=current_version,
|
|
55
|
+
latest_version=latest.version,
|
|
56
|
+
source=latest.source,
|
|
57
|
+
url=latest.url,
|
|
58
|
+
install_hint=f"uv tool upgrade {pypi_package}",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def fetch_latest_pypi_version(
|
|
63
|
+
package_name: str,
|
|
64
|
+
*,
|
|
65
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
66
|
+
urlopen: UrlOpen = urllib.request.urlopen,
|
|
67
|
+
) -> VersionCandidate | None:
|
|
68
|
+
url = f"https://pypi.org/pypi/{package_name}/json"
|
|
69
|
+
try:
|
|
70
|
+
with urlopen(_request(url), timeout=timeout_seconds) as response:
|
|
71
|
+
payload = _read_json_response(response)
|
|
72
|
+
except (OSError, urllib.error.URLError, json_utils.JSONDecodeError):
|
|
73
|
+
return None
|
|
74
|
+
info = payload.get("info") if isinstance(payload, dict) else None
|
|
75
|
+
version = info.get("version") if isinstance(info, dict) else None
|
|
76
|
+
if not isinstance(version, str) or not version.strip():
|
|
77
|
+
return None
|
|
78
|
+
return VersionCandidate(version=version.strip(), source="PyPI", url=f"https://pypi.org/project/{package_name}/")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def fetch_latest_github_version(
|
|
82
|
+
repo: str,
|
|
83
|
+
*,
|
|
84
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
85
|
+
urlopen: UrlOpen = urllib.request.urlopen,
|
|
86
|
+
) -> VersionCandidate | None:
|
|
87
|
+
release = _fetch_github_release_version(repo, timeout_seconds=timeout_seconds, urlopen=urlopen)
|
|
88
|
+
if release is not None:
|
|
89
|
+
return release
|
|
90
|
+
return _fetch_github_tag_version(repo, timeout_seconds=timeout_seconds, urlopen=urlopen)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def compare_versions(a: str, b: str) -> int:
|
|
94
|
+
left = _version_parts(a)
|
|
95
|
+
right = _version_parts(b)
|
|
96
|
+
width = max(len(left), len(right))
|
|
97
|
+
for index in range(width):
|
|
98
|
+
left_part = left[index] if index < len(left) else 0
|
|
99
|
+
right_part = right[index] if index < len(right) else 0
|
|
100
|
+
if left_part > right_part:
|
|
101
|
+
return 1
|
|
102
|
+
if left_part < right_part:
|
|
103
|
+
return -1
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _fetch_github_release_version(
|
|
108
|
+
repo: str,
|
|
109
|
+
*,
|
|
110
|
+
timeout_seconds: float,
|
|
111
|
+
urlopen: UrlOpen,
|
|
112
|
+
) -> VersionCandidate | None:
|
|
113
|
+
url = f"https://api.github.com/repos/{repo}/releases/latest"
|
|
114
|
+
try:
|
|
115
|
+
with urlopen(_request(url), timeout=timeout_seconds) as response:
|
|
116
|
+
payload = _read_json_response(response)
|
|
117
|
+
except (OSError, urllib.error.URLError, json_utils.JSONDecodeError):
|
|
118
|
+
return None
|
|
119
|
+
tag = payload.get("tag_name") if isinstance(payload, dict) else None
|
|
120
|
+
version = _normalize_tag_version(tag)
|
|
121
|
+
if version is None:
|
|
122
|
+
return None
|
|
123
|
+
html_url = payload.get("html_url") if isinstance(payload, dict) else None
|
|
124
|
+
return VersionCandidate(
|
|
125
|
+
version=version,
|
|
126
|
+
source="GitHub",
|
|
127
|
+
url=html_url if isinstance(html_url, str) and html_url else f"https://github.com/{repo}/releases/latest",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _fetch_github_tag_version(
|
|
132
|
+
repo: str,
|
|
133
|
+
*,
|
|
134
|
+
timeout_seconds: float,
|
|
135
|
+
urlopen: UrlOpen,
|
|
136
|
+
) -> VersionCandidate | None:
|
|
137
|
+
url = f"https://api.github.com/repos/{repo}/tags?per_page=1"
|
|
138
|
+
try:
|
|
139
|
+
with urlopen(_request(url), timeout=timeout_seconds) as response:
|
|
140
|
+
payload = _read_json_response(response)
|
|
141
|
+
except (OSError, urllib.error.URLError, json_utils.JSONDecodeError):
|
|
142
|
+
return None
|
|
143
|
+
if not isinstance(payload, list) or not payload:
|
|
144
|
+
return None
|
|
145
|
+
first = payload[0]
|
|
146
|
+
tag = first.get("name") if isinstance(first, dict) else None
|
|
147
|
+
version = _normalize_tag_version(tag)
|
|
148
|
+
if version is None:
|
|
149
|
+
return None
|
|
150
|
+
return VersionCandidate(version=version, source="GitHub", url=f"https://github.com/{repo}/releases/tag/{tag}")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _latest_candidate(candidates: list[VersionCandidate]) -> VersionCandidate | None:
|
|
154
|
+
latest: VersionCandidate | None = None
|
|
155
|
+
for candidate in candidates:
|
|
156
|
+
if latest is None or compare_versions(candidate.version, latest.version) > 0:
|
|
157
|
+
latest = candidate
|
|
158
|
+
return latest
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _read_json_response(response: Any) -> dict[str, Any] | list[Any]:
|
|
162
|
+
data = response.read()
|
|
163
|
+
if isinstance(data, str):
|
|
164
|
+
text = data
|
|
165
|
+
else:
|
|
166
|
+
text = data.decode("utf-8", errors="replace")
|
|
167
|
+
parsed = json_utils.loads(text)
|
|
168
|
+
return parsed if isinstance(parsed, (dict, list)) else {}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _request(url: str) -> urllib.request.Request:
|
|
172
|
+
return urllib.request.Request(
|
|
173
|
+
url,
|
|
174
|
+
headers={
|
|
175
|
+
"Accept": "application/json",
|
|
176
|
+
"User-Agent": "Deepy update check",
|
|
177
|
+
},
|
|
178
|
+
method="GET",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _normalize_tag_version(value: object) -> str | None:
|
|
183
|
+
if not isinstance(value, str):
|
|
184
|
+
return None
|
|
185
|
+
stripped = value.strip()
|
|
186
|
+
if stripped.startswith(("v", "V")):
|
|
187
|
+
stripped = stripped[1:]
|
|
188
|
+
return stripped if _version_parts(stripped) else None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _version_parts(value: str) -> list[int]:
|
|
192
|
+
match = re.match(r"^\s*v?(\d+(?:\.\d+)*)", value, flags=re.IGNORECASE)
|
|
193
|
+
if not match:
|
|
194
|
+
return []
|
|
195
|
+
return [int(part) for part in match.group(1).split(".")]
|
deepy/usage.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
from typing import Any, Mapping
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class TokenUsage:
|
|
9
|
+
prompt_tokens: int = 0
|
|
10
|
+
completion_tokens: int = 0
|
|
11
|
+
total_tokens: int = 0
|
|
12
|
+
prompt_cache_hit_tokens: int = 0
|
|
13
|
+
prompt_cache_miss_tokens: int = 0
|
|
14
|
+
reasoning_tokens: int = 0
|
|
15
|
+
requests: int = 0
|
|
16
|
+
request_usage_entries: list[dict[str, Any]] = field(default_factory=list)
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def known(self) -> bool:
|
|
20
|
+
return any(
|
|
21
|
+
(
|
|
22
|
+
self.prompt_tokens,
|
|
23
|
+
self.completion_tokens,
|
|
24
|
+
self.total_tokens,
|
|
25
|
+
self.prompt_cache_hit_tokens,
|
|
26
|
+
self.prompt_cache_miss_tokens,
|
|
27
|
+
self.reasoning_tokens,
|
|
28
|
+
self.requests,
|
|
29
|
+
)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> dict[str, Any]:
|
|
33
|
+
payload = asdict(self)
|
|
34
|
+
return {key: value for key, value in payload.items() if value not in (0, [], None)}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def normalize_usage(value: Any) -> TokenUsage:
|
|
38
|
+
payload = _to_mapping(value)
|
|
39
|
+
if payload is None:
|
|
40
|
+
return TokenUsage()
|
|
41
|
+
|
|
42
|
+
prompt_tokens = _int_field(payload.get("prompt_tokens"))
|
|
43
|
+
completion_tokens = _int_field(payload.get("completion_tokens"))
|
|
44
|
+
total_tokens = _int_field(payload.get("total_tokens"))
|
|
45
|
+
|
|
46
|
+
sdk_input_tokens = _int_field(payload.get("input_tokens"))
|
|
47
|
+
sdk_output_tokens = _int_field(payload.get("output_tokens"))
|
|
48
|
+
if prompt_tokens == 0:
|
|
49
|
+
prompt_tokens = sdk_input_tokens
|
|
50
|
+
if completion_tokens == 0:
|
|
51
|
+
completion_tokens = sdk_output_tokens
|
|
52
|
+
if total_tokens == 0:
|
|
53
|
+
total_tokens = prompt_tokens + completion_tokens
|
|
54
|
+
|
|
55
|
+
prompt_details = _to_mapping(payload.get("prompt_tokens_details")) or {}
|
|
56
|
+
completion_details = _to_mapping(payload.get("completion_tokens_details")) or {}
|
|
57
|
+
input_details = _to_mapping(payload.get("input_tokens_details")) or {}
|
|
58
|
+
output_details = _to_mapping(payload.get("output_tokens_details")) or {}
|
|
59
|
+
|
|
60
|
+
prompt_cache_hit_tokens = _first_int(
|
|
61
|
+
payload.get("prompt_cache_hit_tokens"),
|
|
62
|
+
prompt_details.get("cached_tokens"),
|
|
63
|
+
input_details.get("cached_tokens"),
|
|
64
|
+
)
|
|
65
|
+
prompt_cache_miss_tokens = _int_field(payload.get("prompt_cache_miss_tokens"))
|
|
66
|
+
if prompt_cache_miss_tokens == 0 and prompt_tokens and prompt_cache_hit_tokens:
|
|
67
|
+
prompt_cache_miss_tokens = max(prompt_tokens - prompt_cache_hit_tokens, 0)
|
|
68
|
+
reasoning_tokens = _first_int(
|
|
69
|
+
completion_details.get("reasoning_tokens"),
|
|
70
|
+
output_details.get("reasoning_tokens"),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
request_entries = _request_usage_entries(payload.get("request_usage_entries"))
|
|
74
|
+
if not request_entries and payload:
|
|
75
|
+
entry = {
|
|
76
|
+
"prompt_tokens": prompt_tokens,
|
|
77
|
+
"completion_tokens": completion_tokens,
|
|
78
|
+
"total_tokens": total_tokens,
|
|
79
|
+
}
|
|
80
|
+
if prompt_cache_hit_tokens:
|
|
81
|
+
entry["prompt_cache_hit_tokens"] = prompt_cache_hit_tokens
|
|
82
|
+
if prompt_cache_miss_tokens:
|
|
83
|
+
entry["prompt_cache_miss_tokens"] = prompt_cache_miss_tokens
|
|
84
|
+
if reasoning_tokens:
|
|
85
|
+
entry["reasoning_tokens"] = reasoning_tokens
|
|
86
|
+
if any(entry.values()):
|
|
87
|
+
request_entries = [entry]
|
|
88
|
+
requests = _int_field(payload.get("requests"))
|
|
89
|
+
if requests == 0 and request_entries:
|
|
90
|
+
requests = len(request_entries)
|
|
91
|
+
|
|
92
|
+
return TokenUsage(
|
|
93
|
+
prompt_tokens=prompt_tokens,
|
|
94
|
+
completion_tokens=completion_tokens,
|
|
95
|
+
total_tokens=total_tokens,
|
|
96
|
+
prompt_cache_hit_tokens=prompt_cache_hit_tokens,
|
|
97
|
+
prompt_cache_miss_tokens=prompt_cache_miss_tokens,
|
|
98
|
+
reasoning_tokens=reasoning_tokens,
|
|
99
|
+
requests=requests,
|
|
100
|
+
request_usage_entries=request_entries,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def merge_usage(*items: TokenUsage | Mapping[str, Any] | None) -> TokenUsage:
|
|
105
|
+
total = TokenUsage()
|
|
106
|
+
for item in items:
|
|
107
|
+
usage = item if isinstance(item, TokenUsage) else normalize_usage(item)
|
|
108
|
+
if not usage.known:
|
|
109
|
+
continue
|
|
110
|
+
total = TokenUsage(
|
|
111
|
+
prompt_tokens=total.prompt_tokens + usage.prompt_tokens,
|
|
112
|
+
completion_tokens=total.completion_tokens + usage.completion_tokens,
|
|
113
|
+
total_tokens=total.total_tokens + usage.total_tokens,
|
|
114
|
+
prompt_cache_hit_tokens=total.prompt_cache_hit_tokens + usage.prompt_cache_hit_tokens,
|
|
115
|
+
prompt_cache_miss_tokens=total.prompt_cache_miss_tokens + usage.prompt_cache_miss_tokens,
|
|
116
|
+
reasoning_tokens=total.reasoning_tokens + usage.reasoning_tokens,
|
|
117
|
+
requests=total.requests + usage.requests,
|
|
118
|
+
request_usage_entries=[
|
|
119
|
+
*total.request_usage_entries,
|
|
120
|
+
*usage.request_usage_entries,
|
|
121
|
+
],
|
|
122
|
+
)
|
|
123
|
+
return total
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def usage_from_run_result(result: Any) -> TokenUsage:
|
|
127
|
+
context = getattr(result, "context_wrapper", None)
|
|
128
|
+
usage = getattr(context, "usage", None)
|
|
129
|
+
return normalize_usage(usage)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def format_usage_line(usage: TokenUsage | Mapping[str, Any] | None) -> str:
|
|
133
|
+
normalized = usage if isinstance(usage, TokenUsage) else normalize_usage(usage)
|
|
134
|
+
if not normalized.known:
|
|
135
|
+
return "usage=unknown"
|
|
136
|
+
parts = [f"context input {normalized.prompt_tokens:,}"]
|
|
137
|
+
cache_tokens = normalized.prompt_cache_hit_tokens + normalized.prompt_cache_miss_tokens
|
|
138
|
+
if cache_tokens:
|
|
139
|
+
cache_hit_rate = normalized.prompt_cache_hit_tokens / cache_tokens * 100
|
|
140
|
+
parts.append(f"fresh input {normalized.prompt_cache_miss_tokens:,}")
|
|
141
|
+
parts.append(
|
|
142
|
+
f"cached input {normalized.prompt_cache_hit_tokens:,} "
|
|
143
|
+
f"({cache_hit_rate:.1f}% hit)"
|
|
144
|
+
)
|
|
145
|
+
parts.append(f"output {normalized.completion_tokens:,}")
|
|
146
|
+
if normalized.reasoning_tokens:
|
|
147
|
+
parts.append(f"reasoning {normalized.reasoning_tokens:,}")
|
|
148
|
+
parts.append(f"total {normalized.total_tokens:,}")
|
|
149
|
+
return " · ".join(parts)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _to_mapping(value: Any) -> Mapping[str, Any] | None:
|
|
153
|
+
if isinstance(value, Mapping):
|
|
154
|
+
return value
|
|
155
|
+
model_dump = getattr(value, "model_dump", None)
|
|
156
|
+
if callable(model_dump):
|
|
157
|
+
dumped = model_dump()
|
|
158
|
+
return dumped if isinstance(dumped, Mapping) else None
|
|
159
|
+
if hasattr(value, "__dict__"):
|
|
160
|
+
return dict(value.__dict__)
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _int_field(value: Any) -> int:
|
|
165
|
+
if isinstance(value, bool):
|
|
166
|
+
return 0
|
|
167
|
+
if isinstance(value, int):
|
|
168
|
+
return max(value, 0)
|
|
169
|
+
if isinstance(value, float) and value.is_integer():
|
|
170
|
+
return max(int(value), 0)
|
|
171
|
+
return 0
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _first_int(*values: Any) -> int:
|
|
175
|
+
for value in values:
|
|
176
|
+
number = _int_field(value)
|
|
177
|
+
if number:
|
|
178
|
+
return number
|
|
179
|
+
return 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _request_usage_entries(value: Any) -> list[dict[str, Any]]:
|
|
183
|
+
if not isinstance(value, list):
|
|
184
|
+
return []
|
|
185
|
+
entries: list[dict[str, Any]] = []
|
|
186
|
+
for item in value:
|
|
187
|
+
normalized = normalize_usage(item)
|
|
188
|
+
if normalized.known:
|
|
189
|
+
payload = normalized.to_dict()
|
|
190
|
+
payload.pop("request_usage_entries", None)
|
|
191
|
+
entries.append(payload)
|
|
192
|
+
return entries
|
deepy/utils/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .debug_logger import debug_log_path, log_debug_event, normalize_error
|
|
2
|
+
from .error_logger import error_log_path, log_api_error, mask_sensitive
|
|
3
|
+
from .notify import build_notify_env, format_duration_seconds, launch_notify_script
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"build_notify_env",
|
|
7
|
+
"debug_log_path",
|
|
8
|
+
"error_log_path",
|
|
9
|
+
"format_duration_seconds",
|
|
10
|
+
"launch_notify_script",
|
|
11
|
+
"log_api_error",
|
|
12
|
+
"log_debug_event",
|
|
13
|
+
"mask_sensitive",
|
|
14
|
+
"normalize_error",
|
|
15
|
+
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import traceback
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from . import json as json_utils
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def debug_log_path(deepy_home: Path | None = None) -> Path:
|
|
12
|
+
home = deepy_home or Path.home() / ".deepy"
|
|
13
|
+
return home / "logs" / "debug.log"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def normalize_error(error: BaseException | object) -> dict[str, str]:
|
|
17
|
+
if isinstance(error, BaseException):
|
|
18
|
+
payload = {
|
|
19
|
+
"name": error.__class__.__name__,
|
|
20
|
+
"message": str(error),
|
|
21
|
+
}
|
|
22
|
+
stack = "".join(traceback.format_exception(type(error), error, error.__traceback__)).strip()
|
|
23
|
+
if stack:
|
|
24
|
+
payload["stack"] = stack
|
|
25
|
+
return payload
|
|
26
|
+
return {"name": "UnknownError", "message": str(error)}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def log_debug_event(entry: Mapping[str, Any], *, deepy_home: Path | None = None) -> None:
|
|
30
|
+
try:
|
|
31
|
+
path = debug_log_path(deepy_home)
|
|
32
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
34
|
+
fh.write(json_utils.dumps(_to_serializable(entry)))
|
|
35
|
+
fh.write("\n")
|
|
36
|
+
except Exception:
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _to_serializable(value: Any, seen: set[int] | None = None) -> Any:
|
|
41
|
+
seen = seen or set()
|
|
42
|
+
if isinstance(value, BaseException):
|
|
43
|
+
return normalize_error(value)
|
|
44
|
+
if isinstance(value, Path):
|
|
45
|
+
return str(value)
|
|
46
|
+
if isinstance(value, dict):
|
|
47
|
+
marker = id(value)
|
|
48
|
+
if marker in seen:
|
|
49
|
+
return "[Circular]"
|
|
50
|
+
seen.add(marker)
|
|
51
|
+
return {str(key): _to_serializable(item, seen) for key, item in value.items()}
|
|
52
|
+
if isinstance(value, list | tuple | set):
|
|
53
|
+
marker = id(value)
|
|
54
|
+
if marker in seen:
|
|
55
|
+
return "[Circular]"
|
|
56
|
+
seen.add(marker)
|
|
57
|
+
return [_to_serializable(item, seen) for item in value]
|
|
58
|
+
try:
|
|
59
|
+
json_utils.dumps(value)
|
|
60
|
+
except TypeError:
|
|
61
|
+
return str(value)
|
|
62
|
+
return value
|