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.
Files changed (69) hide show
  1. deepy/__init__.py +9 -0
  2. deepy/__main__.py +7 -0
  3. deepy/cli.py +413 -0
  4. deepy/config/__init__.py +21 -0
  5. deepy/config/settings.py +237 -0
  6. deepy/data/__init__.py +1 -0
  7. deepy/data/tools/AskUserQuestion.md +10 -0
  8. deepy/data/tools/WebFetch.md +9 -0
  9. deepy/data/tools/WebSearch.md +9 -0
  10. deepy/data/tools/__init__.py +1 -0
  11. deepy/data/tools/bash.md +7 -0
  12. deepy/data/tools/edit.md +13 -0
  13. deepy/data/tools/modify.md +17 -0
  14. deepy/data/tools/read.md +8 -0
  15. deepy/data/tools/write.md +12 -0
  16. deepy/errors.py +63 -0
  17. deepy/llm/__init__.py +13 -0
  18. deepy/llm/agent.py +31 -0
  19. deepy/llm/context.py +109 -0
  20. deepy/llm/events.py +187 -0
  21. deepy/llm/model_capabilities.py +7 -0
  22. deepy/llm/provider.py +81 -0
  23. deepy/llm/replay.py +120 -0
  24. deepy/llm/runner.py +412 -0
  25. deepy/llm/thinking.py +30 -0
  26. deepy/prompts/__init__.py +6 -0
  27. deepy/prompts/compact.py +100 -0
  28. deepy/prompts/rules.py +24 -0
  29. deepy/prompts/runtime_context.py +98 -0
  30. deepy/prompts/system.py +72 -0
  31. deepy/prompts/tool_docs.py +21 -0
  32. deepy/sessions/__init__.py +17 -0
  33. deepy/sessions/jsonl.py +306 -0
  34. deepy/sessions/manager.py +202 -0
  35. deepy/skills.py +202 -0
  36. deepy/status.py +65 -0
  37. deepy/tools/__init__.py +6 -0
  38. deepy/tools/agents.py +343 -0
  39. deepy/tools/builtin.py +2113 -0
  40. deepy/tools/file_state.py +85 -0
  41. deepy/tools/result.py +54 -0
  42. deepy/tools/shell_utils.py +83 -0
  43. deepy/ui/__init__.py +5 -0
  44. deepy/ui/app.py +118 -0
  45. deepy/ui/ask_user_question.py +182 -0
  46. deepy/ui/exit_summary.py +142 -0
  47. deepy/ui/loading_text.py +87 -0
  48. deepy/ui/markdown.py +152 -0
  49. deepy/ui/message_view.py +546 -0
  50. deepy/ui/prompt_buffer.py +176 -0
  51. deepy/ui/prompt_input.py +286 -0
  52. deepy/ui/session_list.py +140 -0
  53. deepy/ui/session_picker.py +179 -0
  54. deepy/ui/slash_commands.py +67 -0
  55. deepy/ui/styles.py +21 -0
  56. deepy/ui/terminal.py +959 -0
  57. deepy/ui/thinking_state.py +29 -0
  58. deepy/ui/welcome.py +195 -0
  59. deepy/update_check.py +195 -0
  60. deepy/usage.py +192 -0
  61. deepy/utils/__init__.py +15 -0
  62. deepy/utils/debug_logger.py +62 -0
  63. deepy/utils/error_logger.py +107 -0
  64. deepy/utils/json.py +29 -0
  65. deepy/utils/notify.py +66 -0
  66. deepy_cli-0.1.1.dist-info/METADATA +205 -0
  67. deepy_cli-0.1.1.dist-info/RECORD +69 -0
  68. deepy_cli-0.1.1.dist-info/WHEEL +4 -0
  69. deepy_cli-0.1.1.dist-info/entry_points.txt +3 -0
deepy/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ __version__ = "0.1.1"
4
+
5
+
6
+ def main() -> None:
7
+ from .cli import main as cli_main
8
+
9
+ cli_main()
deepy/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from .cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ main()
deepy/cli.py ADDED
@@ -0,0 +1,413 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Sequence
9
+
10
+ import tomli_w
11
+
12
+ from . import __version__
13
+ from .config import Settings, load_settings, settings_to_toml_dict
14
+ from .config.settings import DEFAULT_BASE_URL, DEFAULT_MODEL
15
+ from .errors import format_error_display
16
+ from .llm.provider import build_provider_bundle
17
+ from .llm.runner import DEFAULT_MAX_TURNS, run_prompt_once
18
+ from .sessions import DeepyJsonlSession, list_session_entries
19
+ from .skills import discover_skills, find_skill, format_skills_for_terminal, read_skill_body
20
+ from .status import build_status_report, format_status_report, status_report_to_dict
21
+ from .usage import TokenUsage, format_usage_line, usage_from_run_result
22
+ from .ui import run_interactive
23
+ from .utils import json as json_utils
24
+
25
+
26
+ def _build_parser() -> argparse.ArgumentParser:
27
+ parser = argparse.ArgumentParser(
28
+ prog="deepy",
29
+ description="Deepy - Vibe coding for DeepSeek models in your terminal.",
30
+ )
31
+ parser.add_argument("--version", action="version", version=f"Deepy {__version__}")
32
+ parser.add_argument("--config", type=Path, help="Path to config.toml.")
33
+
34
+ subparsers = parser.add_subparsers(dest="command")
35
+
36
+ config_parser = subparsers.add_parser("config", help="Inspect local Deepy config.")
37
+ config_sub = config_parser.add_subparsers(dest="config_command", required=True)
38
+ show_parser = config_sub.add_parser("show", help="Print resolved TOML config.")
39
+ show_parser.add_argument("--show-secret", action="store_true", help="Show API key.")
40
+ show_parser.add_argument("--json", action="store_true", help="Print JSON instead of TOML.")
41
+ init_parser = config_sub.add_parser("init", help="Create a TOML config file.")
42
+ init_parser.add_argument("--api-key", help="DeepSeek API key.")
43
+ init_parser.add_argument("--model", default=DEFAULT_MODEL, help="Model name.")
44
+ init_parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="OpenAI-compatible base URL.")
45
+ init_parser.add_argument("--force", action="store_true", help="Overwrite existing config.")
46
+ setup_parser = config_sub.add_parser("setup", help="Interactively configure Deepy.")
47
+ setup_parser.add_argument("--force", action="store_true", help="Overwrite existing config.")
48
+
49
+ doctor_parser = subparsers.add_parser("doctor", help="Validate local Deepy setup.")
50
+ doctor_parser.add_argument("--json", action="store_true", help="Print JSON diagnostics.")
51
+ doctor_parser.add_argument("--live", action="store_true", help="Send one minimal live DeepSeek request.")
52
+
53
+ run_parser = subparsers.add_parser("run", help="Run a single non-interactive prompt.")
54
+ run_parser.add_argument("prompt", nargs="+", help="Prompt text to send to Deepy.")
55
+ run_parser.add_argument(
56
+ "--max-turns",
57
+ type=int,
58
+ default=DEFAULT_MAX_TURNS,
59
+ help="Maximum agent turns.",
60
+ )
61
+ run_parser.add_argument("--session", help="Resume an existing session id.")
62
+ run_parser.add_argument("--skill", action="append", default=[], help="Load a skill by name.")
63
+
64
+ sessions_parser = subparsers.add_parser("sessions", help="Inspect project sessions.")
65
+ sessions_sub = sessions_parser.add_subparsers(dest="sessions_command", required=True)
66
+ sessions_sub.add_parser("list", help="List sessions for the current project.")
67
+ sessions_show = sessions_sub.add_parser("show", help="Print session items as JSON.")
68
+ sessions_show.add_argument("session_id", help="Session id.")
69
+
70
+ skills_parser = subparsers.add_parser("skills", help="Inspect available skills.")
71
+ skills_sub = skills_parser.add_subparsers(dest="skills_command", required=True)
72
+ skills_sub.add_parser("list", help="List user and project skills.")
73
+ skills_show = skills_sub.add_parser("show", help="Print a skill document.")
74
+ skills_show.add_argument("name", help="Skill name.")
75
+
76
+ status_parser = subparsers.add_parser("status", help="Print current Deepy project status.")
77
+ status_parser.add_argument("--json", action="store_true", help="Print JSON status.")
78
+
79
+ return parser
80
+
81
+
82
+ def _cmd_config_show(args: argparse.Namespace) -> int:
83
+ settings = load_settings(args.config)
84
+ data = settings_to_toml_dict(settings, reveal_secret=args.show_secret)
85
+ if args.json:
86
+ print(json_utils.dumps_pretty(data))
87
+ else:
88
+ print(tomli_w.dumps(data), end="")
89
+ return 0
90
+
91
+
92
+ def _cmd_config_init(args: argparse.Namespace) -> int:
93
+ config_path = args.config.expanduser() if args.config else Path.home() / ".deepy" / "config.toml"
94
+ if config_path.suffix == ".json":
95
+ raise ValueError("Deepy only supports TOML config files; JSON config is not supported.")
96
+ if config_path.exists() and not args.force:
97
+ print(f"Config already exists: {config_path}", file=sys.stderr)
98
+ return 1
99
+ _write_config(
100
+ config_path,
101
+ api_key=args.api_key or "",
102
+ model=args.model,
103
+ base_url=args.base_url,
104
+ )
105
+ print(f"Wrote {config_path}")
106
+ return 0
107
+
108
+
109
+ def _cmd_config_setup(args: argparse.Namespace) -> int:
110
+ config_path = args.config.expanduser() if args.config else Path.home() / ".deepy" / "config.toml"
111
+ if config_path.suffix == ".json":
112
+ raise ValueError("Deepy only supports TOML config files; JSON config is not supported.")
113
+ if config_path.exists() and not args.force:
114
+ existing = load_settings(config_path)
115
+ else:
116
+ existing = Settings(path=config_path)
117
+
118
+ print("DeepSeek API keys: https://platform.deepseek.com/api_keys")
119
+ api_key = _prompt_config_value("API key", default=existing.model.api_key or "", is_password=True)
120
+ model = _prompt_config_value("Model", default=existing.model.name)
121
+ base_url = _prompt_config_value("Base URL", default=existing.model.base_url)
122
+ _write_config(config_path, api_key=api_key, model=model, base_url=base_url)
123
+ print(f"Wrote {config_path}")
124
+ return 0
125
+
126
+
127
+ def _prompt_config_value(label: str, *, default: str, is_password: bool = False) -> str:
128
+ from prompt_toolkit import PromptSession
129
+
130
+ prompt = f"{label}"
131
+ if default and not is_password:
132
+ prompt += f" [{default}]"
133
+ prompt += ": "
134
+ value = PromptSession().prompt(prompt, default="" if is_password else default, is_password=is_password)
135
+ value = value.strip()
136
+ return value or default
137
+
138
+
139
+ def _write_config(config_path: Path, *, api_key: str, model: str, base_url: str) -> None:
140
+ payload = {
141
+ "model": {
142
+ "name": model,
143
+ "base_url": base_url,
144
+ "api_key": api_key,
145
+ "thinking": True,
146
+ "reasoning_effort": "max",
147
+ },
148
+ "context": {
149
+ "window_tokens": 1_048_576,
150
+ "compact_trigger_ratio": 0.8,
151
+ "compact_prompt_token_threshold": 838_861,
152
+ },
153
+ "logging": {
154
+ "debug": False,
155
+ },
156
+ "notify": {
157
+ "enabled": False,
158
+ "command": "",
159
+ },
160
+ "tools": {
161
+ "web_search": {
162
+ "command": "",
163
+ "api_url": "",
164
+ "machine_id": "",
165
+ },
166
+ },
167
+ }
168
+ config_path.parent.mkdir(parents=True, exist_ok=True)
169
+ config_path.write_text(tomli_w.dumps(payload), encoding="utf-8")
170
+ os.chmod(config_path, 0o600)
171
+
172
+
173
+ def _doctor(args: argparse.Namespace) -> tuple[int, dict[str, object]]:
174
+ settings = load_settings(args.config)
175
+ checks: list[dict[str, object]] = []
176
+
177
+ def check(name: str, ok: bool, detail: str) -> None:
178
+ checks.append({"name": name, "ok": ok, "detail": detail})
179
+
180
+ check("config", True, str(settings.path))
181
+ check("config_permissions", *_config_permissions_check(settings.path))
182
+ check(
183
+ "api_key",
184
+ bool(settings.model.api_key),
185
+ "configured" if settings.model.api_key else "missing; run `deepy config setup`",
186
+ )
187
+ check("model", bool(settings.model.name), settings.model.name)
188
+ check("base_url", bool(settings.model.base_url), settings.model.base_url)
189
+ check(
190
+ "context_window",
191
+ settings.context.window_tokens >= 1_000_000,
192
+ str(settings.context.window_tokens),
193
+ )
194
+ check(
195
+ "compact_threshold",
196
+ settings.context.resolved_compact_threshold
197
+ == int(settings.context.window_tokens * 0.8 + 0.999999)
198
+ or settings.context.resolved_compact_threshold > 0,
199
+ str(settings.context.resolved_compact_threshold),
200
+ )
201
+
202
+ try:
203
+ build_provider_bundle(settings)
204
+ except Exception as exc:
205
+ check("openai_agents_provider", False, str(exc))
206
+ else:
207
+ check("openai_agents_provider", True, "OpenAIChatCompletionsModel ready")
208
+
209
+ ok = all(bool(item["ok"]) for item in checks)
210
+ return 0 if ok else 1, {
211
+ "ok": ok,
212
+ "checks": checks,
213
+ "thinking": {
214
+ "enabled": settings.model.thinking_enabled,
215
+ "reasoning_effort": settings.model.reasoning_effort,
216
+ },
217
+ }
218
+
219
+
220
+ async def _doctor_live(settings: Settings) -> dict[str, object]:
221
+ from agents import Agent, RunConfig, Runner
222
+
223
+ provider = build_provider_bundle(settings)
224
+ agent = Agent(
225
+ name="Deepy Doctor",
226
+ instructions="Reply with OK.",
227
+ model=provider.model,
228
+ model_settings=provider.model_settings,
229
+ tools=[],
230
+ )
231
+ result = await Runner.run(
232
+ agent,
233
+ "Reply with OK.",
234
+ max_turns=1,
235
+ run_config=RunConfig(
236
+ workflow_name="Deepy Doctor",
237
+ trace_include_sensitive_data=False,
238
+ reasoning_item_id_policy="omit",
239
+ ),
240
+ )
241
+ output = getattr(result, "final_output", "")
242
+ usage = usage_from_run_result(result)
243
+ return {
244
+ "ok": True,
245
+ "model": settings.model.name,
246
+ "base_url": settings.model.base_url,
247
+ "api_key": "configured",
248
+ "response_summary": str(output).strip()[:200],
249
+ "usage": usage.to_dict(),
250
+ }
251
+
252
+
253
+ def _config_permissions_check(path: Path | None) -> tuple[bool, str]:
254
+ if path is None or not path.exists():
255
+ return False, "missing"
256
+ mode = path.stat().st_mode & 0o777
257
+ if mode & 0o077:
258
+ return False, f"{mode:o}; expected private permissions like 600"
259
+ return True, f"{mode:o}"
260
+
261
+
262
+ def _cmd_doctor(args: argparse.Namespace) -> int:
263
+ code, report = _doctor(args)
264
+ if args.live:
265
+ settings = load_settings(args.config)
266
+ if code != 0:
267
+ report["live"] = {"ok": False, "error": "local doctor checks failed"}
268
+ else:
269
+ try:
270
+ report["live"] = asyncio.run(_doctor_live(settings))
271
+ except Exception as exc:
272
+ report["live"] = {"ok": False, "error": format_error_display(exc)}
273
+ code = 1
274
+ if args.json:
275
+ print(json_utils.dumps_pretty(report))
276
+ return code
277
+ for item in report["checks"]:
278
+ status = "ok" if item["ok"] else "fail"
279
+ print(f"{status:4} {item['name']}: {item['detail']}")
280
+ thinking = report["thinking"]
281
+ print(f"info thinking: enabled={thinking['enabled']} effort={thinking['reasoning_effort']}")
282
+ live = report.get("live")
283
+ if isinstance(live, dict):
284
+ if live.get("ok"):
285
+ usage = live.get("usage")
286
+ print(
287
+ "ok live: "
288
+ f"model={live.get('model')} base_url={live.get('base_url')} "
289
+ f"response={live.get('response_summary')!r} "
290
+ f"{format_usage_line(usage if isinstance(usage, dict) else TokenUsage())}"
291
+ )
292
+ else:
293
+ print(f"fail live: {live.get('error')}")
294
+ return code
295
+
296
+
297
+ def _cmd_run(args: argparse.Namespace) -> int:
298
+ settings = load_settings(args.config)
299
+ prompt = " ".join(args.prompt)
300
+
301
+ def emit(delta: str) -> None:
302
+ print(delta, end="", flush=True)
303
+
304
+ try:
305
+ summary = asyncio.run(
306
+ run_prompt_once(
307
+ prompt,
308
+ settings=settings,
309
+ emit=emit,
310
+ max_turns=args.max_turns,
311
+ session_id=args.session,
312
+ skill_names=args.skill,
313
+ )
314
+ )
315
+ except Exception as exc:
316
+ print(f"deepy run failed: {format_error_display(exc)}", file=sys.stderr)
317
+ return 1
318
+ if summary.output and not summary.output.endswith("\n"):
319
+ print()
320
+ return 0 if summary.complete else 1
321
+
322
+
323
+ def _cmd_sessions(args: argparse.Namespace) -> int:
324
+ if args.sessions_command == "list":
325
+ entries = list_session_entries(Path.cwd())
326
+ if not entries:
327
+ print("No sessions found.")
328
+ return 0
329
+ for entry in entries:
330
+ print(
331
+ f"{entry.id}\tupdated={entry.updated_at}\thistory_tokens={entry.active_tokens}\t"
332
+ f"{format_usage_line(entry.usage)}"
333
+ )
334
+ return 0
335
+ if args.sessions_command == "show":
336
+ session = DeepyJsonlSession.open(Path.cwd(), args.session_id)
337
+ items = asyncio.run(session.get_items())
338
+ entry = next(
339
+ (item for item in list_session_entries(Path.cwd()) if item.id == args.session_id),
340
+ None,
341
+ )
342
+ print(
343
+ json_utils.dumps_pretty(
344
+ {
345
+ "session_id": args.session_id,
346
+ "usage": entry.usage if entry is not None else None,
347
+ "items": items,
348
+ }
349
+ )
350
+ )
351
+ return 0
352
+ return 1
353
+
354
+
355
+ def _cmd_skills(args: argparse.Namespace) -> int:
356
+ if args.skills_command == "list":
357
+ print(format_skills_for_terminal(discover_skills(Path.cwd())))
358
+ return 0
359
+ if args.skills_command == "show":
360
+ skill = find_skill(Path.cwd(), args.name)
361
+ if skill is None:
362
+ print(f"Skill not found: {args.name}", file=sys.stderr)
363
+ return 1
364
+ print(read_skill_body(skill))
365
+ return 0
366
+ return 1
367
+
368
+
369
+ def _cmd_status(args: argparse.Namespace) -> int:
370
+ settings = load_settings(args.config)
371
+ report = build_status_report(Path.cwd(), settings)
372
+ if args.json:
373
+ print(json_utils.dumps_pretty(status_report_to_dict(report)))
374
+ else:
375
+ print(format_status_report(report))
376
+ return 0
377
+
378
+
379
+ def main(argv: Sequence[str] | None = None) -> int:
380
+ parser = _build_parser()
381
+ args = parser.parse_args(argv)
382
+
383
+ if args.command == "config":
384
+ if args.config_command == "show":
385
+ return _cmd_config_show(args)
386
+ if args.config_command == "init":
387
+ return _cmd_config_init(args)
388
+ if args.config_command == "setup":
389
+ return _cmd_config_setup(args)
390
+ if args.command == "doctor":
391
+ return _cmd_doctor(args)
392
+ if args.command == "run":
393
+ return _cmd_run(args)
394
+ if args.command == "sessions":
395
+ return _cmd_sessions(args)
396
+ if args.command == "skills":
397
+ return _cmd_skills(args)
398
+ if args.command == "status":
399
+ return _cmd_status(args)
400
+
401
+ if not sys.stdin.isatty():
402
+ parser.error("interactive mode requires a TTY; use `deepy doctor` or `deepy config show`.")
403
+ settings = load_settings(args.config)
404
+ if not settings.model.api_key:
405
+ print("Deepy needs a DeepSeek API key before starting interactive mode.")
406
+ setup_args = argparse.Namespace(config=args.config, force=True)
407
+ _cmd_config_setup(setup_args)
408
+ settings = load_settings(args.config)
409
+ return run_interactive(settings)
410
+
411
+
412
+ if __name__ == "__main__":
413
+ raise SystemExit(main())
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from .settings import (
4
+ ContextConfig,
5
+ ModelConfig,
6
+ Settings,
7
+ default_config_path,
8
+ load_settings,
9
+ mask_secret,
10
+ settings_to_toml_dict,
11
+ )
12
+
13
+ __all__ = [
14
+ "ContextConfig",
15
+ "ModelConfig",
16
+ "Settings",
17
+ "default_config_path",
18
+ "load_settings",
19
+ "mask_secret",
20
+ "settings_to_toml_dict",
21
+ ]
@@ -0,0 +1,237 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import tomllib
5
+ from dataclasses import asdict, dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any, Mapping, Self
8
+
9
+ DEFAULT_MODEL = "deepseek-v4-pro"
10
+ DEFAULT_BASE_URL = "https://api.deepseek.com"
11
+ DEFAULT_CONTEXT_WINDOW_TOKENS = 1_048_576
12
+ DEFAULT_COMPACT_TRIGGER_RATIO = 0.8
13
+ DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 838_861
14
+ REASONING_EFFORTS = {"high", "max"}
15
+
16
+
17
+ def default_config_path() -> Path:
18
+ return Path.home() / ".deepy" / "config.toml"
19
+
20
+
21
+ def mask_secret(value: str | None) -> str:
22
+ if not value:
23
+ return ""
24
+ if len(value) <= 8:
25
+ return "***"
26
+ return f"{value[:4]}...{value[-4:]}"
27
+
28
+
29
+ def _as_mapping(value: Any) -> Mapping[str, Any]:
30
+ return value if isinstance(value, Mapping) else {}
31
+
32
+
33
+ def _as_bool(value: Any, default: bool) -> bool:
34
+ return value if isinstance(value, bool) else default
35
+
36
+
37
+ def _as_int(value: Any, default: int) -> int:
38
+ if isinstance(value, bool):
39
+ return default
40
+ if isinstance(value, int) and value > 0:
41
+ return value
42
+ return default
43
+
44
+
45
+ def _as_float(value: Any, default: float) -> float:
46
+ if isinstance(value, bool):
47
+ return default
48
+ if isinstance(value, int | float) and value > 0:
49
+ return float(value)
50
+ return default
51
+
52
+
53
+ def _as_str(value: Any, default: str = "") -> str:
54
+ return value.strip() if isinstance(value, str) and value.strip() else default
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class ModelConfig:
59
+ name: str = DEFAULT_MODEL
60
+ base_url: str = DEFAULT_BASE_URL
61
+ api_key: str | None = None
62
+ thinking: bool | None = None
63
+ reasoning_effort: str = "max"
64
+
65
+ @classmethod
66
+ def from_mapping(cls, raw: Mapping[str, Any], env: Mapping[str, str] | None = None) -> Self:
67
+ env = env or {}
68
+ name = _as_str(env.get("DEEPY_MODEL"), _as_str(raw.get("name"), DEFAULT_MODEL))
69
+ base_url = _as_str(
70
+ env.get("DEEPY_BASE_URL"),
71
+ _as_str(raw.get("base_url"), DEFAULT_BASE_URL),
72
+ )
73
+ api_key = _as_str(env.get("DEEPY_API_KEY"), _as_str(raw.get("api_key"), "")) or None
74
+ effort = _as_str(raw.get("reasoning_effort"), "max")
75
+ if effort not in REASONING_EFFORTS:
76
+ effort = "max"
77
+
78
+ thinking_value = raw.get("thinking")
79
+ thinking = thinking_value if isinstance(thinking_value, bool) else None
80
+
81
+ return cls(
82
+ name=name,
83
+ base_url=base_url,
84
+ api_key=api_key,
85
+ thinking=thinking,
86
+ reasoning_effort=effort,
87
+ )
88
+
89
+ @property
90
+ def thinking_enabled(self) -> bool:
91
+ if self.thinking is not None:
92
+ return self.thinking
93
+ return self.name.lower() in {"deepseek-v4-pro", "deepseek-v4-flash"}
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class ContextConfig:
98
+ window_tokens: int = DEFAULT_CONTEXT_WINDOW_TOKENS
99
+ compact_trigger_ratio: float = DEFAULT_COMPACT_TRIGGER_RATIO
100
+ compact_prompt_token_threshold: int | None = None
101
+
102
+ @classmethod
103
+ def from_mapping(cls, raw: Mapping[str, Any]) -> Self:
104
+ window_tokens = _as_int(raw.get("window_tokens"), DEFAULT_CONTEXT_WINDOW_TOKENS)
105
+ ratio = _as_float(raw.get("compact_trigger_ratio"), DEFAULT_COMPACT_TRIGGER_RATIO)
106
+ if ratio <= 0 or ratio > 1:
107
+ ratio = DEFAULT_COMPACT_TRIGGER_RATIO
108
+ threshold = raw.get("compact_prompt_token_threshold")
109
+ compact_threshold = _as_int(threshold, 0) if threshold is not None else None
110
+ return cls(
111
+ window_tokens=window_tokens,
112
+ compact_trigger_ratio=ratio,
113
+ compact_prompt_token_threshold=compact_threshold or None,
114
+ )
115
+
116
+ @property
117
+ def resolved_compact_threshold(self) -> int:
118
+ if self.compact_prompt_token_threshold:
119
+ return self.compact_prompt_token_threshold
120
+ return int(self.window_tokens * self.compact_trigger_ratio + 0.999999)
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class LoggingConfig:
125
+ debug: bool = False
126
+
127
+ @classmethod
128
+ def from_mapping(cls, raw: Mapping[str, Any]) -> Self:
129
+ return cls(debug=_as_bool(raw.get("debug"), False))
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class NotifyConfig:
134
+ enabled: bool = False
135
+ command: str | None = None
136
+
137
+ @classmethod
138
+ def from_mapping(cls, raw: Mapping[str, Any]) -> Self:
139
+ command = _as_str(raw.get("command")) or None
140
+ return cls(enabled=_as_bool(raw.get("enabled"), bool(command)), command=command)
141
+
142
+
143
+ @dataclass(frozen=True)
144
+ class WebSearchToolConfig:
145
+ command: str | None = None
146
+ api_url: str | None = None
147
+ machine_id: str | None = None
148
+
149
+ @classmethod
150
+ def from_mapping(cls, raw: Mapping[str, Any]) -> Self:
151
+ return cls(
152
+ command=_as_str(raw.get("command")) or None,
153
+ api_url=_as_str(raw.get("api_url")) or None,
154
+ machine_id=_as_str(raw.get("machine_id")) or None,
155
+ )
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class ToolsConfig:
160
+ web_search: WebSearchToolConfig = field(default_factory=WebSearchToolConfig)
161
+
162
+ @classmethod
163
+ def from_mapping(cls, raw: Mapping[str, Any]) -> Self:
164
+ return cls(web_search=WebSearchToolConfig.from_mapping(_as_mapping(raw.get("web_search"))))
165
+
166
+
167
+ @dataclass(frozen=True)
168
+ class Settings:
169
+ model: ModelConfig = field(default_factory=ModelConfig)
170
+ context: ContextConfig = field(default_factory=ContextConfig)
171
+ logging: LoggingConfig = field(default_factory=LoggingConfig)
172
+ notify: NotifyConfig = field(default_factory=NotifyConfig)
173
+ tools: ToolsConfig = field(default_factory=ToolsConfig)
174
+ path: Path | None = None
175
+
176
+ @classmethod
177
+ def from_mapping(
178
+ cls,
179
+ raw: Mapping[str, Any],
180
+ *,
181
+ path: Path | None = None,
182
+ env: Mapping[str, str] | None = None,
183
+ ) -> Self:
184
+ return cls(
185
+ model=ModelConfig.from_mapping(_as_mapping(raw.get("model")), env=env),
186
+ context=ContextConfig.from_mapping(_as_mapping(raw.get("context"))),
187
+ logging=LoggingConfig.from_mapping(_as_mapping(raw.get("logging"))),
188
+ notify=NotifyConfig.from_mapping(_as_mapping(raw.get("notify"))),
189
+ tools=ToolsConfig.from_mapping(_as_mapping(raw.get("tools"))),
190
+ path=path,
191
+ )
192
+
193
+
194
+ def load_settings(
195
+ path: str | os.PathLike[str] | None = None,
196
+ *,
197
+ env: Mapping[str, str] | None = None,
198
+ ) -> Settings:
199
+ config_path = Path(path).expanduser() if path is not None else default_config_path()
200
+ if config_path.suffix == ".json":
201
+ raise ValueError("Deepy only supports TOML config files; JSON config is not supported.")
202
+ env = env or os.environ
203
+ if not config_path.exists():
204
+ return Settings.from_mapping({}, path=config_path, env=env)
205
+
206
+ with config_path.open("rb") as fh:
207
+ raw = tomllib.load(fh)
208
+ return Settings.from_mapping(raw, path=config_path, env=env)
209
+
210
+
211
+ def settings_to_toml_dict(settings: Settings, *, reveal_secret: bool = False) -> dict[str, Any]:
212
+ data = _drop_empty(asdict(settings))
213
+ data.pop("path", None)
214
+ api_key = settings.model.api_key
215
+ if api_key:
216
+ data["model"]["api_key"] = api_key if reveal_secret else mask_secret(api_key)
217
+ data["model"]["thinking"] = settings.model.thinking_enabled
218
+ data["context"]["compact_prompt_token_threshold"] = (
219
+ settings.context.resolved_compact_threshold
220
+ )
221
+ return _drop_empty(data)
222
+
223
+
224
+ def _drop_empty(value: Any) -> Any:
225
+ if isinstance(value, dict):
226
+ result = {}
227
+ for key, item in value.items():
228
+ if item is None:
229
+ continue
230
+ cleaned = _drop_empty(item)
231
+ if cleaned == {}:
232
+ continue
233
+ result[key] = cleaned
234
+ return result
235
+ if isinstance(value, list):
236
+ return [_drop_empty(item) for item in value]
237
+ return value
deepy/data/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Package data for Deepy."""