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.
Files changed (68) hide show
  1. package/backend/pyproject.toml +31 -5
  2. package/backend/src/flowent/agent.py +13 -4
  3. package/backend/src/flowent/approval.py +6 -4
  4. package/backend/src/flowent/compact.py +35 -14
  5. package/backend/src/flowent/llm.py +73 -7
  6. package/backend/src/flowent/main.py +441 -85
  7. package/backend/src/flowent/static/assets/index-Bz76A4EJ.js +82 -0
  8. package/backend/src/flowent/static/assets/index-DufpDl8x.css +2 -0
  9. package/backend/src/flowent/static/index.html +2 -2
  10. package/backend/src/flowent/storage.py +151 -7
  11. package/backend/src/flowent/usage.py +315 -0
  12. package/backend/uv.lock +971 -3
  13. package/dist/frontend/assets/index-Bz76A4EJ.js +82 -0
  14. package/dist/frontend/assets/index-DufpDl8x.css +2 -0
  15. package/dist/frontend/index.html +2 -2
  16. package/package.json +24 -3
  17. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/__pycache__/approval.cpython-313.pyc +0 -0
  21. package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
  22. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  23. package/backend/src/flowent/__pycache__/compact.cpython-313.pyc +0 -0
  24. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  25. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  26. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  27. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  28. package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
  29. package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
  30. package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
  31. package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
  32. package/backend/src/flowent/__pycache__/permissions.cpython-313.pyc +0 -0
  33. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  34. package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
  35. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  36. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  37. package/backend/src/flowent/static/assets/index-BlaCigkZ.js +0 -82
  38. package/backend/src/flowent/static/assets/index-CRvbsH4K.css +0 -2
  39. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  41. package/backend/tests/__pycache__/test_approval.cpython-313-pytest-9.0.3.pyc +0 -0
  42. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  43. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  44. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  45. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  46. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  47. package/backend/tests/__pycache__/test_patch.cpython-313-pytest-9.0.3.pyc +0 -0
  48. package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
  49. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  50. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  51. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  52. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  53. package/backend/tests/conftest.py +0 -60
  54. package/backend/tests/test_agent_tools.py +0 -1124
  55. package/backend/tests/test_approval.py +0 -283
  56. package/backend/tests/test_channels.py +0 -360
  57. package/backend/tests/test_health.py +0 -12
  58. package/backend/tests/test_llm_providers.py +0 -548
  59. package/backend/tests/test_logging.py +0 -212
  60. package/backend/tests/test_mcp.py +0 -788
  61. package/backend/tests/test_patch.py +0 -112
  62. package/backend/tests/test_permissions.py +0 -588
  63. package/backend/tests/test_persistence.py +0 -249
  64. package/backend/tests/test_skills.py +0 -462
  65. package/backend/tests/test_startup_requirements.py +0 -144
  66. package/backend/tests/test_workspace_chat.py +0 -2174
  67. package/dist/frontend/assets/index-BlaCigkZ.js +0 -82
  68. 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