flowent 0.2.0 → 0.2.2
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.
- package/backend/pyproject.toml +31 -5
- package/backend/src/flowent/agent.py +13 -4
- package/backend/src/flowent/approval.py +6 -4
- package/backend/src/flowent/compact.py +35 -14
- package/backend/src/flowent/llm.py +73 -7
- package/backend/src/flowent/main.py +441 -85
- package/backend/src/flowent/static/assets/index-Bz76A4EJ.js +82 -0
- package/backend/src/flowent/static/assets/index-DufpDl8x.css +2 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +151 -7
- package/backend/src/flowent/usage.py +315 -0
- package/backend/uv.lock +971 -3
- package/dist/frontend/assets/index-Bz76A4EJ.js +82 -0
- package/dist/frontend/assets/index-DufpDl8x.css +2 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +24 -3
- package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/approval.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/compact.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/permissions.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
- package/backend/src/flowent/static/assets/index-BlaCigkZ.js +0 -82
- package/backend/src/flowent/static/assets/index-CRvbsH4K.css +0 -2
- package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_approval.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_patch.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/conftest.py +0 -60
- package/backend/tests/test_agent_tools.py +0 -1124
- package/backend/tests/test_approval.py +0 -283
- package/backend/tests/test_channels.py +0 -360
- package/backend/tests/test_health.py +0 -12
- package/backend/tests/test_llm_providers.py +0 -548
- package/backend/tests/test_logging.py +0 -212
- package/backend/tests/test_mcp.py +0 -788
- package/backend/tests/test_patch.py +0 -112
- package/backend/tests/test_permissions.py +0 -588
- package/backend/tests/test_persistence.py +0 -249
- package/backend/tests/test_skills.py +0 -462
- package/backend/tests/test_startup_requirements.py +0 -144
- package/backend/tests/test_workspace_chat.py +0 -2174
- package/dist/frontend/assets/index-BlaCigkZ.js +0 -82
- package/dist/frontend/assets/index-CRvbsH4K.css +0 -2
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections.abc import Mapping, Sequence
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
DEFAULT_MODEL_CONTEXT_WINDOW = 120_000
|
|
8
|
+
|
|
9
|
+
MODEL_CONTEXT_WINDOWS: dict[str, int] = {
|
|
10
|
+
"claude-3-7-sonnet-20250219": 200_000,
|
|
11
|
+
"claude-3-haiku-20240307": 200_000,
|
|
12
|
+
"claude-3-opus-20240229": 200_000,
|
|
13
|
+
"claude-4-opus-20250514": 200_000,
|
|
14
|
+
"claude-4-sonnet-20250514": 1_000_000,
|
|
15
|
+
"claude-haiku-4-5": 200_000,
|
|
16
|
+
"claude-haiku-4-5-20251001": 200_000,
|
|
17
|
+
"claude-opus-4-1": 200_000,
|
|
18
|
+
"claude-opus-4-1-20250805": 200_000,
|
|
19
|
+
"claude-opus-4-20250514": 200_000,
|
|
20
|
+
"claude-opus-4-5": 200_000,
|
|
21
|
+
"claude-opus-4-5-20251101": 200_000,
|
|
22
|
+
"claude-opus-4-6": 1_000_000,
|
|
23
|
+
"claude-opus-4-6-20260205": 1_000_000,
|
|
24
|
+
"claude-opus-4-7": 1_000_000,
|
|
25
|
+
"claude-opus-4-7-20260416": 1_000_000,
|
|
26
|
+
"claude-opus-4-8": 1_000_000,
|
|
27
|
+
"claude-sonnet-4-20250514": 1_000_000,
|
|
28
|
+
"claude-sonnet-4-5": 200_000,
|
|
29
|
+
"claude-sonnet-4-5-20250929": 200_000,
|
|
30
|
+
"claude-sonnet-4-5-20250929-v1:0": 200_000,
|
|
31
|
+
"claude-sonnet-4-6": 1_000_000,
|
|
32
|
+
"gemini-2.5-computer-use-preview-10-2025": 128_000,
|
|
33
|
+
"gemini-2.5-flash": 1_048_576,
|
|
34
|
+
"gemini-2.5-flash-image": 32_768,
|
|
35
|
+
"gemini-2.5-flash-lite": 1_048_576,
|
|
36
|
+
"gemini-2.5-flash-lite-preview-06-17": 1_048_576,
|
|
37
|
+
"gemini-2.5-flash-lite-preview-09-2025": 1_048_576,
|
|
38
|
+
"gemini-2.5-flash-native-audio-latest": 1_048_576,
|
|
39
|
+
"gemini-2.5-flash-native-audio-preview-09-2025": 1_048_576,
|
|
40
|
+
"gemini-2.5-flash-native-audio-preview-12-2025": 1_048_576,
|
|
41
|
+
"gemini-2.5-flash-preview-09-2025": 1_048_576,
|
|
42
|
+
"gemini-2.5-pro": 1_048_576,
|
|
43
|
+
"gemini-2.5-pro-preview-tts": 1_048_576,
|
|
44
|
+
"gemini-3-flash-preview": 1_048_576,
|
|
45
|
+
"gemini-3-pro-image-preview": 65_536,
|
|
46
|
+
"gemini-3-pro-preview": 1_048_576,
|
|
47
|
+
"gemini-3.1-flash-image-preview": 65_536,
|
|
48
|
+
"gemini-3.1-flash-lite": 1_048_576,
|
|
49
|
+
"gemini-3.1-flash-lite-preview": 1_048_576,
|
|
50
|
+
"gemini-3.1-flash-live-preview": 131_072,
|
|
51
|
+
"gemini-3.1-pro-preview": 1_048_576,
|
|
52
|
+
"gemini-3.1-pro-preview-customtools": 1_048_576,
|
|
53
|
+
"gemini-3.5-flash": 1_048_576,
|
|
54
|
+
"gpt-4.1": 1_047_576,
|
|
55
|
+
"gpt-4.1-2025-04-14": 1_047_576,
|
|
56
|
+
"gpt-4.1-mini": 1_047_576,
|
|
57
|
+
"gpt-4.1-mini-2025-04-14": 1_047_576,
|
|
58
|
+
"gpt-4.1-nano": 1_047_576,
|
|
59
|
+
"gpt-4.1-nano-2025-04-14": 1_047_576,
|
|
60
|
+
"gpt-5": 272_000,
|
|
61
|
+
"gpt-5-2025-08-07": 272_000,
|
|
62
|
+
"gpt-5-chat": 128_000,
|
|
63
|
+
"gpt-5-chat-latest": 128_000,
|
|
64
|
+
"gpt-5-codex": 272_000,
|
|
65
|
+
"gpt-5-mini": 272_000,
|
|
66
|
+
"gpt-5-mini-2025-08-07": 272_000,
|
|
67
|
+
"gpt-5-nano": 272_000,
|
|
68
|
+
"gpt-5-nano-2025-08-07": 272_000,
|
|
69
|
+
"gpt-5-pro": 128_000,
|
|
70
|
+
"gpt-5-pro-2025-10-06": 128_000,
|
|
71
|
+
"gpt-5-search-api": 272_000,
|
|
72
|
+
"gpt-5-search-api-2025-10-14": 272_000,
|
|
73
|
+
"gpt-5.1": 272_000,
|
|
74
|
+
"gpt-5.1-2025-11-13": 272_000,
|
|
75
|
+
"gpt-5.1-chat-latest": 128_000,
|
|
76
|
+
"gpt-5.1-codex": 272_000,
|
|
77
|
+
"gpt-5.1-codex-max": 272_000,
|
|
78
|
+
"gpt-5.1-codex-mini": 272_000,
|
|
79
|
+
"gpt-5.2": 272_000,
|
|
80
|
+
"gpt-5.2-2025-12-11": 272_000,
|
|
81
|
+
"gpt-5.2-chat-latest": 128_000,
|
|
82
|
+
"gpt-5.2-codex": 272_000,
|
|
83
|
+
"gpt-5.2-pro": 272_000,
|
|
84
|
+
"gpt-5.2-pro-2025-12-11": 272_000,
|
|
85
|
+
"gpt-5.3-chat-latest": 128_000,
|
|
86
|
+
"gpt-5.3-codex": 272_000,
|
|
87
|
+
"gpt-5.4": 1_050_000,
|
|
88
|
+
"gpt-5.4-2026-03-05": 1_050_000,
|
|
89
|
+
"gpt-5.4-mini": 272_000,
|
|
90
|
+
"gpt-5.4-mini-2026-03-17": 272_000,
|
|
91
|
+
"gpt-5.4-nano": 272_000,
|
|
92
|
+
"gpt-5.4-nano-2026-03-17": 272_000,
|
|
93
|
+
"gpt-5.4-pro": 1_050_000,
|
|
94
|
+
"gpt-5.4-pro-2026-03-05": 1_050_000,
|
|
95
|
+
"gpt-5.5": 1_050_000,
|
|
96
|
+
"gpt-5.5-2026-04-23": 1_050_000,
|
|
97
|
+
"gpt-5.5-pro": 1_050_000,
|
|
98
|
+
"gpt-5.5-pro-2026-04-23": 1_050_000,
|
|
99
|
+
"o3": 200_000,
|
|
100
|
+
"o3-2025-04-16": 200_000,
|
|
101
|
+
"o3-deep-research": 200_000,
|
|
102
|
+
"o3-deep-research-2025-06-26": 200_000,
|
|
103
|
+
"o3-mini": 200_000,
|
|
104
|
+
"o3-mini-2025-01-31": 200_000,
|
|
105
|
+
"o3-pro": 200_000,
|
|
106
|
+
"o3-pro-2025-06-10": 200_000,
|
|
107
|
+
"o4-mini": 200_000,
|
|
108
|
+
"o4-mini-2025-04-16": 200_000,
|
|
109
|
+
"o4-mini-deep-research": 200_000,
|
|
110
|
+
"o4-mini-deep-research-2025-06-26": 200_000,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
MODEL_CONTEXT_WINDOW_NAMES = tuple(sorted(MODEL_CONTEXT_WINDOWS, key=len, reverse=True))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TokenUsage(BaseModel):
|
|
117
|
+
model_config = ConfigDict(extra="forbid")
|
|
118
|
+
|
|
119
|
+
input_tokens: int = 0
|
|
120
|
+
cached_input_tokens: int = 0
|
|
121
|
+
output_tokens: int = 0
|
|
122
|
+
reasoning_output_tokens: int = 0
|
|
123
|
+
total_tokens: int = 0
|
|
124
|
+
|
|
125
|
+
def add(self, other: "TokenUsage") -> "TokenUsage":
|
|
126
|
+
return TokenUsage(
|
|
127
|
+
input_tokens=self.input_tokens + other.input_tokens,
|
|
128
|
+
cached_input_tokens=self.cached_input_tokens + other.cached_input_tokens,
|
|
129
|
+
output_tokens=self.output_tokens + other.output_tokens,
|
|
130
|
+
reasoning_output_tokens=self.reasoning_output_tokens
|
|
131
|
+
+ other.reasoning_output_tokens,
|
|
132
|
+
total_tokens=self.total_tokens + other.total_tokens,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TokenUsageInfo(BaseModel):
|
|
137
|
+
model_config = ConfigDict(extra="forbid")
|
|
138
|
+
|
|
139
|
+
total_token_usage: TokenUsage = Field(default_factory=TokenUsage)
|
|
140
|
+
last_token_usage: TokenUsage = Field(default_factory=TokenUsage)
|
|
141
|
+
model_context_window: int | None = None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def current_model_context_window(model_name: str | None = None) -> int:
|
|
145
|
+
return model_context_window_for(model_name)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def model_context_window_for(model_name: str | None = None) -> int:
|
|
149
|
+
candidates = normalized_model_name_candidates(model_name)
|
|
150
|
+
for candidate in candidates:
|
|
151
|
+
context_window = MODEL_CONTEXT_WINDOWS.get(candidate)
|
|
152
|
+
if context_window is not None:
|
|
153
|
+
return context_window
|
|
154
|
+
for candidate in candidates:
|
|
155
|
+
for known_model in MODEL_CONTEXT_WINDOW_NAMES:
|
|
156
|
+
if is_model_context_window_prefix_match(candidate, known_model):
|
|
157
|
+
return MODEL_CONTEXT_WINDOWS[known_model]
|
|
158
|
+
return DEFAULT_MODEL_CONTEXT_WINDOW
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def normalized_model_name_candidates(model_name: str | None) -> tuple[str, ...]:
|
|
162
|
+
if model_name is None:
|
|
163
|
+
return ()
|
|
164
|
+
normalized = model_name.strip().lower()
|
|
165
|
+
if not normalized:
|
|
166
|
+
return ()
|
|
167
|
+
candidates = [normalized]
|
|
168
|
+
if "/" in normalized:
|
|
169
|
+
candidates.append(normalized.rsplit("/", 1)[-1])
|
|
170
|
+
return tuple(dict.fromkeys(candidates))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def is_model_context_window_prefix_match(candidate: str, known_model: str) -> bool:
|
|
174
|
+
if candidate == known_model:
|
|
175
|
+
return True
|
|
176
|
+
if not candidate.startswith(known_model):
|
|
177
|
+
return False
|
|
178
|
+
return candidate[len(known_model)] in {"-", ".", ":", "/"}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def append_token_usage(
|
|
182
|
+
usage_info: TokenUsageInfo | None,
|
|
183
|
+
usage: TokenUsage,
|
|
184
|
+
*,
|
|
185
|
+
model_context_window: int | None = None,
|
|
186
|
+
) -> TokenUsageInfo:
|
|
187
|
+
info = usage_info or TokenUsageInfo(model_context_window=model_context_window)
|
|
188
|
+
return TokenUsageInfo(
|
|
189
|
+
total_token_usage=info.total_token_usage.add(usage),
|
|
190
|
+
last_token_usage=usage,
|
|
191
|
+
model_context_window=model_context_window or info.model_context_window,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def recompute_context_usage(
|
|
196
|
+
usage_info: TokenUsageInfo | None,
|
|
197
|
+
active_context_tokens: int,
|
|
198
|
+
*,
|
|
199
|
+
model_context_window: int | None = None,
|
|
200
|
+
) -> TokenUsageInfo:
|
|
201
|
+
info = usage_info or TokenUsageInfo(model_context_window=model_context_window)
|
|
202
|
+
return TokenUsageInfo(
|
|
203
|
+
total_token_usage=info.total_token_usage,
|
|
204
|
+
last_token_usage=TokenUsage(total_tokens=max(0, active_context_tokens)),
|
|
205
|
+
model_context_window=model_context_window or info.model_context_window,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def token_usage_from_response(response: Any) -> TokenUsage | None:
|
|
210
|
+
usage = value_at(response, "usage")
|
|
211
|
+
if usage is None:
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
input_tokens = first_int_value(
|
|
215
|
+
value_at(usage, "input_tokens"),
|
|
216
|
+
value_at(usage, "prompt_tokens"),
|
|
217
|
+
)
|
|
218
|
+
output_tokens = first_int_value(
|
|
219
|
+
value_at(usage, "output_tokens"),
|
|
220
|
+
value_at(usage, "completion_tokens"),
|
|
221
|
+
)
|
|
222
|
+
total_tokens = first_int_value(value_at(usage, "total_tokens"))
|
|
223
|
+
cached_input_tokens = first_int_value(
|
|
224
|
+
value_at(usage, "cached_input_tokens"),
|
|
225
|
+
value_at(usage, "cache_read_input_tokens"),
|
|
226
|
+
value_at(usage, "cached_tokens"),
|
|
227
|
+
nested_value_at(usage, "prompt_tokens_details", "cached_tokens"),
|
|
228
|
+
nested_value_at(usage, "input_tokens_details", "cached_tokens"),
|
|
229
|
+
nested_value_at(usage, "cache_read", "input_tokens"),
|
|
230
|
+
)
|
|
231
|
+
reasoning_output_tokens = first_int_value(
|
|
232
|
+
value_at(usage, "reasoning_output_tokens"),
|
|
233
|
+
nested_value_at(usage, "completion_tokens_details", "reasoning_tokens"),
|
|
234
|
+
nested_value_at(usage, "output_tokens_details", "reasoning_tokens"),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if total_tokens is None:
|
|
238
|
+
total_tokens = (input_tokens or 0) + (output_tokens or 0)
|
|
239
|
+
|
|
240
|
+
return TokenUsage(
|
|
241
|
+
input_tokens=input_tokens or 0,
|
|
242
|
+
cached_input_tokens=cached_input_tokens or 0,
|
|
243
|
+
output_tokens=output_tokens or 0,
|
|
244
|
+
reasoning_output_tokens=reasoning_output_tokens or 0,
|
|
245
|
+
total_tokens=total_tokens,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def estimated_token_usage_for_messages(
|
|
250
|
+
messages: Sequence[Mapping[str, object]],
|
|
251
|
+
*,
|
|
252
|
+
output_content: str = "",
|
|
253
|
+
) -> TokenUsage:
|
|
254
|
+
total_tokens = sum(estimate_mapping_message_tokens(message) for message in messages)
|
|
255
|
+
output_tokens = approximate_token_count(output_content)
|
|
256
|
+
return TokenUsage(
|
|
257
|
+
input_tokens=max(total_tokens - output_tokens, 0),
|
|
258
|
+
output_tokens=output_tokens,
|
|
259
|
+
total_tokens=total_tokens,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def estimate_mapping_message_tokens(message: Mapping[str, object]) -> int:
|
|
264
|
+
total = approximate_token_count(string_content(message.get("content")))
|
|
265
|
+
tool_calls = message.get("tool_calls")
|
|
266
|
+
if tool_calls:
|
|
267
|
+
total += approximate_token_count(json.dumps(tool_calls, ensure_ascii=False))
|
|
268
|
+
if message.get("role") == "tool":
|
|
269
|
+
total += approximate_token_count(string_content(message.get("tool_call_id")))
|
|
270
|
+
return total
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def approximate_token_count(content: str) -> int:
|
|
274
|
+
if not content:
|
|
275
|
+
return 0
|
|
276
|
+
return max(1, (len(content) + 3) // 4)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def string_content(value: object) -> str:
|
|
280
|
+
if value is None:
|
|
281
|
+
return ""
|
|
282
|
+
if isinstance(value, str):
|
|
283
|
+
return value
|
|
284
|
+
return json.dumps(value, ensure_ascii=False)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def value_at(value: Any, key: str, default: Any = None) -> Any:
|
|
288
|
+
if isinstance(value, Mapping):
|
|
289
|
+
return value.get(key, default)
|
|
290
|
+
return getattr(value, key, default)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def nested_value_at(value: Any, *keys: str) -> Any:
|
|
294
|
+
current = value
|
|
295
|
+
for key in keys:
|
|
296
|
+
current = value_at(current, key)
|
|
297
|
+
if current is None:
|
|
298
|
+
return None
|
|
299
|
+
return current
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def first_int_value(*values: Any) -> int | None:
|
|
303
|
+
for value in values:
|
|
304
|
+
if isinstance(value, bool) or value is None:
|
|
305
|
+
continue
|
|
306
|
+
if isinstance(value, int):
|
|
307
|
+
return max(0, value)
|
|
308
|
+
if isinstance(value, float):
|
|
309
|
+
return max(0, int(value))
|
|
310
|
+
if isinstance(value, str):
|
|
311
|
+
try:
|
|
312
|
+
return max(0, int(value))
|
|
313
|
+
except ValueError:
|
|
314
|
+
continue
|
|
315
|
+
return None
|