flowent 0.1.4 → 0.2.0

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 +1 -1
  2. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  3. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  4. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  5. package/backend/src/flowent/__pycache__/approval.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/compact.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/permissions.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  21. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  22. package/backend/src/flowent/agent.py +117 -34
  23. package/backend/src/flowent/approval.py +148 -0
  24. package/backend/src/flowent/cli.py +4 -2
  25. package/backend/src/flowent/context.py +19 -1
  26. package/backend/src/flowent/llm.py +176 -16
  27. package/backend/src/flowent/logging.py +60 -0
  28. package/backend/src/flowent/main.py +639 -210
  29. package/backend/src/flowent/patch.py +55 -31
  30. package/backend/src/flowent/permissions.py +185 -42
  31. package/backend/src/flowent/sandbox.py +55 -1
  32. package/backend/src/flowent/static/assets/index-BlaCigkZ.js +82 -0
  33. package/backend/src/flowent/static/assets/index-CRvbsH4K.css +2 -0
  34. package/backend/src/flowent/static/index.html +2 -2
  35. package/backend/src/flowent/storage.py +113 -18
  36. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  37. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  38. package/backend/tests/__pycache__/test_approval.cpython-313-pytest-9.0.3.pyc +0 -0
  39. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  41. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  42. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  43. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  44. package/backend/tests/__pycache__/test_patch.cpython-313-pytest-9.0.3.pyc +0 -0
  45. package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
  46. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  47. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  48. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  49. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  50. package/backend/tests/conftest.py +39 -0
  51. package/backend/tests/test_agent_tools.py +213 -1
  52. package/backend/tests/test_approval.py +283 -0
  53. package/backend/tests/test_llm_providers.py +377 -0
  54. package/backend/tests/test_logging.py +30 -0
  55. package/backend/tests/test_patch.py +112 -0
  56. package/backend/tests/test_permissions.py +198 -53
  57. package/backend/tests/test_persistence.py +78 -0
  58. package/backend/tests/test_startup_requirements.py +54 -0
  59. package/backend/tests/test_workspace_chat.py +902 -36
  60. package/backend/uv.lock +1 -1
  61. package/dist/frontend/assets/index-BlaCigkZ.js +82 -0
  62. package/dist/frontend/assets/index-CRvbsH4K.css +2 -0
  63. package/dist/frontend/index.html +2 -2
  64. package/package.json +1 -1
  65. package/backend/src/flowent/static/assets/index-BREidonU.css +0 -2
  66. package/backend/src/flowent/static/assets/index-DSniOrhL.js +0 -81
  67. package/dist/frontend/assets/index-BREidonU.css +0 -2
  68. package/dist/frontend/assets/index-DSniOrhL.js +0 -81
@@ -1,11 +1,17 @@
1
1
  import logging
2
+ import re
2
3
  from collections.abc import AsyncIterator, Awaitable, Mapping, Sequence
3
4
  from enum import StrEnum
4
- from typing import Any, Literal, Protocol
5
+ from typing import Any, Literal, Protocol, cast
6
+ from urllib.parse import urlsplit, urlunsplit
5
7
 
6
8
  from pydantic import BaseModel, ConfigDict, Field
7
9
 
8
- from flowent.logging import TRACE_LEVEL, configure_litellm_logging
10
+ from flowent.logging import (
11
+ TRACE_LEVEL,
12
+ configure_litellm_logging,
13
+ write_llm_request_diagnostic,
14
+ )
9
15
 
10
16
 
11
17
  class ProviderFormat(StrEnum):
@@ -62,12 +68,26 @@ class ModelListCallable(Protocol):
62
68
  logger = logging.getLogger("flowent.llm")
63
69
 
64
70
 
71
+ class LLMStreamError(RuntimeError):
72
+ pass
73
+
74
+
65
75
  MODEL_PREFIXES: dict[ProviderFormat, str] = {
66
76
  ProviderFormat.OPENAI: "openai",
67
77
  ProviderFormat.OPENAI_RESPONSES: "openai",
68
78
  ProviderFormat.ANTHROPIC: "anthropic",
69
79
  ProviderFormat.GEMINI: "gemini",
70
80
  }
81
+ _litellm_stream_error_patch_installed = False
82
+
83
+ PROVIDER_API_VERSIONS: dict[ProviderFormat, str] = {
84
+ ProviderFormat.OPENAI: "v1",
85
+ ProviderFormat.OPENAI_RESPONSES: "v1",
86
+ ProviderFormat.ANTHROPIC: "v1",
87
+ ProviderFormat.GEMINI: "v1beta",
88
+ }
89
+
90
+ VERSION_PATH_SEGMENT = re.compile(r"^v\d+(?:[a-z]+)?$", re.IGNORECASE)
71
91
 
