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.
- clink/__init__.py +19 -0
- clink/agents/__init__.py +30 -0
- clink/agents/base.py +70 -0
- clink/agents/claude.py +7 -0
- clink/agents/codex.py +7 -0
- clink/agents/gemini.py +7 -0
- clink/allowed_roots.py +356 -0
- clink/backend.py +765 -0
- clink/cli.py +594 -0
- clink/constants.py +78 -0
- clink/contracts.py +167 -0
- clink/files.py +70 -0
- clink/generation.py +274 -0
- clink/generation_capabilities.py +207 -0
- clink/generation_contracts.py +214 -0
- clink/generation_policy.py +174 -0
- clink/models.py +243 -0
- clink/native.py +409 -0
- clink/native_adapters.py +244 -0
- clink/native_contracts.py +68 -0
- clink/native_generation.py +421 -0
- clink/native_generation_claude_policy.py +59 -0
- clink/native_generation_contracts.py +264 -0
- clink/native_generation_families.py +218 -0
- clink/native_generation_family_policy.py +14 -0
- clink/native_generation_files.py +153 -0
- clink/native_generation_policy.py +82 -0
- clink/native_policy.py +86 -0
- clink/parsers/__init__.py +34 -0
- clink/parsers/agy.py +53 -0
- clink/parsers/agy_native.py +161 -0
- clink/parsers/agy_native_generation.py +189 -0
- clink/parsers/base.py +209 -0
- clink/parsers/claude.py +104 -0
- clink/parsers/claude_native.py +113 -0
- clink/parsers/claude_native_generation.py +346 -0
- clink/parsers/codex.py +172 -0
- clink/parsers/codex_generation.py +212 -0
- clink/parsers/codex_native.py +236 -0
- clink/parsers/codex_native_generation.py +367 -0
- clink/parsers/gemini.py +110 -0
- clink/parsers/grok.py +63 -0
- clink/parsers/grok_native.py +91 -0
- clink/parsers/grok_native_generation.py +157 -0
- clink/policy.py +180 -0
- clink/registry.py +544 -0
- clink/resources/CODEX-LICENSE +201 -0
- clink/resources/CODEX-NOTICE +6 -0
- clink/resources/PROVENANCE.md +16 -0
- clink/resources/codex-0.166.0-models-direct.json +1284 -0
- clink/resources/codex-0.166.0-models-generation.json +1269 -0
- clink/resources/native-agy-1.4.12-admission.json +746 -0
- clink/resources/native-claude-2.1.304-admission.json +99 -0
- clink/resources/native-generation-claude-2.1.304-admission.json +277 -0
- clink/resources/native-generation-codex-0.166.0-admission.json +309 -0
- clink/resources/native-generation-grok-1.0.70-admission.json +329 -0
- clink/resources/native-grok-1.0.70-admission.json +644 -0
- clink/runner.py +303 -0
- clink/setup.py +399 -0
- clink/tasks.py +695 -0
- clink_cli-0.2.1.dist-info/METADATA +225 -0
- clink_cli-0.2.1.dist-info/RECORD +141 -0
- clink_cli-0.2.1.dist-info/WHEEL +5 -0
- clink_cli-0.2.1.dist-info/entry_points.txt +3 -0
- clink_cli-0.2.1.dist-info/licenses/LICENSE +197 -0
- clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-LICENSE +201 -0
- clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-NOTICE +6 -0
- clink_cli-0.2.1.dist-info/top_level.txt +7 -0
- conf/__init__.py +1 -0
- conf/cli_clients/agy.json +40 -0
- conf/cli_clients/claude.json +39 -0
- conf/cli_clients/codex.json +40 -0
- conf/cli_clients/gemini.json +23 -0
- conf/cli_clients/grok.json +40 -0
- config.py +40 -0
- server.py +896 -0
- systemprompts/__init__.py +35 -0
- systemprompts/analyze_prompt.py +87 -0
- systemprompts/chat_prompt.py +57 -0
- systemprompts/clink/codex_codereviewer.txt +8 -0
- systemprompts/clink/default.txt +8 -0
- systemprompts/clink/default_codereviewer.txt +8 -0
- systemprompts/clink/default_planner.txt +7 -0
- systemprompts/clink/native_codereviewer.txt +1 -0
- systemprompts/clink/native_default.txt +1 -0
- systemprompts/clink/native_planner.txt +1 -0
- systemprompts/codereview_prompt.py +91 -0
- systemprompts/consensus_prompt.py +120 -0
- systemprompts/debug_prompt.py +150 -0
- systemprompts/docgen_prompt.py +252 -0
- systemprompts/generate_code_prompt.py +181 -0
- systemprompts/planner_prompt.py +127 -0
- systemprompts/precommit_prompt.py +179 -0
- systemprompts/refactor_prompt.py +323 -0
- systemprompts/secaudit_prompt.py +420 -0
- systemprompts/testgen_prompt.py +133 -0
- systemprompts/thinkdeep_prompt.py +55 -0
- systemprompts/tracer_prompt.py +145 -0
- tools/__init__.py +43 -0
- tools/analyze.py +583 -0
- tools/apilookup.py +97 -0
- tools/challenge.py +198 -0
- tools/chat.py +187 -0
- tools/clink.py +130 -0
- tools/codereview.py +724 -0
- tools/consensus.py +790 -0
- tools/debug.py +573 -0
- tools/docgen.py +577 -0
- tools/listmodels.py +48 -0
- tools/models.py +373 -0
- tools/planner.py +533 -0
- tools/precommit.py +776 -0
- tools/refactor.py +645 -0
- tools/secaudit.py +685 -0
- tools/shared/__init__.py +19 -0
- tools/shared/base_models.py +167 -0
- tools/shared/base_tool.py +659 -0
- tools/shared/exceptions.py +20 -0
- tools/shared/schema_builders.py +159 -0
- tools/simple/__init__.py +18 -0
- tools/simple/base.py +539 -0
- tools/testgen.py +513 -0
- tools/thinkdeep.py +586 -0
- tools/tracer.py +763 -0
- tools/version.py +53 -0
- tools/workflow/__init__.py +22 -0
- tools/workflow/base.py +448 -0
- tools/workflow/schema_builders.py +166 -0
- tools/workflow/workflow_mixin.py +1195 -0
- utils/__init__.py +21 -0
- utils/call_state.py +349 -0
- utils/client_info.py +293 -0
- utils/consensus_manifest.py +131 -0
- utils/conversation_memory.py +582 -0
- utils/env.py +111 -0
- utils/file_types.py +271 -0
- utils/file_utils.py +803 -0
- utils/image_utils.py +94 -0
- utils/security_config.py +163 -0
- utils/storage_backend.py +177 -0
- 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)")
|
utils/security_config.py
ADDED
|
@@ -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
|
utils/storage_backend.py
ADDED
|
@@ -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
|