crabcode 0.1.6__py3-none-win_amd64.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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +268 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Tool system types for CrabCode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Callable, Coroutine
|
|
11
|
+
|
|
12
|
+
from crabcode_core.types.config import DEFAULT_FILESYSTEM_TIMEOUT
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from crabcode_core.tools.loading import ToolCatalog
|
|
16
|
+
from crabcode_core.types.message import AssistantMessage, Message
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PermissionBehavior(str, Enum):
|
|
20
|
+
ALLOW = "allow"
|
|
21
|
+
DENY = "deny"
|
|
22
|
+
ASK = "ask"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
ToolEventCallback = Callable[[str, str, dict[str, Any]], None]
|
|
26
|
+
"""Signature: (tool_name, event_type, data)"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Keep this aligned with the Gateway's ImageAttachment validation limit. The
|
|
30
|
+
# limit applies to decoded bytes, not the larger Base64 representation.
|
|
31
|
+
MAX_INLINE_IMAGE_BYTES = 20 * 1024 * 1024
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class PermissionResult:
|
|
36
|
+
behavior: PermissionBehavior = PermissionBehavior.ALLOW
|
|
37
|
+
reason: str | None = None
|
|
38
|
+
updated_input: dict[str, Any] | None = None
|
|
39
|
+
permission_key: str | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ToolResult:
|
|
44
|
+
"""Result from executing a tool."""
|
|
45
|
+
|
|
46
|
+
data: Any = None
|
|
47
|
+
result_for_model: str = ""
|
|
48
|
+
result_for_display: str | None = None
|
|
49
|
+
is_error: bool = False
|
|
50
|
+
# Inline image attachments for the conversation UI. Each entry carries
|
|
51
|
+
# ``media_type``, base64-encoded ``data``, and an optional ``description``.
|
|
52
|
+
images: list[dict[str, str]] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class ToolContext:
|
|
57
|
+
"""Context passed to tool execution."""
|
|
58
|
+
|
|
59
|
+
cwd: str = "."
|
|
60
|
+
messages: list[Message] = field(default_factory=list)
|
|
61
|
+
abort_signal: Any | None = None
|
|
62
|
+
session_id: str = ""
|
|
63
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
64
|
+
on_event: ToolEventCallback | None = None
|
|
65
|
+
tool_config: dict[str, Any] = field(default_factory=dict)
|
|
66
|
+
choice_queue: Any | None = None # asyncio.Queue[ChoiceResponseEvent]
|
|
67
|
+
tool_event_queue: Any | None = None # asyncio.Queue[CoreEvent] — for tools to emit events mid-execution
|
|
68
|
+
agent_id: str | None = None
|
|
69
|
+
tool_use_id: str | None = None
|
|
70
|
+
agent_depth: int = 0
|
|
71
|
+
agent_manager: Any | None = None
|
|
72
|
+
lsp_manager: Any | None = None # LSPManager — session-scoped, set during CoreSession.initialize()
|
|
73
|
+
team_id: str | None = None # Team ID the current agent belongs to
|
|
74
|
+
team_manager: Any | None = None # TeamManager — session-scoped
|
|
75
|
+
schedule_manager: Any | None = None # ScheduleManager — session-scoped
|
|
76
|
+
session: Any | None = None # CoreSession — for checkpoint/revert operations
|
|
77
|
+
api_adapter: Any | None = None # Active turn's provider, including subagent/model switches
|
|
78
|
+
model: str | None = None
|
|
79
|
+
tool_catalog: ToolCatalog | None = None
|
|
80
|
+
snapshot_enabled: bool = True
|
|
81
|
+
snapshot_max_size_mb: int = 1024
|
|
82
|
+
filesystem_timeout: float | None = DEFAULT_FILESYSTEM_TIMEOUT
|
|
83
|
+
# ImageContent emitted by a tool during this invocation. This is kept on
|
|
84
|
+
# the per-call context so concurrent tools cannot leak attachments into
|
|
85
|
+
# one another.
|
|
86
|
+
emitted_images: list[dict[str, str]] = field(default_factory=list, repr=False)
|
|
87
|
+
|
|
88
|
+
def emit_image(self, data: bytes, mime_type: str, *, description: str = "") -> None:
|
|
89
|
+
"""Append an inline image to this tool invocation's result.
|
|
90
|
+
|
|
91
|
+
This mirrors Codex's ``emitImage({ bytes, mimeType })`` boundary:
|
|
92
|
+
callers provide raw bytes and the transport carries a separate image
|
|
93
|
+
content block instead of embedding binary data in text or Markdown.
|
|
94
|
+
"""
|
|
95
|
+
if not isinstance(data, (bytes, bytearray, memoryview)):
|
|
96
|
+
raise TypeError("image data must be bytes")
|
|
97
|
+
raw = bytes(data)
|
|
98
|
+
if not raw:
|
|
99
|
+
raise ValueError("image data must not be empty")
|
|
100
|
+
if len(raw) > MAX_INLINE_IMAGE_BYTES:
|
|
101
|
+
raise ValueError("image data exceeds the 20MB limit")
|
|
102
|
+
media_type = str(mime_type or "").strip()
|
|
103
|
+
if not media_type.lower().startswith("image/"):
|
|
104
|
+
raise ValueError("mime_type must be an image MIME type")
|
|
105
|
+
self.emitted_images.append({
|
|
106
|
+
"media_type": media_type,
|
|
107
|
+
"data": base64.b64encode(raw).decode("ascii"),
|
|
108
|
+
**({"description": description} if description else {}),
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
CanUseToolFn = Callable[
|
|
113
|
+
["Tool", dict[str, Any], "ToolContext"],
|
|
114
|
+
Coroutine[Any, Any, PermissionResult],
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Tool(ABC):
|
|
119
|
+
"""Base class for all CrabCode tools."""
|
|
120
|
+
|
|
121
|
+
name: str = ""
|
|
122
|
+
description: str = ""
|
|
123
|
+
input_schema: dict[str, Any] = {}
|
|
124
|
+
is_read_only: bool = False
|
|
125
|
+
is_concurrency_safe: bool = False
|
|
126
|
+
is_enabled: bool = True
|
|
127
|
+
uses_tool_permission_policy: bool = False
|
|
128
|
+
|
|
129
|
+
_cached_prompt: str | None = None
|
|
130
|
+
_background_task: asyncio.Task[Any] | None = None
|
|
131
|
+
_setup_context: ToolContext | None = None
|
|
132
|
+
|
|
133
|
+
@abstractmethod
|
|
134
|
+
async def call(
|
|
135
|
+
self,
|
|
136
|
+
tool_input: dict[str, Any],
|
|
137
|
+
context: ToolContext,
|
|
138
|
+
) -> ToolResult:
|
|
139
|
+
"""Execute the tool with given input."""
|
|
140
|
+
...
|
|
141
|
+
|
|
142
|
+
async def setup(self, context: ToolContext) -> None:
|
|
143
|
+
"""Called once during session init. Override for heavy initialization."""
|
|
144
|
+
self._setup_context = context
|
|
145
|
+
|
|
146
|
+
async def close(self) -> None:
|
|
147
|
+
"""Called during session shutdown to release resources."""
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
def emit_event(self, event_type: str, data: dict[str, Any] | None = None) -> None:
|
|
151
|
+
"""Emit a tool event (progress, ready, error, etc.)."""
|
|
152
|
+
if self._setup_context and self._setup_context.on_event:
|
|
153
|
+
self._setup_context.on_event(self.name, event_type, data or {})
|
|
154
|
+
|
|
155
|
+
async def wait_ready(self) -> None:
|
|
156
|
+
"""Wait for background initialization to complete."""
|
|
157
|
+
if self._background_task and not self._background_task.done():
|
|
158
|
+
await self._background_task
|
|
159
|
+
|
|
160
|
+
async def get_prompt(self, **kwargs: Any) -> str:
|
|
161
|
+
"""Return the tool description for the system prompt / API tools list."""
|
|
162
|
+
return self.description
|
|
163
|
+
|
|
164
|
+
async def validate_input(
|
|
165
|
+
self, tool_input: dict[str, Any]
|
|
166
|
+
) -> str | None:
|
|
167
|
+
"""Validate input. Return error string or None if valid."""
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
def is_available(self, context: ToolContext) -> bool:
|
|
171
|
+
"""Whether the active provider/context supports this tool."""
|
|
172
|
+
return self.is_enabled
|
|
173
|
+
|
|
174
|
+
async def check_permissions(
|
|
175
|
+
self,
|
|
176
|
+
tool_input: dict[str, Any],
|
|
177
|
+
context: ToolContext,
|
|
178
|
+
) -> PermissionResult:
|
|
179
|
+
"""Check if the tool can be used with the given input."""
|
|
180
|
+
return PermissionResult()
|
|
181
|
+
|
|
182
|
+
def get_permission_key(self, tool_input: dict[str, Any]) -> str:
|
|
183
|
+
"""Return the permission key used for session-scoped allow rules."""
|
|
184
|
+
return self.name
|
|
185
|
+
|
|
186
|
+
def to_api_schema(self) -> dict[str, Any]:
|
|
187
|
+
"""Convert to API tool schema for model requests.
|
|
188
|
+
|
|
189
|
+
Uses the detailed prompt from get_prompt() when available,
|
|
190
|
+
falling back to the short description.
|
|
191
|
+
"""
|
|
192
|
+
return {
|
|
193
|
+
"name": self.name,
|
|
194
|
+
"description": self._cached_prompt or self.description,
|
|
195
|
+
"input_schema": self.input_schema,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async def resolve_prompt(self, **kwargs: Any) -> None:
|
|
199
|
+
"""Pre-resolve the detailed prompt for use in to_api_schema().
|
|
200
|
+
|
|
201
|
+
Call this once during setup so to_api_schema() can include the
|
|
202
|
+
full tool description without being async.
|
|
203
|
+
"""
|
|
204
|
+
prompt = await self.get_prompt(**kwargs)
|
|
205
|
+
if prompt and prompt != self.description:
|
|
206
|
+
self._cached_prompt = prompt
|
|
207
|
+
else:
|
|
208
|
+
self._cached_prompt = None
|
crabcode_core/usage.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Request-level, provider-reported token ledger and local calendar aggregation.
|
|
2
|
+
|
|
3
|
+
This store deliberately does not estimate missing usage or rewrite session totals.
|
|
4
|
+
Each network attempt has its own row, including retries and interrupted streams.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from contextlib import closing
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import sqlite3
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from datetime import date, datetime, time as day_time, timedelta, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, AsyncIterator
|
|
19
|
+
from zoneinfo import ZoneInfo
|
|
20
|
+
|
|
21
|
+
from crabcode_core.api.base import APIAdapter, ModelConfig, StreamChunk, usage_int_field
|
|
22
|
+
from crabcode_core.api.error_logging import log_api_error
|
|
23
|
+
from crabcode_core.paths import get_config_home
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
recording_error: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class UsageStore:
|
|
30
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
31
|
+
self.path = path or get_config_home() / "usage.sqlite3"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def error_marker(self) -> Path:
|
|
35
|
+
return self.path.with_name("usage-recording-error.txt")
|
|
36
|
+
|
|
37
|
+
def mark_error(self) -> None:
|
|
38
|
+
"""Make a write gap visible to another CLI/Gateway process when possible."""
|
|
39
|
+
try:
|
|
40
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
self.error_marker.write_text(
|
|
42
|
+
"Usage recording failed in a CrabCode process. Historical totals may be incomplete.\n",
|
|
43
|
+
encoding="utf-8",
|
|
44
|
+
)
|
|
45
|
+
except OSError:
|
|
46
|
+
logger.warning("Unable to persist usage recording failure marker", exc_info=True)
|
|
47
|
+
|
|
48
|
+
def _connect(self) -> sqlite3.Connection:
|
|
49
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
deadline = time.monotonic() + 3
|
|
51
|
+
while True:
|
|
52
|
+
db = sqlite3.connect(self.path, timeout=3)
|
|
53
|
+
try:
|
|
54
|
+
db.execute("PRAGMA busy_timeout=3000")
|
|
55
|
+
if db.execute("PRAGMA user_version").fetchone()[0] == 0:
|
|
56
|
+
db.execute("PRAGMA journal_mode=WAL")
|
|
57
|
+
db.execute("""CREATE TABLE IF NOT EXISTS requests (
|
|
58
|
+
id TEXT PRIMARY KEY, started_ms INTEGER NOT NULL, ended_ms INTEGER,
|
|
59
|
+
project_key TEXT, project_path TEXT, requested_model TEXT NOT NULL,
|
|
60
|
+
model_id TEXT NOT NULL, provider TEXT NOT NULL, purpose TEXT NOT NULL,
|
|
61
|
+
session_id TEXT, operation_id TEXT, input_tokens INTEGER, output_tokens INTEGER,
|
|
62
|
+
reported_total_tokens INTEGER, cache_read_tokens INTEGER,
|
|
63
|
+
cache_write_tokens INTEGER, reasoning_tokens INTEGER,
|
|
64
|
+
usage_status TEXT NOT NULL, status TEXT NOT NULL, stop_reason TEXT
|
|
65
|
+
)""")
|
|
66
|
+
db.execute("CREATE INDEX IF NOT EXISTS requests_time ON requests(started_ms)")
|
|
67
|
+
db.execute("CREATE INDEX IF NOT EXISTS requests_project_time ON requests(project_key, started_ms)")
|
|
68
|
+
db.execute("CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
|
|
69
|
+
db.execute("INSERT OR REPLACE INTO metadata VALUES ('schema_version', '1')")
|
|
70
|
+
db.execute("PRAGMA user_version=1")
|
|
71
|
+
db.commit()
|
|
72
|
+
return db
|
|
73
|
+
except sqlite3.OperationalError as exc:
|
|
74
|
+
db.close()
|
|
75
|
+
if "locked" not in str(exc).lower() or time.monotonic() >= deadline:
|
|
76
|
+
raise
|
|
77
|
+
time.sleep(0.05)
|
|
78
|
+
except BaseException:
|
|
79
|
+
db.close()
|
|
80
|
+
raise
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def project_key(cwd: str | None) -> str | None:
|
|
84
|
+
return os.path.normcase(os.path.normpath(os.path.abspath(cwd))) if cwd else None
|
|
85
|
+
|
|
86
|
+
def begin(self, request_id: str, started_ms: int, *, cwd: str | None,
|
|
87
|
+
model: str, provider: str, purpose: str, session_id: str | None,
|
|
88
|
+
operation_id: str | None = None) -> None:
|
|
89
|
+
with closing(self._connect()) as db, db:
|
|
90
|
+
db.execute("INSERT OR IGNORE INTO metadata VALUES ('tracking_started_ms', ?)", (str(started_ms),))
|
|
91
|
+
db.execute("""INSERT INTO requests
|
|
92
|
+
(id, started_ms, project_key, project_path, requested_model, model_id,
|
|
93
|
+
provider, purpose, session_id, operation_id, usage_status, status)
|
|
94
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'missing', 'running')""",
|
|
95
|
+
(request_id, started_ms, self.project_key(cwd), cwd,
|
|
96
|
+
model, model, provider, purpose, session_id, operation_id))
|
|
97
|
+
|
|
98
|
+
def snapshot(self, request_id: str, usage: dict[str, int], model_id: str | None = None) -> None:
|
|
99
|
+
"""Persist changed cumulative usage, including before a process crashes."""
|
|
100
|
+
inputs = usage.get("total_input_tokens", usage.get("input_tokens"))
|
|
101
|
+
outputs = usage.get("output_tokens")
|
|
102
|
+
total = usage.get("total_tokens")
|
|
103
|
+
status = "complete" if inputs is not None and outputs is not None else "partial" if usage else "missing"
|
|
104
|
+
with closing(self._connect()) as db, db:
|
|
105
|
+
db.execute("""UPDATE requests SET input_tokens=?, output_tokens=?,
|
|
106
|
+
reported_total_tokens=?, cache_read_tokens=?, cache_write_tokens=?,
|
|
107
|
+
reasoning_tokens=?, usage_status=?, model_id=COALESCE(?, model_id) WHERE id=?""",
|
|
108
|
+
(inputs, outputs, total, usage.get("cache_read_tokens"),
|
|
109
|
+
usage.get("cache_write_tokens"), usage.get("reasoning_tokens"),
|
|
110
|
+
status, model_id, request_id))
|
|
111
|
+
|
|
112
|
+
def finish(self, request_id: str, usage: dict[str, int],
|
|
113
|
+
status: str, stop_reason: str | None = None, model_id: str | None = None) -> None:
|
|
114
|
+
inputs = usage.get("total_input_tokens", usage.get("input_tokens"))
|
|
115
|
+
outputs = usage.get("output_tokens")
|
|
116
|
+
usage_status = "complete" if inputs is not None and outputs is not None else "partial" if usage else "missing"
|
|
117
|
+
with closing(self._connect()) as db, db:
|
|
118
|
+
db.execute("""UPDATE requests SET ended_ms=?, status=?, stop_reason=?,
|
|
119
|
+
input_tokens=?, output_tokens=?, reported_total_tokens=?,
|
|
120
|
+
cache_read_tokens=?, cache_write_tokens=?, reasoning_tokens=?,
|
|
121
|
+
usage_status=?, model_id=COALESCE(?, model_id) WHERE id=?""",
|
|
122
|
+
(int(time.time() * 1000), status, stop_reason, inputs, outputs,
|
|
123
|
+
usage.get("total_tokens"), usage.get("cache_read_tokens"),
|
|
124
|
+
usage.get("cache_write_tokens"), usage.get("reasoning_tokens"),
|
|
125
|
+
usage_status, model_id, request_id))
|
|
126
|
+
|
|
127
|
+
def daily(self, start: date, end: date, zone: ZoneInfo,
|
|
128
|
+
cwd: str | None = None) -> dict[str, Any]:
|
|
129
|
+
if end < start or (end - start).days >= 366:
|
|
130
|
+
raise ValueError("日期范围必须为 1–366 天")
|
|
131
|
+
lower = int(datetime.combine(start, day_time.min, zone).timestamp() * 1000)
|
|
132
|
+
upper = int(datetime.combine(end + timedelta(days=1), day_time.min, zone).timestamp() * 1000)
|
|
133
|
+
with closing(self._connect()) as db, db:
|
|
134
|
+
row = db.execute("SELECT value FROM metadata WHERE key='tracking_started_ms'").fetchone()
|
|
135
|
+
tracking_start = int(row[0]) if row else None
|
|
136
|
+
args: list[Any] = [lower, upper]
|
|
137
|
+
clause = ""
|
|
138
|
+
if cwd is not None:
|
|
139
|
+
clause = " AND project_key=?"
|
|
140
|
+
args.append(self.project_key(cwd))
|
|
141
|
+
rows = db.execute("""SELECT started_ms, provider, model_id, input_tokens,
|
|
142
|
+
output_tokens, reported_total_tokens, usage_status, status
|
|
143
|
+
FROM requests WHERE started_ms>=? AND started_ms<?""" + clause,
|
|
144
|
+
args).fetchall()
|
|
145
|
+
activity_rows = db.execute("""SELECT provider, model_id, COUNT(*)
|
|
146
|
+
FROM requests WHERE 1=1""" + clause + " GROUP BY provider, model_id",
|
|
147
|
+
[self.project_key(cwd)] if cwd is not None else []).fetchall()
|
|
148
|
+
bounds = db.execute("SELECT MIN(started_ms), MAX(started_ms) FROM requests WHERE 1=1" + clause,
|
|
149
|
+
[self.project_key(cwd)] if cwd is not None else []).fetchone()
|
|
150
|
+
|
|
151
|
+
days: dict[str, dict[str, Any]] = {}
|
|
152
|
+
cursor = start
|
|
153
|
+
while cursor <= end:
|
|
154
|
+
key = cursor.isoformat()
|
|
155
|
+
days[key] = {"date": key, "input_tokens": 0, "output_tokens": 0,
|
|
156
|
+
"total_tokens": None, "request_count": 0,
|
|
157
|
+
"unknown_requests": 0, "missing_requests": 0,
|
|
158
|
+
"partial_requests": 0, "coverage": "unavailable"}
|
|
159
|
+
cursor += timedelta(days=1)
|
|
160
|
+
by_model: dict[str, dict[str, int]] = {}
|
|
161
|
+
model_totals: dict[str, int] = {}
|
|
162
|
+
for started_ms, provider, model_id, input_tokens, output_tokens, reported_total, usage_status, status in rows:
|
|
163
|
+
key = datetime.fromtimestamp(started_ms / 1000, timezone.utc).astimezone(zone).date().isoformat()
|
|
164
|
+
day = days[key]
|
|
165
|
+
day["request_count"] += 1
|
|
166
|
+
if usage_status != "complete" or status not in {"completed", "output_limit"}:
|
|
167
|
+
day["unknown_requests"] += 1
|
|
168
|
+
day["missing_requests" if usage_status == "missing" else "partial_requests"] += 1
|
|
169
|
+
inputs = input_tokens or 0
|
|
170
|
+
outputs = output_tokens or 0
|
|
171
|
+
amount = reported_total if reported_total is not None else inputs + outputs
|
|
172
|
+
day["input_tokens"] += inputs
|
|
173
|
+
day["output_tokens"] += outputs
|
|
174
|
+
day["total_tokens"] = (day["total_tokens"] or 0) + amount
|
|
175
|
+
model_key = f"{provider}/{model_id or '未知模型'}"
|
|
176
|
+
model_day = by_model.setdefault(model_key, {})
|
|
177
|
+
model_day[key] = model_day.get(key, 0) + amount
|
|
178
|
+
model_totals[model_key] = model_totals.get(model_key, 0) + amount
|
|
179
|
+
for key, day in days.items():
|
|
180
|
+
day_start = int(datetime.combine(date.fromisoformat(key), day_time.min, zone).timestamp() * 1000)
|
|
181
|
+
if day["unknown_requests"]:
|
|
182
|
+
day["coverage"] = "partial"
|
|
183
|
+
elif tracking_start is None or day_start < tracking_start:
|
|
184
|
+
day["coverage"] = "partial" if day["request_count"] else "unavailable"
|
|
185
|
+
else:
|
|
186
|
+
day["coverage"] = "complete"
|
|
187
|
+
if day["coverage"] == "complete" and day["total_tokens"] is None:
|
|
188
|
+
day["total_tokens"] = 0
|
|
189
|
+
activity_counts = {f"{provider}/{model_id or '未知模型'}": count
|
|
190
|
+
for provider, model_id, count in activity_rows}
|
|
191
|
+
models = [{"key": model_key, "provider": model_key.split("/", 1)[0],
|
|
192
|
+
"model_id": model_key.split("/", 1)[1], "model": model_key,
|
|
193
|
+
"total_tokens": model_totals[model_key],
|
|
194
|
+
"recorded_request_count": activity_counts[model_key],
|
|
195
|
+
"points": [{"date": key, "total_tokens": totals.get(key, 0) if days[key]["coverage"] != "unavailable" else None}
|
|
196
|
+
for key in days]}
|
|
197
|
+
for model_key, totals in sorted(by_model.items())]
|
|
198
|
+
return {"start": start.isoformat(), "end": end.isoformat(), "timezone": str(zone),
|
|
199
|
+
"scope": "project" if cwd is not None else "global",
|
|
200
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
201
|
+
"tracking_started_at": datetime.fromtimestamp(tracking_start / 1000, timezone.utc).isoformat() if tracking_start else None,
|
|
202
|
+
"first_recorded_at": datetime.fromtimestamp(bounds[0] / 1000, timezone.utc).isoformat() if bounds[0] else None,
|
|
203
|
+
"last_recorded_at": datetime.fromtimestamp(bounds[1] / 1000, timezone.utc).isoformat() if bounds[1] else None,
|
|
204
|
+
"days": list(days.values()), "models": models,
|
|
205
|
+
"summary": {"input_tokens": sum(d["input_tokens"] for d in days.values()),
|
|
206
|
+
"output_tokens": sum(d["output_tokens"] for d in days.values()),
|
|
207
|
+
"total_tokens": sum(d["total_tokens"] or 0 for d in days.values()),
|
|
208
|
+
"request_count": sum(d["request_count"] for d in days.values()),
|
|
209
|
+
"unknown_requests": sum(d["unknown_requests"] for d in days.values()),
|
|
210
|
+
"missing_requests": sum(d["missing_requests"] for d in days.values()),
|
|
211
|
+
"partial_requests": sum(d["partial_requests"] for d in days.values())}}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
async def tracked_stream_message(
|
|
215
|
+
adapter: APIAdapter, *, messages: Any, system: list[str], tools: list[dict[str, Any]],
|
|
216
|
+
config: ModelConfig, cwd: str | None = None, session_id: str | None = None,
|
|
217
|
+
purpose: str = "conversation", operation_id: str | None = None,
|
|
218
|
+
) -> AsyncIterator[StreamChunk]:
|
|
219
|
+
"""Yield unchanged provider chunks; persist one row per attempt on close."""
|
|
220
|
+
global recording_error
|
|
221
|
+
store = UsageStore()
|
|
222
|
+
request_id = uuid.uuid4().hex
|
|
223
|
+
provider = str(getattr(getattr(adapter, "config", None), "provider", None) or type(adapter).__name__)
|
|
224
|
+
recorded = False
|
|
225
|
+
try:
|
|
226
|
+
await asyncio.to_thread(store.begin, request_id, int(time.time() * 1000),
|
|
227
|
+
cwd=cwd, model=config.model, provider=provider,
|
|
228
|
+
purpose=purpose, session_id=session_id,
|
|
229
|
+
operation_id=operation_id)
|
|
230
|
+
recorded = True
|
|
231
|
+
except Exception:
|
|
232
|
+
recording_error = "使用记录数据库无法写入;部分请求可能未被统计"
|
|
233
|
+
store.mark_error()
|
|
234
|
+
logger.warning("Unable to start usage record", exc_info=True)
|
|
235
|
+
usage: dict[str, int] = {}
|
|
236
|
+
outcome = "interrupted"
|
|
237
|
+
stop_reason: str | None = None
|
|
238
|
+
actual_model: str | None = None
|
|
239
|
+
source = None
|
|
240
|
+
try:
|
|
241
|
+
source = adapter.stream_message(messages=messages, system=system, tools=tools, config=config)
|
|
242
|
+
async for chunk in source:
|
|
243
|
+
if chunk.model_id:
|
|
244
|
+
actual_model = chunk.model_id
|
|
245
|
+
if chunk.usage:
|
|
246
|
+
changed = False
|
|
247
|
+
for key in ("total_input_tokens", "input_tokens", "output_tokens", "total_tokens",
|
|
248
|
+
"cache_read_tokens", "cache_write_tokens", "reasoning_tokens"):
|
|
249
|
+
value, present = usage_int_field(chunk.usage, key)
|
|
250
|
+
if present and (key not in usage or value > usage[key]):
|
|
251
|
+
usage[key] = value
|
|
252
|
+
changed = True
|
|
253
|
+
if changed and recorded:
|
|
254
|
+
try:
|
|
255
|
+
await asyncio.to_thread(store.snapshot, request_id, usage.copy(), actual_model)
|
|
256
|
+
except Exception:
|
|
257
|
+
recording_error = "使用记录数据库无法写入;部分请求可能未被统计"
|
|
258
|
+
store.mark_error()
|
|
259
|
+
logger.warning("Unable to save usage snapshot", exc_info=True)
|
|
260
|
+
if chunk.stop_reason:
|
|
261
|
+
stop_reason = chunk.stop_reason
|
|
262
|
+
if chunk.type == "error":
|
|
263
|
+
outcome = "error"
|
|
264
|
+
log_api_error(adapter, session_id=session_id, model=config.model,
|
|
265
|
+
attempt_id=request_id, purpose=purpose, chunk=chunk)
|
|
266
|
+
yield chunk
|
|
267
|
+
if outcome != "error":
|
|
268
|
+
outcome = "output_limit" if stop_reason in {"max_tokens", "max_output_tokens", "length"} else "completed"
|
|
269
|
+
except (GeneratorExit, asyncio.CancelledError):
|
|
270
|
+
outcome = "interrupted"
|
|
271
|
+
raise
|
|
272
|
+
except BaseException as exc:
|
|
273
|
+
outcome = "error"
|
|
274
|
+
if isinstance(exc, Exception):
|
|
275
|
+
log_api_error(adapter, session_id=session_id, model=config.model,
|
|
276
|
+
attempt_id=request_id, purpose=purpose, exception=exc)
|
|
277
|
+
raise
|
|
278
|
+
finally:
|
|
279
|
+
try:
|
|
280
|
+
if source is not None:
|
|
281
|
+
await source.aclose()
|
|
282
|
+
finally:
|
|
283
|
+
if recorded:
|
|
284
|
+
try:
|
|
285
|
+
await asyncio.to_thread(store.finish, request_id, usage.copy(), outcome, stop_reason, actual_model)
|
|
286
|
+
except Exception:
|
|
287
|
+
recording_error = "使用记录数据库无法写入;部分请求可能未被统计"
|
|
288
|
+
store.mark_error()
|
|
289
|
+
logger.warning("Unable to finish usage record", exc_info=True)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Normalize text for UTF-8 I/O (JSON, HTTP, terminals).
|
|
2
|
+
|
|
3
|
+
Python allows lone surrogate code units in ``str``; strict UTF-8 encoders
|
|
4
|
+
(API clients, ``sys.stdout`` on UTF-8, JSON lines) reject them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def safe_utf8_str(s: str) -> str:
|
|
13
|
+
"""Replace lone surrogates so the string encodes as UTF-8."""
|
|
14
|
+
if not s:
|
|
15
|
+
return s
|
|
16
|
+
return s.encode("utf-8", errors="surrogatepass").decode("utf-8", errors="replace")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def safe_utf8_json_tree(obj: Any) -> Any:
|
|
20
|
+
"""Recursively sanitize strings for JSON or API payloads."""
|
|
21
|
+
if isinstance(obj, str):
|
|
22
|
+
return safe_utf8_str(obj)
|
|
23
|
+
if isinstance(obj, dict):
|
|
24
|
+
return {k: safe_utf8_json_tree(v) for k, v in obj.items()}
|
|
25
|
+
if isinstance(obj, list):
|
|
26
|
+
return [safe_utf8_json_tree(v) for v in obj]
|
|
27
|
+
if isinstance(obj, tuple):
|
|
28
|
+
return tuple(safe_utf8_json_tree(v) for v in obj)
|
|
29
|
+
return obj
|