72
92
 
73
93
  def provider_model_name(connection: ProviderConnection) -> str:
@@ -78,6 +98,40 @@ def provider_litellm_name(provider: ProviderFormat) -> str:
78
98
  return MODEL_PREFIXES[provider]
79
99
 
80
100
 
101
+ def normalize_provider_base_url(
102
+ provider: ProviderFormat, base_url: str | None
103
+ ) -> str | None:
104
+ if base_url is None:
105
+ return None
106
+ raw_base_url = base_url.strip()
107
+ if not raw_base_url:
108
+ return None
109
+ if raw_base_url.endswith("#"):
110
+ return raw_base_url[:-1].rstrip("/") or None
111
+
112
+ trimmed_base_url = raw_base_url.rstrip("/")
113
+ parsed_base_url = urlsplit(trimmed_base_url)
114
+ path_segments = [segment for segment in parsed_base_url.path.split("/") if segment]
115
+ if any(VERSION_PATH_SEGMENT.fullmatch(segment) for segment in path_segments):
116
+ return trimmed_base_url
117
+
118
+ version = PROVIDER_API_VERSIONS[provider]
119
+ if parsed_base_url.scheme and parsed_base_url.netloc:
120
+ path = parsed_base_url.path.rstrip("/")
121
+ normalized_path = f"{path}/{version}" if path else f"/{version}"
122
+ return urlunsplit(
123
+ (
124
+ parsed_base_url.scheme,
125
+ parsed_base_url.netloc,
126
+ normalized_path,
127
+ parsed_base_url.query,
128
+ parsed_base_url.fragment,
129
+ )
130
+ )
131
+
132
+ return f"{trimmed_base_url}/{version}"
133
+
134
+
81
135
  def normalize_provider_model_name(provider: ProviderFormat, model: str) -> str:
82
136
  prefix = f"{provider_litellm_name(provider)}/"
83
137
  if model.startswith(prefix):
@@ -85,6 +139,71 @@ def normalize_provider_model_name(provider: ProviderFormat, model: str) -> str:
85
139
  return model
86
140
 
87
141
 
142
+ def stream_failure_message(chunk: Any) -> str:
143
+ if isinstance(chunk, BaseModel):
144
+ chunk = chunk.model_dump()
145
+ if not isinstance(chunk, Mapping):
146
+ return ""
147
+
148
+ event_type = getattr(chunk.get("type"), "value", chunk.get("type"))
149
+ event_type = str(event_type or "")
150
+ if event_type == "error":
151
+ error = chunk.get("error", {})
152
+ elif event_type == "response.failed":
153
+ response = chunk.get("response", {})
154
+ error = value_at(response, "error", {})
155
+ else:
156
+ return ""
157
+
158
+ message = value_at(error, "message", "")
159
+ if isinstance(message, str) and message:
160
+ return message
161
+ code = value_at(error, "code", "")
162
+ if isinstance(code, str) and code:
163
+ return code
164
+ return "Upstream request failed"
165
+
166
+
167
+ def raise_for_stream_failure(chunk: Any) -> None:
168
+ message = stream_failure_message(chunk)
169
+ if message:
170
+ raise LLMStreamError(message)
171
+
172
+
173
+ def configure_litellm_stream_error_handling() -> None:
174
+ global _litellm_stream_error_patch_installed
175
+
176
+ if _litellm_stream_error_patch_installed:
177
+ return
178
+ try:
179
+ from litellm.completion_extras.litellm_responses_transformation.transformation import (
180
+ OpenAiResponsesToChatCompletionStreamIterator,
181
+ )
182
+ except Exception:
183
+ return
184
+
185
+ if getattr(
186
+ OpenAiResponsesToChatCompletionStreamIterator,
187
+ "_flowent_stream_error_patch_installed",
188
+ False,
189
+ ):
190
+ _litellm_stream_error_patch_installed = True
191
+ return
192
+
193
+ transformer = cast(Any, OpenAiResponsesToChatCompletionStreamIterator)
194
+ original = transformer.translate_responses_chunk_to_openai_stream
195
+
196
+ def translate_responses_chunk_to_openai_stream(parsed_chunk: Any) -> Any:
197
+ raise_for_stream_failure(parsed_chunk)
198
+ return original(parsed_chunk)
199
+
200
+ transformer.translate_responses_chunk_to_openai_stream = staticmethod(
201
+ translate_responses_chunk_to_openai_stream
202
+ )
203
+ transformer._flowent_stream_error_patch_installed = True
204
+ _litellm_stream_error_patch_installed = True
205
+
206
+
88
207
  def unique_model_names(provider: ProviderFormat, models: Sequence[str]) -> list[str]:
89
208
  seen: set[str] = set()
90
209
  normalized_models: list[str] = []
@@ -111,7 +230,7 @@ def list_provider_models(
111
230
  model_lister = get_valid_models
112
231
 
113
232
  models = model_lister(
114
- api_base=base_url,
233
+ api_base=normalize_provider_base_url(provider, base_url),
115
234
  api_key=secret_reference,
116
235
  check_provider_endpoint=True,
117
236
  custom_llm_provider=provider_litellm_name(provider),
@@ -119,6 +238,21 @@ def list_provider_models(
119
238
  return unique_model_names(provider, models)
120
239
 
121
240
 
241
+ def normalize_system_messages(
242
+ messages: Sequence[Mapping[str, Any]],
243
+ provider: ProviderFormat,
244
+ ) -> list[dict[str, Any]]:
245
+ normalized_messages = [dict(message) for message in messages]
246
+ if provider in {ProviderFormat.ANTHROPIC, ProviderFormat.GEMINI}:
247
+ return [
248
+ {**message, "role": "user"}
249
+ if message.get("role") == "system" and index > 0
250
+ else message
251
+ for index, message in enumerate(normalized_messages)
252
+ ]
253
+ return normalized_messages
254
+
255
+
122
256
  def build_litellm_request(
123
257
  connection: ProviderConnection,
124
258
  messages: Sequence[ChatMessage | Mapping[str, Any]],
@@ -126,10 +260,13 @@ def build_litellm_request(
126
260
  stream: bool = False,
127
261
  tools: Sequence[Mapping[str, Any]] | None = None,
128
262
  ) -> dict[str, Any]:
129
- request_messages = [
130
- message.model_dump() if isinstance(message, ChatMessage) else dict(message)
131
- for message in messages
132
- ]
263
+ request_messages = normalize_system_messages(
264
+ [
265
+ message.model_dump() if isinstance(message, ChatMessage) else dict(message)
266
+ for message in messages
267
+ ],
268
+ connection.provider,
269
+ )
133
270
  request: dict[str, Any] = {
134
271
  "api_key": connection.secret_reference,
135
272
  "messages": request_messages,
@@ -139,8 +276,11 @@ def build_litellm_request(
139
276
  request["tools"] = list(tools)
140
277
  if stream:
141
278
  request["stream"] = True
142
- if connection.base_url:
143
- request["api_base"] = connection.base_url
279
+ normalized_base_url = normalize_provider_base_url(
280
+ connection.provider, connection.base_url
281
+ )
282
+ if normalized_base_url:
283
+ request["api_base"] = normalized_base_url
144
284
  if connection.reasoning_effort != ReasoningEffort.DEFAULT:
145
285
  request["reasoning_effort"] = connection.reasoning_effort.value
146
286
  logger.log(
@@ -148,7 +288,7 @@ def build_litellm_request(
148
288
  "Built LiteLLM request provider=%s model=%s base_url=%s stream=%s tools=%s reasoning_effort=%s messages=%r",
149
289
  connection.provider,
150
290
  connection.model,
151
- connection.base_url or "",
291
+ normalized_base_url or "",
152
292
  stream,
153
293
  bool(tools),
154
294
  connection.reasoning_effort,
@@ -157,6 +297,24 @@ def build_litellm_request(
157
297
  return request
158
298
 
159
299
 
300
+ def record_litellm_request_diagnostic(
301
+ connection: ProviderConnection,
302
+ request: Mapping[str, Any],
303
+ ) -> None:
304
+ write_llm_request_diagnostic(
305
+ {
306
+ "base_url": request.get("api_base"),
307
+ "litellm_model": request["model"],
308
+ "messages": request["messages"],
309
+ "model": connection.model,
310
+ "provider": connection.provider.value,
311
+ "reasoning_effort": connection.reasoning_effort.value,
312
+ "stream": request.get("stream", False),
313
+ "tools": request.get("tools", []),
314
+ }
315
+ )
316
+
317
+
160
318
  async def complete_chat(
161
319
  connection: ProviderConnection,
162
320
  messages: Sequence[ChatMessage | Mapping[str, Any]],
@@ -175,9 +333,9 @@ async def complete_chat(
175
333
  connection.provider,
176
334
  connection.model,
177
335
  )
178
- response = await completion(
179
- **build_litellm_request(connection, messages, tools=tools)
180
- )
336
+ request = build_litellm_request(connection, messages, tools=tools)
337
+ record_litellm_request_diagnostic(connection, request)
338
+ response = await completion(**request)
181
339
  logger.log(TRACE_LEVEL, "LLM completion response=%r", response)
182
340
  choice = response["choices"][0]["message"]
183
341
  return ChatMessage(role=choice.get("role", "assistant"), content=choice["content"])
@@ -277,6 +435,7 @@ async def stream_chat_chunks(
277
435
  from litellm import acompletion
278
436
 
279
437
  configure_litellm_logging()
438
+ configure_litellm_stream_error_handling()
280
439
  completion = acompletion
281
440
 
282
441
  logger.debug(
@@ -284,10 +443,11 @@ async def stream_chat_chunks(
284
443
  connection.provider,
285
444
  connection.model,
286
445
  )
287
- response = await completion(
288
- **build_litellm_request(connection, messages, stream=True, tools=tools)
289
- )
446
+ request = build_litellm_request(connection, messages, stream=True, tools=tools)
447
+ record_litellm_request_diagnostic(connection, request)
448
+ response = await completion(**request)
290
449
  async for chunk in response:
450
+ raise_for_stream_failure(chunk)
291
451
  logger.log(TRACE_LEVEL, "LLM stream chunk=%r", chunk)
292
452
  yield chunk
293
453
 
@@ -1,11 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  import logging
4
5
  import os
5
6
  import re
6
7
  import sys
7
8
  from datetime import datetime
8
9
  from pathlib import Path
10
+ from typing import Any
9
11
 
10
12
  from flowent.paths import data_directory
11
13
 
@@ -14,6 +16,7 @@ DEFAULT_LOG_RETENTION = 5
14
16
  LITELLM_LOGGER_NAMES = ("LiteLLM", "LiteLLM Router", "LiteLLM Proxy")
15
17
  _configured_log_file: Path | None = None
16
18
  _configured_log_process_id: int | None = None
19
+ _llm_request_counter = 0
17
20
  _SECRET_PATTERNS = (
18
21
  re.compile(r"(?i)\b(bearer)\s+([^\s,}]+)"),
19
22
  re.compile(
@@ -36,10 +39,21 @@ def redact_log_value(value: object) -> str:
36
39
  return text
37
40
 
38
41
 
42
+ def redact_diagnostic_value(value: object) -> str:
43
+ text = str(value)
44
+ for pattern in _SECRET_PATTERNS:
45
+ text = pattern.sub("[REDACTED]", text)
46
+ return text
47
+
48
+
39
49
  def log_directory(directory: Path | None = None) -> Path:
40
50
  return (directory or data_directory()) / "logs"
41
51
 
42
52
 
53
+ def llm_request_log_directory(directory: Path | None = None) -> Path:
54
+ return log_directory(directory) / "llm-requests"
55
+
56
+
43
57
  def parse_log_level(value: str | None, default: int) -> int:
44
58
  if not value:
45
59
  return default
@@ -112,6 +126,52 @@ def new_log_file_path(directory: Path | None = None) -> Path:
112
126
  return logs / f"flowent-{timestamp}-{os.getpid()}.log"
113
127
 
114
128
 
129
+ def sanitize_diagnostic_value(value: Any) -> Any:
130
+ if isinstance(value, dict):
131
+ return {
132
+ key: sanitize_diagnostic_value(item)
133
+ for key, item in value.items()
134
+ if not secret_field_name(str(key))
135
+ }
136
+ if isinstance(value, list | tuple):
137
+ return [sanitize_diagnostic_value(item) for item in value]
138
+ if isinstance(value, str):
139
+ return redact_diagnostic_value(value)
140
+ return value
141
+
142
+
143
+ def secret_field_name(name: str) -> bool:
144
+ normalized = re.sub(r"[^a-z0-9]", "", name.lower())
145
+ return "secret" in normalized or normalized in {
146
+ "accesstoken",
147
+ "apikey",
148
+ "authorization",
149
+ "password",
150
+ "refreshtoken",
151
+ "token",
152
+ }
153
+
154
+
155
+ def write_llm_request_diagnostic(payload: dict[str, Any]) -> Path | None:
156
+ global _llm_request_counter
157
+
158
+ if not development_mode():
159
+ return None
160
+
161
+ _llm_request_counter += 1
162
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
163
+ path = llm_request_log_directory() / (
164
+ f"llm-request-{timestamp}-{os.getpid()}-{_llm_request_counter:06d}.json"
165
+ )
166
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
167
+ path.write_text(
168
+ json.dumps(sanitize_diagnostic_value(payload), ensure_ascii=False, indent=2)
169
+ + "\n",
170
+ encoding="utf-8",
171
+ )
172
+ return path
173
+
174
+
115
175
  def prune_old_logs(logs: Path, *, keep: int = DEFAULT_LOG_RETENTION) -> None:
116
176
  files = sorted(logs.glob("flowent-*.log"), key=lambda item: item.name)
117
177
  for old_log in files[:-keep]: