zall 0.4.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 (111) hide show
  1. zall/__init__.py +7 -0
  2. zall/__main__.py +3 -0
  3. zall/_util/__init__.py +15 -0
  4. zall/_util/file.py +115 -0
  5. zall/_util/file_state.py +123 -0
  6. zall/_util/logging.py +130 -0
  7. zall/_util/model_registry.py +225 -0
  8. zall/_util/path.py +74 -0
  9. zall/_util/string.py +22 -0
  10. zall/_util/toml.py +223 -0
  11. zall/_util/win32.py +91 -0
  12. zall/adapters/__init__.py +26 -0
  13. zall/adapters/anthropic.py +399 -0
  14. zall/adapters/base.py +208 -0
  15. zall/adapters/gemini.py +343 -0
  16. zall/adapters/ollama.py +305 -0
  17. zall/adapters/openai_compat.py +446 -0
  18. zall/cli/__init__.py +8 -0
  19. zall/cli/app.py +131 -0
  20. zall/cli/commands/__init__.py +159 -0
  21. zall/cli/commands/_common.py +1029 -0
  22. zall/cli/commands/_legacy.py +101 -0
  23. zall/cli/commands/files.py +318 -0
  24. zall/cli/commands/git.py +210 -0
  25. zall/cli/commands/model.py +697 -0
  26. zall/cli/commands/reload.py +92 -0
  27. zall/cli/commands/session.py +474 -0
  28. zall/cli/commands/system.py +460 -0
  29. zall/cli/commands/v040.py +428 -0
  30. zall/cli/config.py +419 -0
  31. zall/cli/config_layers.py +246 -0
  32. zall/cli/environment.py +738 -0
  33. zall/cli/init_wizard.py +284 -0
  34. zall/cli/judge.py +206 -0
  35. zall/cli/orchestrator.py +527 -0
  36. zall/cli/prompt.py +268 -0
  37. zall/cli/render.py +966 -0
  38. zall/cli/repl_ui.py +439 -0
  39. zall/cli/replay.py +310 -0
  40. zall/cli/responder.py +247 -0
  41. zall/cli/session.py +650 -0
  42. zall/cli/update.py +263 -0
  43. zall/codegraph/__init__.py +919 -0
  44. zall/core/__init__.py +40 -0
  45. zall/core/accountability.py +318 -0
  46. zall/core/action.py +66 -0
  47. zall/core/agent.py +530 -0
  48. zall/core/builder.py +407 -0
  49. zall/core/chat_state.py +596 -0
  50. zall/core/checkpoint.py +416 -0
  51. zall/core/compactor.py +460 -0
  52. zall/core/context.py +117 -0
  53. zall/core/events.py +148 -0
  54. zall/core/executor.py +361 -0
  55. zall/core/extension.py +238 -0
  56. zall/core/gate.py +337 -0
  57. zall/core/goal.py +420 -0
  58. zall/core/lifecycle.py +262 -0
  59. zall/core/loop.py +1597 -0
  60. zall/core/memory.py +216 -0
  61. zall/core/model.py +241 -0
  62. zall/core/refiner.py +320 -0
  63. zall/core/safety.py +316 -0
  64. zall/core/tool.py +159 -0
  65. zall/core/toolset.py +223 -0
  66. zall/core/verifiability.py +571 -0
  67. zall/eval/__init__.py +15 -0
  68. zall/eval/metrics.py +349 -0
  69. zall/extensions/__init__.py +1 -0
  70. zall/extensions/auto_learn.py +507 -0
  71. zall/extensions/usage_tracker.py +91 -0
  72. zall/lsp/__init__.py +849 -0
  73. zall/mcp/__init__.py +22 -0
  74. zall/mcp/client.py +270 -0
  75. zall/mcp/config.py +191 -0
  76. zall/mcp/tool.py +124 -0
  77. zall/plugin/__init__.py +585 -0
  78. zall/safety/__init__.py +10 -0
  79. zall/safety/config.py +162 -0
  80. zall/safety/rules_file.py +579 -0
  81. zall/sandbox/__init__.py +599 -0
  82. zall/skills/__init__.py +11 -0
  83. zall/skills/loader.py +202 -0
  84. zall/tools/__init__.py +11 -0
  85. zall/tools/_bash_semantics.py +257 -0
  86. zall/tools/_diff.py +24 -0
  87. zall/tools/bash.py +633 -0
  88. zall/tools/batch_edit.py +315 -0
  89. zall/tools/code_understanding.py +249 -0
  90. zall/tools/codegraph.py +330 -0
  91. zall/tools/edit_file.py +188 -0
  92. zall/tools/git_protect.py +189 -0
  93. zall/tools/glob.py +133 -0
  94. zall/tools/grep.py +271 -0
  95. zall/tools/list_dir.py +151 -0
  96. zall/tools/lsp_diagnostics.py +315 -0
  97. zall/tools/project_analysis.py +217 -0
  98. zall/tools/pty_executor.py +253 -0
  99. zall/tools/read_file.py +257 -0
  100. zall/tools/read_image.py +312 -0
  101. zall/tools/search.py +397 -0
  102. zall/tools/spawn_subagent.py +687 -0
  103. zall/tools/todo.py +137 -0
  104. zall/tools/web_fetch.py +488 -0
  105. zall/tools/write_file.py +141 -0
  106. zall-0.4.0.dist-info/METADATA +316 -0
  107. zall-0.4.0.dist-info/RECORD +111 -0
  108. zall-0.4.0.dist-info/WHEEL +5 -0
  109. zall-0.4.0.dist-info/entry_points.txt +2 -0
  110. zall-0.4.0.dist-info/licenses/LICENSE +21 -0
  111. zall-0.4.0.dist-info/top_level.txt +1 -0
