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.
- package/backend/pyproject.toml +1 -1
- 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/agent.py +117 -34
- package/backend/src/flowent/approval.py +148 -0
- package/backend/src/flowent/cli.py +4 -2
- package/backend/src/flowent/context.py +19 -1
- package/backend/src/flowent/llm.py +176 -16
- package/backend/src/flowent/logging.py +60 -0
- package/backend/src/flowent/main.py +639 -210
- package/backend/src/flowent/patch.py +55 -31
- package/backend/src/flowent/permissions.py +185 -42
- package/backend/src/flowent/sandbox.py +55 -1
- package/backend/src/flowent/static/assets/index-BlaCigkZ.js +82 -0
- package/backend/src/flowent/static/assets/index-CRvbsH4K.css +2 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +113 -18
- 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 +39 -0
- package/backend/tests/test_agent_tools.py +213 -1
- package/backend/tests/test_approval.py +283 -0
- package/backend/tests/test_llm_providers.py +377 -0
- package/backend/tests/test_logging.py +30 -0
- package/backend/tests/test_patch.py +112 -0
- package/backend/tests/test_permissions.py +198 -53
- package/backend/tests/test_persistence.py +78 -0
- package/backend/tests/test_startup_requirements.py +54 -0
- package/backend/tests/test_workspace_chat.py +902 -36
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/index-BlaCigkZ.js +82 -0
- package/dist/frontend/assets/index-CRvbsH4K.css +2 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +1 -1
- package/backend/src/flowent/static/assets/index-BREidonU.css +0 -2
- package/backend/src/flowent/static/assets/index-DSniOrhL.js +0 -81
- package/dist/frontend/assets/index-BREidonU.css +0 -2
- 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
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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]:
|