clink-cli 0.2.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 (141) hide show
  1. clink/__init__.py +19 -0
  2. clink/agents/__init__.py +30 -0
  3. clink/agents/base.py +70 -0
  4. clink/agents/claude.py +7 -0
  5. clink/agents/codex.py +7 -0
  6. clink/agents/gemini.py +7 -0
  7. clink/allowed_roots.py +356 -0
  8. clink/backend.py +765 -0
  9. clink/cli.py +594 -0
  10. clink/constants.py +78 -0
  11. clink/contracts.py +167 -0
  12. clink/files.py +70 -0
  13. clink/generation.py +274 -0
  14. clink/generation_capabilities.py +207 -0
  15. clink/generation_contracts.py +214 -0
  16. clink/generation_policy.py +174 -0
  17. clink/models.py +243 -0
  18. clink/native.py +409 -0
  19. clink/native_adapters.py +244 -0
  20. clink/native_contracts.py +68 -0
  21. clink/native_generation.py +421 -0
  22. clink/native_generation_claude_policy.py +59 -0
  23. clink/native_generation_contracts.py +264 -0
  24. clink/native_generation_families.py +218 -0
  25. clink/native_generation_family_policy.py +14 -0
  26. clink/native_generation_files.py +153 -0
  27. clink/native_generation_policy.py +82 -0
  28. clink/native_policy.py +86 -0
  29. clink/parsers/__init__.py +34 -0
  30. clink/parsers/agy.py +53 -0
  31. clink/parsers/agy_native.py +161 -0
  32. clink/parsers/agy_native_generation.py +189 -0
  33. clink/parsers/base.py +209 -0
  34. clink/parsers/claude.py +104 -0
  35. clink/parsers/claude_native.py +113 -0
  36. clink/parsers/claude_native_generation.py +346 -0
  37. clink/parsers/codex.py +172 -0
  38. clink/parsers/codex_generation.py +212 -0
  39. clink/parsers/codex_native.py +236 -0
  40. clink/parsers/codex_native_generation.py +367 -0
  41. clink/parsers/gemini.py +110 -0
  42. clink/parsers/grok.py +63 -0
  43. clink/parsers/grok_native.py +91 -0
  44. clink/parsers/grok_native_generation.py +157 -0
  45. clink/policy.py +180 -0
  46. clink/registry.py +544 -0
  47. clink/resources/CODEX-LICENSE +201 -0
  48. clink/resources/CODEX-NOTICE +6 -0
  49. clink/resources/PROVENANCE.md +16 -0
  50. clink/resources/codex-0.166.0-models-direct.json +1284 -0
  51. clink/resources/codex-0.166.0-models-generation.json +1269 -0
  52. clink/resources/native-agy-1.4.12-admission.json +746 -0
  53. clink/resources/native-claude-2.1.304-admission.json +99 -0
  54. clink/resources/native-generation-claude-2.1.304-admission.json +277 -0
  55. clink/resources/native-generation-codex-0.166.0-admission.json +309 -0
  56. clink/resources/native-generation-grok-1.0.70-admission.json +329 -0
  57. clink/resources/native-grok-1.0.70-admission.json +644 -0
  58. clink/runner.py +303 -0
  59. clink/setup.py +399 -0
  60. clink/tasks.py +695 -0
  61. clink_cli-0.2.1.dist-info/METADATA +225 -0
  62. clink_cli-0.2.1.dist-info/RECORD +141 -0
  63. clink_cli-0.2.1.dist-info/WHEEL +5 -0
  64. clink_cli-0.2.1.dist-info/entry_points.txt +3 -0
  65. clink_cli-0.2.1.dist-info/licenses/LICENSE +197 -0
  66. clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-LICENSE +201 -0
  67. clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-NOTICE +6 -0
  68. clink_cli-0.2.1.dist-info/top_level.txt +7 -0
  69. conf/__init__.py +1 -0
  70. conf/cli_clients/agy.json +40 -0
  71. conf/cli_clients/claude.json +39 -0
  72. conf/cli_clients/codex.json +40 -0
  73. conf/cli_clients/gemini.json +23 -0
  74. conf/cli_clients/grok.json +40 -0
  75. config.py +40 -0
  76. server.py +896 -0
  77. systemprompts/__init__.py +35 -0
  78. systemprompts/analyze_prompt.py +87 -0
  79. systemprompts/chat_prompt.py +57 -0
  80. systemprompts/clink/codex_codereviewer.txt +8 -0
  81. systemprompts/clink/default.txt +8 -0
  82. systemprompts/clink/default_codereviewer.txt +8 -0
  83. systemprompts/clink/default_planner.txt +7 -0
  84. systemprompts/clink/native_codereviewer.txt +1 -0
  85. systemprompts/clink/native_default.txt +1 -0
  86. systemprompts/clink/native_planner.txt +1 -0
  87. systemprompts/codereview_prompt.py +91 -0
  88. systemprompts/consensus_prompt.py +120 -0
  89. systemprompts/debug_prompt.py +150 -0
  90. systemprompts/docgen_prompt.py +252 -0
  91. systemprompts/generate_code_prompt.py +181 -0
  92. systemprompts/planner_prompt.py +127 -0
  93. systemprompts/precommit_prompt.py +179 -0
  94. systemprompts/refactor_prompt.py +323 -0
  95. systemprompts/secaudit_prompt.py +420 -0
  96. systemprompts/testgen_prompt.py +133 -0
  97. systemprompts/thinkdeep_prompt.py +55 -0
  98. systemprompts/tracer_prompt.py +145 -0
  99. tools/__init__.py +43 -0
  100. tools/analyze.py +583 -0
  101. tools/apilookup.py +97 -0
  102. tools/challenge.py +198 -0
  103. tools/chat.py +187 -0
  104. tools/clink.py +130 -0
  105. tools/codereview.py +724 -0
  106. tools/consensus.py +790 -0
  107. tools/debug.py +573 -0
  108. tools/docgen.py +577 -0
  109. tools/listmodels.py +48 -0
  110. tools/models.py +373 -0
  111. tools/planner.py +533 -0
  112. tools/precommit.py +776 -0
  113. tools/refactor.py +645 -0
  114. tools/secaudit.py +685 -0
  115. tools/shared/__init__.py +19 -0
  116. tools/shared/base_models.py +167 -0
  117. tools/shared/base_tool.py +659 -0
  118. tools/shared/exceptions.py +20 -0
  119. tools/shared/schema_builders.py +159 -0
  120. tools/simple/__init__.py +18 -0
  121. tools/simple/base.py +539 -0
  122. tools/testgen.py +513 -0
  123. tools/thinkdeep.py +586 -0
  124. tools/tracer.py +763 -0
  125. tools/version.py +53 -0
  126. tools/workflow/__init__.py +22 -0
  127. tools/workflow/base.py +448 -0
  128. tools/workflow/schema_builders.py +166 -0
  129. tools/workflow/workflow_mixin.py +1195 -0
  130. utils/__init__.py +21 -0
  131. utils/call_state.py +349 -0
  132. utils/client_info.py +293 -0
  133. utils/consensus_manifest.py +131 -0
  134. utils/conversation_memory.py +582 -0
  135. utils/env.py +111 -0
  136. utils/file_types.py +271 -0
  137. utils/file_utils.py +803 -0
  138. utils/image_utils.py +94 -0
  139. utils/security_config.py +163 -0
  140. utils/storage_backend.py +177 -0
  141. utils/token_utils.py +54 -0