zall/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """zall — model-agnostic engineering agent.
2
+
3
+ Model-agnostic engineering agent. This package MUST NOT import any model SDK;
4
+ Model adapters reside in `zall.adapters`. See IMPL.md IPR-3.
5
+ """
6
+
7
+ __version__ = "0.2.7"
zall/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from zall.cli import main
2
+
3
+ raise SystemExit(main())
zall/_util/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """zall._util — 内部共享toolfunction库。
2
+
3
+ 本包为 zall 内部使用的工具函数, 不构成 public API。
4
+ 各模块消除重复代码用 (B22/B23/B24 等)。
5
+ """
6
+
7
+ from zall._util.file import is_binary, read_text_file
8
+ from zall._util.path import NOISE_DIRS, is_noise, skip_noise_dirs
9
+ from zall._util.string import unquote
10
+
11
+ __all__ = [
12
+ "is_binary", "read_text_file",
13
+ "NOISE_DIRS", "is_noise", "skip_noise_dirs",
14
+ "unquote",
15
+ ]
zall/_util/file.py ADDED
@@ -0,0 +1,115 @@
1
+ """zall._util.file — File operations共享toolfunction。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import locale
6
+ from pathlib import Path
7
+
8
+
9
+ def _preferred_encoding() -> str:
10
+ """Get the system's preferred encoding (Windows Chinese is GBK/CP936, do not hardcode UTF-8)."""
11
+ try:
12
+ enc = locale.getpreferredencoding(False)
13
+ if enc:
14
+ return enc
15
+ except (ValueError, LookupError):
16
+ pass
17
+ return "utf-8" # fallback
18
+
19
+
20
+ def detect_text_encoding(path: Path) -> str:
21
+ """Detect text file encoding: try UTF-8 first, fallback to system preferred encoding.
22
+
23
+ Most modern code/text files are UTF-8. On Chinese Windows, the system default is GBK,
24
+ but opening a UTF-8 file with GBK produces mojibake. This function tries UTF-8 first
25
+ (strict), and verifies via round-trip to avoid GBK text accidentally passing UTF-8
26
+ validation (e.g., GBK bytes that happen to form valid UTF-8 sequences).
27
+
28
+ Note: the returned encoding should be used WITHOUT errors='replace', because
29
+ replacement characters (U+FFFD) cannot be encoded in the target encoding
30
+ (e.g., GBK) when the content is sent to the API pipeline.
31
+ """
32
+ try:
33
+ with open(path, "rb") as f:
34
+ raw = f.read(8192)
35
+ # Strict UTF-8 decode
36
+ decoded = raw.decode("utf-8")
37
+ # Round-trip verification: re-encode must match original bytes exactly
38
+ # This catches GBK text that happens to be valid UTF-8 (different characters)
39
+ if decoded.encode("utf-8") == raw:
40
+ return "utf-8"
41
+ except (UnicodeDecodeError, OSError):
42
+ pass
43
+
44
+ # Try system encoding (GBK/CP936 on Chinese Windows)
45
+ sys_enc = _preferred_encoding()
46
+ if sys_enc.lower() != "utf-8":
47
+ try:
48
+ raw.decode(sys_enc)
49
+ return sys_enc
50
+ except (UnicodeDecodeError, LookupError):
51
+ pass
52
+
53
+ return "utf-8" # best-effort fallback
54
+
55
+
56
+ def is_binary(path: Path) -> bool:
57
+ """检测二进制file: 读前 8KB, check空字节。
58
+
59
+ 失败时返回 True (fail-safe: 二进制文件不读).
60
+ B23: 统一 grep.py 的 _is_binary 和 read_file.py 的内联检测。
61
+ """
62
+ try:
63
+ with open(path, "rb") as f:
64
+ chunk = f.read(8192)
65
+ return b"\0" in chunk
66
+ except OSError:
67
+ return True
68
+
69
+
70
+ def read_text_file(path: Path, encoding: str | None = None) -> str:
71
+ """read文本file, 统一exceptionhandle。
72
+
73
+ B22: 统一 edit_file.py 和 batch_edit.py 的文件读取逻辑。
74
+ 自动解析相对路径, 检查存在性/类型, 返回文件内容。
75
+ 抛出 OSError 时调用方自行处理。
76
+ 编码默认自动检测: 先尝试 UTF-8, 失败回退系统编码。
77
+ """
78
+ if encoding is None:
79
+ encoding = detect_text_encoding(path)
80
+ path = Path.cwd() / path
81
+ if not path.exists():
82
+ raise FileNotFoundError(f"file not found: {path}")
83
+ if not path.is_file():
84
+ raise IsADirectoryError(f"not a file: {path}")
85
+ return path.read_text(encoding=encoding)
86
+
87
+
88
+ def atomic_write(path: Path, content: str, encoding: str | None = None) -> Path:
89
+ """原子writefile — 使用唯一临时file名, 避免 concurrent write竞态。
90
+
91
+ v2 fix: 旧实现 path.with_suffix(suffix + ".zall_tmp") 在两个 agent
92
+ 实例同时编辑同一文件时会产生相同临时文件路径, 导致竞态。
93
+ 新实现使用 uuid 生成唯一文件名, 保证并发安全。
94
+ 编码默认 UTF-8 (现代标准, 与 detect_text_encoding 的优先尝试一致)。
95
+
96
+ Returns: 临时文件路径 (已被 os.replace 移走, 不再存在)
97
+ Raises: OSError on write/replace failure (临时文件已清理)
98
+ """
99
+ import os
100
+ import uuid
101
+ if encoding is None:
102
+ encoding = "utf-8"
103
+ tmp_name = f".zall_tmp_{uuid.uuid4().hex[:8]}"
104
+ tmp = path.parent / tmp_name
105
+ try:
106
+ tmp.write_text(content, encoding=encoding)
107
+ os.replace(str(tmp), str(path))
108
+ except OSError:
109
+ try:
110
+ if tmp.exists():
111
+ tmp.unlink()
112
+ except Exception:
113
+ pass
114
+ raise
115
+ return tmp
@@ -0,0 +1,123 @@
1
+ """File state cache for tracking read file contents and modification state.
2
+
3
+ 缓存每个文件的最后读取时间和 mtime, 支持:
4
+ - is_file_unchanged(path) — 文件是否在上次读取后未被修改
5
+ - get_cached_mtime(path) — 获取缓存的 mtime
6
+ - mark_file_read(path) — 标记文件已读取
7
+ - invalidate(path) — 使缓存失效
8
+
9
+ 用途: 避免 agent 重复读取未修改的文件, 节省 API token。
10
+ 当 agent 调用 read_file 后, 文件状态被缓存; 下次读取前先检查 mtime,
11
+ 如果未变化则提示 agent "file unchanged, no need to re-read"。
12
+
13
+ IPR constraints:
14
+ IPR-3: stdlib only, no model SDK
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import threading
20
+ import time
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+
25
+ class FileStateCache:
26
+ """filestatecache — 跟踪file mtime + read时间。
27
+
28
+ 线程安全: 使用 RLock 保护内部 dict。
29
+ 缓存上限: 1000 条 (LRU 淘汰), 防长会话无界增长。
30
+ """
31
+
32
+ __test__ = False
33
+
34
+ _MAX_ENTRIES = 1000
35
+
36
+ def __init__(self) -> None:
37
+ self._lock = threading.RLock()
38
+ # key: str(path), value: {"mtime": float, "read_at": float, "size": int}
39
+ self._cache: dict[str, dict[str, Any]] = {}
40
+
41
+ def mark_file_read(self, path: Path | str) -> None:
42
+ """标记file已read, cache其 mtime 和 size。"""
43
+ key = str(path)
44
+ try:
45
+ stat = Path(path).stat()
46
+ mtime = stat.st_mtime
47
+ size = stat.st_size
48
+ except OSError:
49
+ return
50
+ with self._lock:
51
+ # LRU 淘汰
52
+ if len(self._cache) >= self._MAX_ENTRIES:
53
+ # 淘汰最旧的条目
54
+ oldest_key = min(self._cache, key=lambda k: self._cache[k].get("read_at", 0))
55
+ self._cache.pop(oldest_key, None)
56
+ self._cache[key] = {"mtime": mtime, "read_at": time.time(), "size": size}
57
+
58
+ def is_file_unchanged(self, path: Path | str) -> bool:
59
+ """checkfile是否在上次read后未被修改。
60
+
61
+ Returns:
62
+ True — 文件未修改 (可以跳过重复读取)
63
+ False — 文件已修改或未被缓存过 (需要重新读取)
64
+ """
65
+ key = str(path)
66
+ with self._lock:
67
+ cached = self._cache.get(key)
68
+ if cached is None:
69
+ return False
70
+ try:
71
+ current_mtime = Path(path).stat().st_mtime
72
+ except OSError:
73
+ return False
74
+ return bool(cached["mtime"] == current_mtime)
75
+
76
+ def get_cached_mtime(self, path: Path | str) -> float | None:
77
+ """获取cache的 mtime (不读盘)。"""
78
+ key = str(path)
79
+ with self._lock:
80
+ entry = self._cache.get(key)
81
+ return entry["mtime"] if entry else None
82
+
83
+ def invalidate(self, path: Path | str | None = None) -> None:
84
+ """使cache失效。path=None 时清除全部cache。"""
85
+ with self._lock:
86
+ if path is None:
87
+ self._cache.clear()
88
+ else:
89
+ self._cache.pop(str(path), None)
90
+
91
+ def get_changed_files(self) -> list[str]:
92
+ """return自上次read以来被修改的filelist (供 /doctor 用)。"""
93
+ changed = []
94
+ with self._lock:
95
+ for key, entry in self._cache.items():
96
+ try:
97
+ current_mtime = Path(key).stat().st_mtime
98
+ if current_mtime != entry["mtime"]:
99
+ changed.append(key)
100
+ except OSError:
101
+ changed.append(key) # 文件被删除也算变更
102
+ return changed
103
+
104
+ @property
105
+ def size(self) -> int:
106
+ """当前cache条目数。"""
107
+ with self._lock:
108
+ return len(self._cache)
109
+
110
+
111
+ # 全局singleton (process级)
112
+ _global_cache: FileStateCache | None = None
113
+ _global_lock = threading.Lock()
114
+
115
+
116
+ def get_file_state_cache() -> FileStateCache:
117
+ """获取全局 FileStateCache singleton。"""
118
+ global _global_cache
119
+ if _global_cache is None:
120
+ with _global_lock:
121
+ if _global_cache is None:
122
+ _global_cache = FileStateCache()
123
+ return _global_cache
zall/_util/logging.py ADDED
@@ -0,0 +1,130 @@
1
+ """zall._util.logging — Unified logging configuration (IPR-0: self-falsifiable errors).
2
+
3
+ Inspired by Grok Build's xai-tracing crate: structured, contextual logging that
4
+ makes non-fatal errors observable without changing control flow.
5
+
6
+ Design:
7
+ - Single entry point: get_zall_logger(__name__) → standard library logger
8
+ - ZALL_LOG_LEVEL env var controls verbosity (default: WARNING)
9
+ - IPR-0 invariant: observer/renderer errors are swallowed but LOGGED
10
+ (silent pass → violates falsifiability; logged pass → observable)
11
+
12
+ Usage:
13
+ from zall._util.logging import get_zall_logger
14
+ logger = get_zall_logger(__name__)
15
+ ...
16
+ except Exception as e:
17
+ logger.warning("operation failed: %s", e) # observable, non-fatal
18
+
19
+ Corresponds to:
20
+ §0 PR-0: self-falsifiability — silent errors are unfalsifiable
21
+ IPR-0 Self-falsifiability code form — errors must be observable
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import os
28
+ import sys
29
+ from typing import TextIO
30
+
31
+ # ── Defaults ──
32
+
33
+ _DEFAULT_LEVEL = logging.WARNING
34
+ _ENV_VAR = "ZALL_LOG_LEVEL"
35
+
36
+ # Level name → int mapping for env var parsing
37
+ _LEVEL_NAMES: dict[str, int] = {
38
+ "DEBUG": logging.DEBUG,
39
+ "INFO": logging.INFO,
40
+ "WARNING": logging.WARNING,
41
+ "ERROR": logging.ERROR,
42
+ "CRITICAL": logging.CRITICAL,
43
+ }
44
+
45
+
46
+ def _resolve_level() -> int:
47
+ """Resolve log level from environment or default."""
48
+ raw = os.environ.get(_ENV_VAR, "").strip().upper()
49
+ if raw in _LEVEL_NAMES:
50
+ return _LEVEL_NAMES[raw]
51
+ if raw and raw.isdigit():
52
+ return int(raw)
53
+ return _DEFAULT_LEVEL
54
+
55
+
56
+ # ── Formatter ──
57
+
58
+ _FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
59
+ _FORMAT_DATE = "%H:%M:%S"
60
+
61
+
62
+ def _make_handler(stream: TextIO = sys.stderr) -> logging.Handler:
63
+ """Create a stderr handler with the standard format."""
64
+ handler = logging.StreamHandler(stream)
65
+ handler.setFormatter(logging.Formatter(_FORMAT, _FORMAT_DATE))
66
+ return handler
67
+
68
+
69
+ # ── Global state (lazy-initialized) ──
70
+
71
+ _initialized = False
72
+
73
+
74
+ def setup_logging(
75
+ *,
76
+ level: int | None = None,
77
+ stream: TextIO | None = None,
78
+ force: bool = False,
79
+ ) -> None:
80
+ """Configure the root zall logger.
81
+
82
+ Called once at process start (from main/REPL). Safe to call multiple
83
+ times — subsequent calls are no-ops unless *force=True*.
84
+
85
+ Args:
86
+ level: Log level (default: from ZALL_LOG_LEVEL env, or WARNING).
87
+ stream: Output stream (default: sys.stderr).
88
+ force: Reconfigure even if already initialized.
89
+ """
90
+ global _initialized
91
+ if _initialized and not force:
92
+ return
93
+
94
+ root = logging.getLogger("zall")
95
+ root.setLevel(level if level is not None else _resolve_level())
96
+
97
+ # Remove existing handlers to avoid duplicates on force re-init
98
+ if force:
99
+ root.handlers.clear()
100
+
101
+ if not root.handlers:
102
+ root.addHandler(_make_handler(stream or sys.stderr))
103
+
104
+ _initialized = True
105
+
106
+
107
+ def get_zall_logger(name: str) -> logging.Logger:
108
+ """Get a zall-namespaced logger.
109
+
110
+ Usage:
111
+ logger = get_zall_logger(__name__)
112
+ logger.warning("non-fatal issue: %s", detail)
113
+
114
+ The logger name is prefixed with 'zall.' to keep it under the
115
+ zall root logger's level configuration.
116
+ """
117
+ # Strip leading 'zall.' if already present to avoid 'zall.zall...'
118
+ if name.startswith("zall."):
119
+ logger_name = name
120
+ elif name.startswith("zall"):
121
+ logger_name = f"zall.{name}"
122
+ else:
123
+ logger_name = f"zall.{name}"
124
+
125
+ return logging.getLogger(logger_name)
126
+
127
+
128
+ def get_log_level() -> int:
129
+ """Return the current effective log level for the zall root logger."""
130
+ return logging.getLogger("zall").level
@@ -0,0 +1,225 @@
1
+ """zall._util.model_registry — 统一model元数据 (window大小 + 价格 + 别名 + provider register表).
2
+
3
+ C1: 消除模型元数据分散在两处的问题:
4
+ - compactor.py 的 _KNOWN_WINDOWS (窗口大小)
5
+ - app.py _cmd_cost 的 _PRICES (价格表)
6
+ - cli/config.py 的 _MODEL_PRESETS (别名/预设)
7
+
8
+ 新增模型时只需更新本文件一处。
9
+
10
+ 重命名自 model_meta.py (v0.2.2): 更准确的命名反映其包含 provider 注册表。"""
11
+
12
+ from __future__ import annotations
13
+
14
+
15
+ # ──────────────────────────────────────────────────────────────────────────
16
+ # modelwindow大小 (token)
17
+ # ──────────────────────────────────────────────────────────────────────────
18
+
19
+ _KNOWN_WINDOWS: dict[str, int] = {
20
+ # OpenAI
21
+ "gpt-4o": 128000,
22
+ "gpt-4o-mini": 128000,
23
+ "gpt-4-turbo": 128000,
24
+ "gpt-4.1": 128000,
25
+ "gpt-4.1-mini": 128000,
26
+ "gpt-4.1-nano": 128000,
27
+ "o1": 200000,
28
+ "o3-mini": 200000,
29
+ "o4-mini": 200000,
30
+ # Anthropic
31
+ "claude-3-5-sonnet": 200000,
32
+ "claude-3-5-haiku": 200000,
33
+ "claude-3-opus": 200000,
34
+ "claude-3-haiku": 200000,
35
+ "claude-sonnet-4": 200000,
36
+ "claude-sonnet-4-20250514": 200000,
37
+ # Google
38
+ "gemini-2.5-pro": 1_000_000,
39
+ "gemini-2.5-flash": 1_000_000,
40
+ "gemini-2.0-flash": 1_000_000,
41
+ # DeepSeek
42
+ "deepseek-chat": 128000,
43
+ "deepseek-reasoner": 128000,
44
+ "deepseek-v3": 128000,
45
+ "deepseek-r1": 128000,
46
+ # Meta
47
+ "llama3.1": 128000,
48
+ "llama3": 8192,
49
+ "llama-3": 8192,
50
+ # Qwen (all modern variants 128K)
51
+ "qwen2.5": 128000,
52
+ "qwen2.5-coder": 128000,
53
+ "qwen-plus": 131072,
54
+ "qwen3": 131072,
55
+ # 其他
56
+ "agnes-1.5-flash": 128000,
57
+ "agnes-2.0-flash": 128000,
58
+ "agnes-2.5-flash": 128000,
59
+ "glm-4-flash": 128000,
60
+ "glm-4": 128000,
61
+ # llama.cpp (window大小由启动parameter决定, 写常见值)
62
+ "llama.cpp-local": 8192,
63
+ }
64
+
65
+ # defaultwindow大小 (未知model)
66
+ _DEFAULT_WINDOW: int = 32000
67
+
68
+
69
+ # ──────────────────────────────────────────────────────────────────────────
70
+ # model价格表 ($/1M tokens)
71
+ # ──────────────────────────────────────────────────────────────────────────
72
+
73
+ _KNOWN_PRICES: dict[str, tuple[float, float]] = {
74
+ # OpenAI
75
+ "gpt-4o": (2.50, 10.00),
76
+ "gpt-4o-mini": (0.15, 0.60),
77
+ "gpt-4-turbo": (10.00, 30.00),
78
+ "gpt-4.1": (2.00, 8.00),
79
+ "gpt-4.1-mini": (0.40, 1.60),
80
+ "gpt-4.1-nano": (0.10, 0.40),
81
+ "o1": (15.00, 60.00),
82
+ "o3-mini": (1.10, 4.40),
83
+ "o4-mini": (0.15, 0.60),
84
+ # Anthropic
85
+ "claude-3-5-sonnet": (3.00, 15.00),
86
+ "claude-3-5-haiku": (0.80, 4.00),
87
+ "claude-3-opus": (15.00, 75.00),
88
+ "claude-3-haiku": (0.25, 1.25),
89
+ "claude-sonnet-4": (3.00, 15.00),
90
+ "claude-sonnet-4-20250514": (3.00, 15.00),
91
+ # Google
92
+ "gemini-2.5-pro": (1.25, 5.00),
93
+ "gemini-2.5-flash": (0.15, 0.60),
94
+ "gemini-2.0-flash": (0.10, 0.40),
95
+ # DeepSeek
96
+ "deepseek-chat": (0.14, 0.28),
97
+ "deepseek-reasoner": (0.55, 2.19),
98
+ "deepseek-v3": (0.27, 1.10),
99
+ "deepseek-r1": (0.55, 2.19),
100
+ # Qwen
101
+ "qwen2.5": (0.35, 1.20),
102
+ "qwen2.5-coder": (0.35, 1.20),
103
+ "qwen-plus": (0.80, 2.00),
104
+ "qwen3": (0.80, 2.00),
105
+ # Zhipu GLM
106
+ "glm-4-flash": (0.10, 0.10),
107
+ "glm-4": (0.50, 1.50),
108
+ # 本地model / 其他
109
+ "agnes-1.5-flash": (0.15, 0.60),
110
+ "agnes-2.0-flash": (0.15, 0.60),
111
+ "agnes-2.5-flash": (0.15, 0.60),
112
+ "llama3.1": (0.0, 0.0), # local
113
+ "llama.cpp-local": (0.0, 0.0), # local (llama.cpp server)
114
+ }
115
+
116
+ # default价格 (未知model用保守值)
117
+ _DEFAULT_PRICE_IN: float = 3.0 # $3/M input
118
+ _DEFAULT_PRICE_OUT: float = 15.0 # $15/M output
119
+
120
+ # O8: 模块级预sort (按名称长度降序),避免每次调 get_window_size/get_price 都 sorted()
121
+ _SORTED_WINDOWS: list[tuple[str, int]] = sorted(
122
+ _KNOWN_WINDOWS.items(), key=lambda x: -len(x[0])
123
+ )
124
+ _SORTED_PRICES: list[tuple[str, tuple[float, float]]] = sorted(
125
+ _KNOWN_PRICES.items(), key=lambda x: -len(x[0])
126
+ )
127
+
128
+
129
+ def get_window_size(model_name: str) -> int:
130
+ """查modelwindow大小。已知modelreturn精确值, 未知return保守default值。"""
131
+ if not model_name:
132
+ return _DEFAULT_WINDOW
133
+ # B1 fix: 先精确匹配完整名称
134
+ if model_name in _KNOWN_WINDOWS:
135
+ return _KNOWN_WINDOWS[model_name]
136
+ # 再按前缀匹配, 按名称长度降序 (长前缀优先, 防 gpt-4o-mini 误配 gpt-4o)
137
+ for known, size in _SORTED_WINDOWS:
138
+ if model_name.startswith(known):
139
+ return size
140
+ return _DEFAULT_WINDOW
141
+
142
+
143
+ def get_price(model_name: str) -> tuple[float, float]:
144
+ """获取model价格 (input_price, output_price) $/1M tokens。
145
+
146
+ 未知模型返回保守默认值 ($3/$15 per 1M)。
147
+ B1 fix: 先精确匹配完整名称, 再按长前缀降序匹配。
148
+ """
149
+ if not model_name:
150
+ return (_DEFAULT_PRICE_IN, _DEFAULT_PRICE_OUT)
151
+ # 先精确匹配
152
+ if model_name in _KNOWN_PRICES:
153
+ return _KNOWN_PRICES[model_name]
154
+ # 再按前缀长度降序匹配
155
+ for prefix, prices in _SORTED_PRICES:
156
+ if model_name.startswith(prefix):
157
+ return prices
158
+ return (_DEFAULT_PRICE_IN, _DEFAULT_PRICE_OUT)
159
+
160
+
161
+ # ──────────────────────────────────────────────────────────────────────────
162
+ # Item B: Provider register表 (统一元数据, 消除 if/elif 链)
163
+ # ──────────────────────────────────────────────────────────────────────────
164
+
165
+ # Item D: 6-tuple: (display, env_var, default_api_base, get_key_url, model_prefixes, adapter_import_path)
166
+ # adapter_import_path 格式: "module.path:ClassName" — 供 _build_adapter 用 importlib dynamicload
167
+ _ProviderMeta = dict[str, tuple[str, str, str, str, tuple[str, ...], str]]
168
+
169
+ _PROVIDER_REGISTRY: _ProviderMeta = {
170
+ "openai": ("OpenAI-compatible", "ZALL_API_KEY", "https://api.openai.com/v1", "https://platform.openai.com/api-keys", ("gpt-", "o1", "o3", "o4", "glm-", "qwen"), "zall.adapters.openai_compat:OpenAICompatAdapter"),
171
+ "anthropic": ("Anthropic Claude", "ANTHROPIC_API_KEY", "https://api.anthropic.com", "https://console.anthropic.com/", ("claude-", "claude"), "zall.adapters.anthropic:AnthropicAdapter"),
172
+ "gemini": ("Google Gemini", "GOOGLE_API_KEY", "https://generativelanguage.googleapis.com", "https://aistudio.google.com/app/apikey", ("gemini-", "gemini"), "zall.adapters.gemini:GeminiAdapter"),
173
+ "deepseek": ("DeepSeek", "DEEPSEEK_API_KEY", "https://api.deepseek.com/v1", "https://platform.deepseek.com/api_keys", ("deepseek-", "deepseek"), "zall.adapters.openai_compat:OpenAICompatAdapter"),
174
+ "ollama": ("Ollama (local)", "", "http://localhost:11434", "https://ollama.ai", ("ollama-", "llama"), "zall.adapters.ollama:OllamaAdapter"),
175
+ "agnes": ("Agnes AI", "ZALL_API_KEY", "https://apihub.agnes-ai.com/v1", "https://apihub.agnes-ai.com", ("agnes-",), "zall.adapters.openai_compat:OpenAICompatAdapter"),
176
+ }
177
+
178
+ # compatible性: preserve旧 dict reference (代码中仍reference _PROVIDER_DISPLAY 等)
179
+ _provider_display = {k: v[0] for k, v in _PROVIDER_REGISTRY.items()}
180
+ _provider_env_vars = {k: v[1] for k, v in _PROVIDER_REGISTRY.items()}
181
+ _provider_api_bases = {k: v[2] for k, v in _PROVIDER_REGISTRY.items()}
182
+ _provider_key_urls = {k: v[3] for k, v in _PROVIDER_REGISTRY.items()}
183
+
184
+ # Item D: 已知 adapter type → import path mapping (供 TOML 自定义 provider 用)
185
+ _ADAPTER_TYPE_MAP: dict[str, str] = {
186
+ "openai-compat": "zall.adapters.openai_compat:OpenAICompatAdapter",
187
+ "anthropic": "zall.adapters.anthropic:AnthropicAdapter",
188
+ "gemini": "zall.adapters.gemini:GeminiAdapter",
189
+ "ollama": "zall.adapters.ollama:OllamaAdapter",
190
+ }
191
+
192
+
193
+ def get_model_provider(model_name: str) -> str:
194
+ """根据model名推断 provider type (Item B: 基于register表)。"""
195
+ if not model_name:
196
+ return "openai"
197
+ model_lower = model_name.lower()
198
+ for provider, (_display, _env, _base, _url, prefixes, _adapter) in _PROVIDER_REGISTRY.items():
199
+ for prefix in prefixes:
200
+ if model_lower.startswith(prefix):
201
+ return provider
202
+ return "openai" # default
203
+
204
+
205
+ # ──────────────────────────────────────────────────────────────────────────
206
+ # model预设list (供交互式 picker 使用, 从 config.py 迁移至此)
207
+ # ──────────────────────────────────────────────────────────────────────────
208
+
209
+ _MODEL_PRESETS: list[tuple[str, str, str, str]] = [
210
+ ("agnes-2.0-flash", "agnes-2.0-flash", "fast / cheap (default + latest)", "agnes"),
211
+ ("agnes-2.5-flash", "agnes-2.5-flash", "fast / cheap (newer)", "agnes"),
212
+ ("agnes-1.5-flash", "agnes-1.5-flash", "fast / cheap (legacy)", "agnes"),
213
+ ("gpt-4o-mini", "gpt-4o-mini", "OpenAI, cheap", "openai"),
214
+ ("gpt-4o", "gpt-4o", "OpenAI, capable", "openai"),
215
+ ("claude-3-5-sonnet", "claude-3-5-sonnet", "Anthropic", "anthropic"),
216
+ ("claude-sonnet-4", "claude-sonnet-4-20250514", "Anthropic Claude Sonnet 4", "anthropic"),
217
+ ("deepseek-chat", "deepseek-chat", "DeepSeek", "deepseek"),
218
+ ("glm-4-flash", "glm-4-flash", "Zhipu GLM", "openai"),
219
+ ("qwen-plus", "qwen-plus", "Alibaba Qwen", "openai"),
220
+ ("gemini-2.5-pro", "gemini-2.5-pro-exp-03-25", "Google Gemini 2.5 Pro", "gemini"),
221
+ ("gemini-2.5-flash", "gemini-2.5-flash-001", "Google Gemini 2.5 Flash", "gemini"),
222
+ ("ollama-llama3", "llama3.1", "Ollama local (llama3.1)", "ollama"),
223
+ ("ollama-qwen25", "qwen2.5", "Ollama local (qwen2.5)", "ollama"),
224
+ ("llama.cpp", "llama.cpp-local", "llama.cpp local server (OpenAI-compat)", "openai"),
225
+ ]