utils/image_utils.py ADDED
@@ -0,0 +1,94 @@
1
+ """Utility helpers for validating image inputs."""
2
+
3
+ import base64
4
+ import binascii
5
+ import os
6
+ from collections.abc import Iterable
7
+
8
+ from utils.file_types import IMAGES, get_image_mime_type
9
+
10
+ DEFAULT_MAX_IMAGE_SIZE_MB = 20.0
11
+
12
+ __all__ = ["DEFAULT_MAX_IMAGE_SIZE_MB", "validate_image"]
13
+
14
+
15
+ def _valid_mime_types() -> Iterable[str]:
16
+ """Return the MIME types permitted by the IMAGES whitelist."""
17
+ return (get_image_mime_type(ext) for ext in IMAGES)
18
+
19
+
20
+ def validate_image(image_path: str, max_size_mb: float = None) -> tuple[bytes, str]:
21
+ """Validate a user-supplied image path or data URL.
22
+
23
+ Args:
24
+ image_path: Either a filesystem path or a data URL.
25
+ max_size_mb: Optional size limit (defaults to ``DEFAULT_MAX_IMAGE_SIZE_MB``).
26
+
27
+ Returns:
28
+ A tuple ``(image_bytes, mime_type)`` ready for upstream providers.
29
+
30
+ Raises:
31
+ ValueError: When the image is missing, malformed, or exceeds limits.
32
+ """
33
+ if max_size_mb is None:
34
+ max_size_mb = DEFAULT_MAX_IMAGE_SIZE_MB
35
+
36
+ if image_path.startswith("data:"):
37
+ return _validate_data_url(image_path, max_size_mb)
38
+
39
+ return _validate_file_path(image_path, max_size_mb)
40
+
41
+
42
+ def _validate_data_url(image_data_url: str, max_size_mb: float) -> tuple[bytes, str]:
43
+ """Validate a data URL and return image bytes plus MIME type."""
44
+ try:
45
+ header, data = image_data_url.split(",", 1)
46
+ mime_type = header.split(";")[0].split(":")[1]
47
+ except (ValueError, IndexError) as exc:
48
+ raise ValueError(f"Invalid data URL format: {exc}")
49
+
50
+ valid_mime_types = list(_valid_mime_types())
51
+ if mime_type not in valid_mime_types:
52
+ raise ValueError(
53
+ "Unsupported image type: {mime}. Supported types: {supported}".format(
54
+ mime=mime_type, supported=", ".join(valid_mime_types)
55
+ )
56
+ )
57
+
58
+ try:
59
+ image_bytes = base64.b64decode(data)
60
+ except binascii.Error as exc:
61
+ raise ValueError(f"Invalid base64 data: {exc}")
62
+
63
+ _validate_size(image_bytes, max_size_mb)
64
+ return image_bytes, mime_type
65
+
66
+
67
+ def _validate_file_path(file_path: str, max_size_mb: float) -> tuple[bytes, str]:
68
+ """Validate an image loaded from the filesystem."""
69
+ try:
70
+ with open(file_path, "rb") as handle:
71
+ image_bytes = handle.read()
72
+ except FileNotFoundError:
73
+ raise ValueError(f"Image file not found: {file_path}")
74
+ except OSError as exc:
75
+ raise ValueError(f"Failed to read image file: {exc}")
76
+
77
+ ext = os.path.splitext(file_path)[1].lower()
78
+ if ext not in IMAGES:
79
+ raise ValueError(
80
+ "Unsupported image format: {ext}. Supported formats: {supported}".format(
81
+ ext=ext, supported=", ".join(sorted(IMAGES))
82
+ )
83
+ )
84
+
85
+ mime_type = get_image_mime_type(ext)
86
+ _validate_size(image_bytes, max_size_mb)
87
+ return image_bytes, mime_type
88
+
89
+
90
+ def _validate_size(image_bytes: bytes, max_size_mb: float) -> None:
91
+ """Ensure the image does not exceed the configured size limit."""
92
+ size_mb = len(image_bytes) / (1024 * 1024)
93
+ if size_mb > max_size_mb:
94
+ raise ValueError(f"Image too large: {size_mb:.1f}MB (max: {max_size_mb}MB)")
@@ -0,0 +1,163 @@
1
+ """
2
+ Security configuration and path validation constants
3
+
4
+ This module contains security-related constants and configurations
5
+ for file access control.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ # Dangerous system paths - block these AND all their subdirectories
11
+ # These are system directories where user code should never reside
12
+ DANGEROUS_SYSTEM_PATHS = {
13
+ "/",
14
+ "/etc",
15
+ "/usr",
16
+ "/bin",
17
+ "/var",
18
+ "/root",
19
+ "C:\\Windows",
20
+ "C:\\Program Files",
21
+ }
22
+
23
+ # User home container paths - block ONLY the exact path, not subdirectories
24
+ # Subdirectory access (e.g., /home/user/project) is controlled by is_home_directory_root()
25
+ # This allows users to work in their home subdirectories while blocking overly broad access
26
+ DANGEROUS_HOME_CONTAINERS = {
27
+ "/home",
28
+ "C:\\Users",
29
+ }
30
+
31
+ # Combined set for backward compatibility
32
+ DANGEROUS_PATHS = DANGEROUS_SYSTEM_PATHS | DANGEROUS_HOME_CONTAINERS
33
+
34
+ # Directories to exclude from recursive file search
35
+ # These typically contain generated code, dependencies, or build artifacts
36
+ EXCLUDED_DIRS = {
37
+ # Python
38
+ "__pycache__",
39
+ ".venv",
40
+ "venv",
41
+ "env",
42
+ ".env",
43
+ "*.egg-info",
44
+ ".eggs",
45
+ "wheels",
46
+ ".Python",
47
+ ".mypy_cache",
48
+ ".pytest_cache",
49
+ ".tox",
50
+ "htmlcov",
51
+ ".coverage",
52
+ "coverage",
53
+ # Node.js / JavaScript
54
+ "node_modules",
55
+ ".next",
56
+ ".nuxt",
57
+ "bower_components",
58
+ ".sass-cache",
59
+ # Version Control
60
+ ".git",
61
+ ".svn",
62
+ ".hg",
63
+ # Build Output
64
+ "build",
65
+ "dist",
66
+ "target",
67
+ "out",
68
+ # IDEs
69
+ ".idea",
70
+ ".vscode",
71
+ ".sublime",
72
+ ".atom",
73
+ ".brackets",
74
+ # Temporary / Cache
75
+ ".cache",
76
+ ".temp",
77
+ ".tmp",
78
+ "*.swp",
79
+ "*.swo",
80
+ "*~",
81
+ # OS-specific
82
+ ".DS_Store",
83
+ "Thumbs.db",
84
+ # Java / JVM
85
+ ".gradle",
86
+ ".m2",
87
+ # Documentation build
88
+ "_build",
89
+ "site",
90
+ # Mobile development
91
+ ".expo",
92
+ ".flutter",
93
+ # Package managers
94
+ "vendor",
95
+ }
96
+
97
+
98
+ def is_dangerous_path(path: Path) -> bool:
99
+ """
100
+ Check if a path is in or under a dangerous directory.
101
+
102
+ This function handles two categories of dangerous paths differently:
103
+
104
+ 1. System paths (DANGEROUS_SYSTEM_PATHS): Block the path AND all subdirectories.
105
+ Example: /etc is dangerous, so /etc/passwd is also blocked.
106
+
107
+ 2. Home containers (DANGEROUS_HOME_CONTAINERS): Block ONLY the exact path.
108
+ Example: /home is blocked, but /home/user/project is allowed.
109
+ Subdirectory access control is delegated to is_home_directory_root().
110
+
111
+ Args:
112
+ path: Path to check
113
+
114
+ Returns:
115
+ True if the path is dangerous and should not be accessed
116
+
117
+ Security:
118
+ Fixes path traversal vulnerability (CWE-22) while preserving
119
+ user access to home subdirectories.
120
+ """
121
+ try:
122
+ resolved = path.resolve()
123
+
124
+ def _dangerous_variants(p: Path) -> set[Path]:
125
+ variants = {p}
126
+ # Only resolve paths that are absolute on the current platform.
127
+ # This avoids turning Windows-style strings into nonsense absolute paths on POSIX.
128
+ if p.is_absolute():
129
+ try:
130
+ variants.add(p.resolve())
131
+ except Exception:
132
+ pass
133
+ return variants
134
+
135
+ # Check 1: Root directory (filesystem root)
136
+ if resolved.parent == resolved:
137
+ return True
138
+
139
+ # Check 2: System paths - block exact match AND all subdirectories
140
+ for dangerous in DANGEROUS_SYSTEM_PATHS:
141
+ # Skip root "/" - already handled above
142
+ if dangerous == "/":
143
+ continue
144
+
145
+ for dangerous_path in _dangerous_variants(Path(dangerous)):
146
+ # is_relative_to() correctly handles both exact matches and subdirectories.
147
+ # Resolving the dangerous base path also handles platform symlinks
148
+ # (e.g., macOS /etc -> /private/etc, /var -> /private/var).
149
+ if resolved == dangerous_path or resolved.is_relative_to(dangerous_path):
150
+ return True
151
+
152
+ # Check 3: Home containers - block ONLY exact match
153
+ # Subdirectories like /home/user/project should pass through here
154
+ # and be handled by is_home_directory_root() in resolve_and_validate_path()
155
+ for container in DANGEROUS_HOME_CONTAINERS:
156
+ for container_path in _dangerous_variants(Path(container)):
157
+ if resolved == container_path:
158
+ return True
159
+
160
+ return False
161
+
162
+ except Exception:
163
+ return True # If we can't resolve, consider it dangerous
@@ -0,0 +1,177 @@
1
+ """Bounded, process-local conversation storage with atomic admission reservations."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import threading
6
+ import time
7
+ from collections.abc import Callable
8
+ from contextlib import asynccontextmanager
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ MAX_ACTIVE_THREADS = 256
13
+ MAX_STORAGE_BYTES = 64 * 1024 * 1024
14
+
15
+
16
+ class StorageCapacityError(RuntimeError):
17
+ """The process cannot admit another record without evicting live state."""
18
+
19
+
20
+ class InMemoryStorage:
21
+ """Keep expiring JSON records; never evict a live conversation for capacity."""
22
+
23
+ def __init__(
24
+ self,
25
+ max_threads: int = MAX_ACTIVE_THREADS,
26
+ max_bytes: int = MAX_STORAGE_BYTES,
27
+ clock: Callable[[], float] = time.time,
28
+ ):
29
+ self._store: dict[str, tuple[str, float]] = {}
30
+ self._lock = threading.RLock()
31
+ self._reservations: dict[str, int] = {}
32
+ self._async_locks: dict[str, tuple[asyncio.Lock, int]] = {}
33
+ self.max_threads = max_threads
34
+ self.max_bytes = max_bytes
35
+ self._clock = clock
36
+ self._stop = threading.Event()
37
+ self._cleanup_thread = threading.Thread(target=self._cleanup_worker, daemon=True)
38
+ self._cleanup_thread.start()
39
+
40
+ @staticmethod
41
+ def _size(value: str) -> int:
42
+ return len(value.encode("utf-8"))
43
+
44
+ def _cleanup_expired_locked(self) -> None:
45
+ now = self._clock()
46
+ for key, (_, expires_at) in list(self._store.items()):
47
+ # A reserved operation owns its record until its terminal update.
48
+ if expires_at <= now and key not in self._reservations:
49
+ del self._store[key]
50
+ self._discard_idle_lock(key)
51
+
52
+ def _discard_idle_lock(self, key: str) -> None:
53
+ entry = self._async_locks.get(key)
54
+ if entry is not None and entry[1] == 0 and key not in self._store:
55
+ del self._async_locks[key]
56
+
57
+ def _check_capacity(self, key: str, value: str, reservation: int = 0) -> None:
58
+ old_size = self._size(self._store[key][0]) if key in self._store else 0
59
+ total = sum(self._size(item[0]) for item in self._store.values()) - old_size + self._size(value)
60
+ total += sum(size for reserved_key, size in self._reservations.items() if reserved_key != key)
61
+ if key not in self._store and len(self._store) >= self.max_threads:
62
+ raise StorageCapacityError("Conversation capacity reached; wait for an existing conversation to expire.")
63
+ if total + reservation > self.max_bytes:
64
+ raise StorageCapacityError("Conversation storage is full; wait for existing conversations to expire.")
65
+
66
+ def set_with_ttl(self, key: str, ttl_seconds: int, value: str) -> None:
67
+ """Atomically admit a record, respecting every outstanding reservation."""
68
+ with self._lock:
69
+ self._cleanup_expired_locked()
70
+ if key in self._reservations:
71
+ raise RuntimeError("Conversation is owned by an active transaction")
72
+ self._check_capacity(key, value)
73
+ self._store[key] = (value, self._clock() + ttl_seconds)
74
+
75
+ def get(self, key: str) -> str | None:
76
+ with self._lock:
77
+ self._cleanup_expired_locked()
78
+ entry = self._store.get(key)
79
+ return entry[0] if entry is not None else None
80
+
81
+ def setex(self, key: str, ttl_seconds: int, value: str) -> None:
82
+ self.set_with_ttl(key, ttl_seconds, value)
83
+
84
+ def update(self, key: str, ttl_seconds: int, transform: Callable[[str], str]) -> bool:
85
+ """Apply a synchronous legacy helper update without losing concurrent turns."""
86
+ with self._lock:
87
+ current = self.get(key)
88
+ if current is None:
89
+ return False
90
+ self.setex(key, ttl_seconds, transform(current))
91
+ return True
92
+
93
+ def reserve(self, key: str, ttl_seconds: int, initial_value: str | None, extra_bytes: int) -> None:
94
+ """Reserve a live slot and worst-case terminal growth before external work."""
95
+ with self._lock:
96
+ self._cleanup_expired_locked()
97
+ existing = self._store.get(key)
98
+ value = existing[0] if existing is not None else initial_value
99
+ if value is None:
100
+ raise KeyError(key)
101
+ if key in self._reservations:
102
+ raise RuntimeError("Conversation already has a reservation")
103
+ self._check_capacity(key, value, extra_bytes)
104
+ if existing is None:
105
+ self._store[key] = (value, self._clock() + ttl_seconds)
106
+ self._reservations[key] = extra_bytes
107
+
108
+ def commit_reserved(self, key: str, ttl_seconds: int, value: str) -> None:
109
+ """Publish a complete terminal record and release its growth reservation."""
110
+ with self._lock:
111
+ if key not in self._reservations or key not in self._store:
112
+ raise RuntimeError("Conversation has no active reservation")
113
+ previous_size = self._size(self._store[key][0])
114
+ if self._size(value) > previous_size + self._reservations[key]:
115
+ raise StorageCapacityError("Terminal conversation state exceeds its admitted storage reservation.")
116
+ self._check_capacity(key, value)
117
+ self._store[key] = (value, self._clock() + ttl_seconds)
118
+ del self._reservations[key]
119
+
120
+ def release_reservation(self, key: str) -> None:
121
+ with self._lock:
122
+ self._reservations.pop(key, None)
123
+ self._cleanup_expired_locked()
124
+
125
+ @asynccontextmanager
126
+ async def serialize(self, key: str, deadline: float | None = None):
127
+ """Serialize one continuation through its terminal update; include lock wait in deadline."""
128
+ with self._lock:
129
+ lock, users = self._async_locks.get(key, (asyncio.Lock(), 0))
130
+ self._async_locks[key] = (lock, users + 1)
131
+ acquired = False
132
+ try:
133
+ if deadline is None:
134
+ await lock.acquire()
135
+ else:
136
+ remaining = deadline - time.monotonic()
137
+ if remaining <= 0:
138
+ raise asyncio.TimeoutError
139
+ await asyncio.wait_for(lock.acquire(), remaining)
140
+ acquired = True
141
+ if deadline is not None and time.monotonic() >= deadline:
142
+ raise asyncio.TimeoutError
143
+ yield
144
+ finally:
145
+ if acquired:
146
+ lock.release()
147
+ with self._lock:
148
+ _, users = self._async_locks[key]
149
+ self._async_locks[key] = (lock, users - 1)
150
+ self._discard_idle_lock(key)
151
+
152
+ def _cleanup_worker(self) -> None:
153
+ while not self._stop.wait(300):
154
+ self._cleanup_expired()
155
+
156
+ def _cleanup_expired(self) -> None:
157
+ with self._lock:
158
+ self._cleanup_expired_locked()
159
+
160
+ def shutdown(self) -> None:
161
+ self._stop.set()
162
+ if self._cleanup_thread.is_alive():
163
+ self._cleanup_thread.join(timeout=1)
164
+
165
+
166
+ _storage_instance = None
167
+ _storage_lock = threading.Lock()
168
+
169
+
170
+ def get_storage_backend() -> InMemoryStorage:
171
+ """Return the process-local storage instance."""
172
+ global _storage_instance
173
+ if _storage_instance is None:
174
+ with _storage_lock:
175
+ if _storage_instance is None:
176
+ _storage_instance = InMemoryStorage()
177
+ return _storage_instance
utils/token_utils.py ADDED
@@ -0,0 +1,54 @@
1
+ """
2
+ Token counting utilities for managing API context limits
3
+
4
+ This module provides functions for estimating token counts to ensure
5
+ requests stay within the Gemini API's context window limits.
6
+
7
+ Note: The estimation uses a simple character-to-token ratio which is
8
+ approximate. For production systems requiring precise token counts,
9
+ consider using the actual tokenizer for the specific model.
10
+ """
11
+
12
+ # Default fallback for token limit (conservative estimate)
13
+ DEFAULT_CONTEXT_WINDOW = 200_000 # Conservative fallback for unknown models
14
+
15
+
16
+ def estimate_tokens(text: str) -> int:
17
+ """
18
+ Estimate token count using a character-based approximation.
19
+
20
+ This uses a rough heuristic where 1 token ≈ 4 characters, which is
21
+ a reasonable approximation for English text. The actual token count
22
+ may vary based on:
23
+ - Language (non-English text may have different ratios)
24
+ - Code vs prose (code often has more tokens per character)
25
+ - Special characters and formatting
26
+
27
+ Args:
28
+ text: The text to estimate tokens for
29
+
30
+ Returns:
31
+ int: Estimated number of tokens
32
+ """
33
+ return len(text) // 4
34
+
35
+
36
+ def check_token_limit(text: str, context_window: int = DEFAULT_CONTEXT_WINDOW) -> tuple[bool, int]:
37
+ """
38
+ Check if text exceeds the specified token limit.
39
+
40
+ This function is used to validate that prepared prompts will fit
41
+ within the model's context window, preventing API errors and ensuring
42
+ reliable operation.
43
+
44
+ Args:
45
+ text: The text to check
46
+ context_window: The model's context window size (defaults to conservative fallback)
47
+
48
+ Returns:
49
+ Tuple[bool, int]: (is_within_limit, estimated_tokens)
50
+ - is_within_limit: True if the text fits within context_window
51
+ - estimated_tokens: The estimated token count
52
+ """
53
+ estimated = estimate_tokens(text)
54
+ return estimated <= context_window, estimated