flowent 0.1.5 → 0.2.1
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 +107 -37
- package/backend/src/flowent/compact.py +35 -14
- package/backend/src/flowent/llm.py +198 -12
- package/backend/src/flowent/main.py +260 -59
- package/backend/src/flowent/static/assets/index-CRSV2xu1.css +2 -0
- package/backend/src/flowent/static/assets/index-DUYj6rgD.js +82 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +135 -3
- package/backend/src/flowent/usage.py +315 -0
- package/backend/uv.lock +971 -3
- package/dist/frontend/assets/index-CRSV2xu1.css +2 -0
- package/dist/frontend/assets/index-DUYj6rgD.js +82 -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-Cl20cARb.css +0 -2
- package/backend/src/flowent/static/assets/index-dsDDsEym.js +0 -81
- 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 -21
- package/backend/tests/test_agent_tools.py +0 -988
- 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 -387
- 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 -2122
- package/dist/frontend/assets/index-Cl20cARb.css +0 -2
- package/dist/frontend/assets/index-dsDDsEym.js +0 -81
|
@@ -1,387 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
|
|
5
|
-
from flowent.llm import (
|
|
6
|
-
ChatMessage,
|
|
7
|
-
ProviderConnection,
|
|
8
|
-
ProviderFormat,
|
|
9
|
-
ReasoningEffort,
|
|
10
|
-
build_litellm_request,
|
|
11
|
-
chunk_delta_reasoning,
|
|
12
|
-
complete_chat,
|
|
13
|
-
normalize_system_messages,
|
|
14
|
-
stream_chat,
|
|
15
|
-
)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def read_single_llm_request_diagnostic(tmp_path):
|
|
19
|
-
files = sorted((tmp_path / "logs" / "llm-requests").glob("llm-request-*.json"))
|
|
20
|
-
assert len(files) == 1
|
|
21
|
-
return json.loads(files[0].read_text())
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def test_supported_provider_formats_match_product_choices() -> None:
|
|
25
|
-
assert [provider.value for provider in ProviderFormat] == [
|
|
26
|
-
"openai",
|
|
27
|
-
"openai_responses",
|
|
28
|
-
"anthropic",
|
|
29
|
-
"gemini",
|
|
30
|
-
]
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def test_build_litellm_request_maps_provider_connection_to_completion_args() -> None:
|
|
34
|
-
connection = ProviderConnection(
|
|
35
|
-
name="Primary",
|
|
36
|
-
provider=ProviderFormat.ANTHROPIC,
|
|
37
|
-
model="claude-sonnet-4-5",
|
|
38
|
-
secret_reference="connection-primary",
|
|
39
|
-
base_url="https://example.test/v1",
|
|
40
|
-
)
|
|
41
|
-
messages = [
|
|
42
|
-
ChatMessage(role="system", content="Keep answers direct."),
|
|
43
|
-
ChatMessage(role="user", content="Draft a launch checklist."),
|
|
44
|
-
]
|
|
45
|
-
|
|
46
|
-
request = build_litellm_request(connection, messages)
|
|
47
|
-
|
|
48
|
-
assert request == {
|
|
49
|
-
"api_base": "https://example.test/v1",
|
|
50
|
-
"api_key": "connection-primary",
|
|
51
|
-
"messages": [
|
|
52
|
-
{"role": "system", "content": "Keep answers direct."},
|
|
53
|
-
{"role": "user", "content": "Draft a launch checklist."},
|
|
54
|
-
],
|
|
55
|
-
"model": "anthropic/claude-sonnet-4-5",
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def test_build_litellm_request_omits_default_reasoning_effort() -> None:
|
|
60
|
-
connection = ProviderConnection(
|
|
61
|
-
name="Primary",
|
|
62
|
-
provider=ProviderFormat.OPENAI,
|
|
63
|
-
model="gpt-5.1",
|
|
64
|
-
secret_reference="connection-primary",
|
|
65
|
-
reasoning_effort=ReasoningEffort.DEFAULT,
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
request = build_litellm_request(
|
|
69
|
-
connection, [ChatMessage(role="user", content="Draft a checklist.")]
|
|
70
|
-
)
|
|
71
|
-
|
|
72
|
-
assert "reasoning_effort" not in request
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def test_build_litellm_request_includes_selected_reasoning_effort() -> None:
|
|
76
|
-
connection = ProviderConnection(
|
|
77
|
-
name="Primary",
|
|
78
|
-
provider=ProviderFormat.OPENAI,
|
|
79
|
-
model="gpt-5.1",
|
|
80
|
-
secret_reference="connection-primary",
|
|
81
|
-
reasoning_effort=ReasoningEffort.XHIGH,
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
request = build_litellm_request(
|
|
85
|
-
connection, [ChatMessage(role="user", content="Draft a checklist.")]
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
assert request["reasoning_effort"] == "xhigh"
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def test_chunk_delta_reasoning_reads_litellm_reasoning_fields() -> None:
|
|
92
|
-
assert (
|
|
93
|
-
chunk_delta_reasoning(
|
|
94
|
-
{"choices": [{"delta": {"reasoning_content": "Checking files."}}]}
|
|
95
|
-
)
|
|
96
|
-
== "Checking files."
|
|
97
|
-
)
|
|
98
|
-
assert (
|
|
99
|
-
chunk_delta_reasoning(
|
|
100
|
-
{
|
|
101
|
-
"choices": [
|
|
102
|
-
{
|
|
103
|
-
"delta": {
|
|
104
|
-
"thinking_blocks": [{"thinking": "Read files."}],
|
|
105
|
-
"reasoning_items": [{"summary": "Summarize."}],
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
]
|
|
109
|
-
}
|
|
110
|
-
)
|
|
111
|
-
== "Read files.Summarize."
|
|
112
|
-
)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
@pytest.mark.anyio
|
|
116
|
-
async def test_complete_chat_uses_injected_litellm_completion() -> None:
|
|
117
|
-
captured_request: dict[str, object] = {}
|
|
118
|
-
|
|
119
|
-
async def fake_completion(**request: object) -> dict[str, object]:
|
|
120
|
-
captured_request.update(request)
|
|
121
|
-
return {
|
|
122
|
-
"choices": [
|
|
123
|
-
{
|
|
124
|
-
"message": {
|
|
125
|
-
"content": "Here is the checklist.",
|
|
126
|
-
"role": "assistant",
|
|
127
|
-
},
|
|
128
|
-
}
|
|
129
|
-
]
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
connection = ProviderConnection(
|
|
133
|
-
name="Responses",
|
|
134
|
-
provider=ProviderFormat.OPENAI_RESPONSES,
|
|
135
|
-
model="gpt-5.1",
|
|
136
|
-
secret_reference="connection-responses",
|
|
137
|
-
)
|
|
138
|
-
|
|
139
|
-
answer = await complete_chat(
|
|
140
|
-
connection,
|
|
141
|
-
[ChatMessage(role="user", content="Create a checklist.")],
|
|
142
|
-
completion=fake_completion,
|
|
143
|
-
)
|
|
144
|
-
|
|
145
|
-
assert captured_request["model"] == "openai/gpt-5.1"
|
|
146
|
-
assert answer == ChatMessage(role="assistant", content="Here is the checklist.")
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
@pytest.mark.anyio
|
|
150
|
-
async def test_development_mode_writes_completion_request_diagnostic_file(
|
|
151
|
-
tmp_path, monkeypatch
|
|
152
|
-
) -> None:
|
|
153
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
154
|
-
monkeypatch.setenv("DEBUG", "true")
|
|
155
|
-
|
|
156
|
-
async def fake_completion(**request: object) -> dict[str, object]:
|
|
157
|
-
return {
|
|
158
|
-
"choices": [
|
|
159
|
-
{
|
|
160
|
-
"message": {
|
|
161
|
-
"content": "Here is the checklist.",
|
|
162
|
-
"role": "assistant",
|
|
163
|
-
},
|
|
164
|
-
}
|
|
165
|
-
]
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
connection = ProviderConnection(
|
|
169
|
-
name="Responses",
|
|
170
|
-
provider=ProviderFormat.OPENAI_RESPONSES,
|
|
171
|
-
model="gpt-5.1",
|
|
172
|
-
secret_reference="sk-request-secret",
|
|
173
|
-
)
|
|
174
|
-
messages = [ChatMessage(role="user", content="Create a checklist.")]
|
|
175
|
-
tools = [
|
|
176
|
-
{
|
|
177
|
-
"type": "function",
|
|
178
|
-
"function": {
|
|
179
|
-
"name": "create_checklist",
|
|
180
|
-
"description": "Create a checklist.",
|
|
181
|
-
},
|
|
182
|
-
}
|
|
183
|
-
]
|
|
184
|
-
|
|
185
|
-
await complete_chat(
|
|
186
|
-
connection,
|
|
187
|
-
messages,
|
|
188
|
-
completion=fake_completion,
|
|
189
|
-
tools=tools,
|
|
190
|
-
)
|
|
191
|
-
|
|
192
|
-
diagnostic = read_single_llm_request_diagnostic(tmp_path)
|
|
193
|
-
|
|
194
|
-
assert diagnostic == {
|
|
195
|
-
"base_url": None,
|
|
196
|
-
"litellm_model": "openai/gpt-5.1",
|
|
197
|
-
"messages": [{"content": "Create a checklist.", "role": "user"}],
|
|
198
|
-
"model": "gpt-5.1",
|
|
199
|
-
"provider": "openai_responses",
|
|
200
|
-
"reasoning_effort": "default",
|
|
201
|
-
"stream": False,
|
|
202
|
-
"tools": tools,
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
@pytest.mark.anyio
|
|
207
|
-
async def test_stream_chat_uses_litellm_streaming() -> None:
|
|
208
|
-
captured_request: dict[str, object] = {}
|
|
209
|
-
|
|
210
|
-
async def fake_completion(**request: object) -> object:
|
|
211
|
-
captured_request.update(request)
|
|
212
|
-
|
|
213
|
-
async def chunks() -> object:
|
|
214
|
-
yield {"choices": [{"delta": {"content": "Here is "}}]}
|
|
215
|
-
yield {"choices": [{"delta": {"content": "the checklist."}}]}
|
|
216
|
-
|
|
217
|
-
return chunks()
|
|
218
|
-
|
|
219
|
-
connection = ProviderConnection(
|
|
220
|
-
name="Responses",
|
|
221
|
-
provider=ProviderFormat.OPENAI_RESPONSES,
|
|
222
|
-
model="gpt-5.1",
|
|
223
|
-
secret_reference="connection-responses",
|
|
224
|
-
)
|
|
225
|
-
|
|
226
|
-
chunks = [
|
|
227
|
-
chunk
|
|
228
|
-
async for chunk in stream_chat(
|
|
229
|
-
connection,
|
|
230
|
-
[ChatMessage(role="user", content="Create a checklist.")],
|
|
231
|
-
completion=fake_completion,
|
|
232
|
-
)
|
|
233
|
-
]
|
|
234
|
-
|
|
235
|
-
assert captured_request["stream"] is True
|
|
236
|
-
assert captured_request["model"] == "openai/gpt-5.1"
|
|
237
|
-
assert chunks == ["Here is ", "the checklist."]
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
@pytest.mark.anyio
|
|
241
|
-
async def test_development_mode_writes_one_streaming_request_diagnostic_file(
|
|
242
|
-
tmp_path, monkeypatch
|
|
243
|
-
) -> None:
|
|
244
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
245
|
-
monkeypatch.setenv("DEBUG", "true")
|
|
246
|
-
|
|
247
|
-
async def fake_completion(**request: object) -> object:
|
|
248
|
-
async def chunks() -> object:
|
|
249
|
-
yield {"choices": [{"delta": {"content": "Here is "}}]}
|
|
250
|
-
yield {"choices": [{"delta": {"content": "the checklist."}}]}
|
|
251
|
-
|
|
252
|
-
return chunks()
|
|
253
|
-
|
|
254
|
-
connection = ProviderConnection(
|
|
255
|
-
name="Responses",
|
|
256
|
-
provider=ProviderFormat.OPENAI_RESPONSES,
|
|
257
|
-
model="gpt-5.1",
|
|
258
|
-
secret_reference="sk-request-secret",
|
|
259
|
-
)
|
|
260
|
-
|
|
261
|
-
chunks = [
|
|
262
|
-
chunk
|
|
263
|
-
async for chunk in stream_chat(
|
|
264
|
-
connection,
|
|
265
|
-
[ChatMessage(role="user", content="Create a checklist.")],
|
|
266
|
-
completion=fake_completion,
|
|
267
|
-
)
|
|
268
|
-
]
|
|
269
|
-
diagnostic = read_single_llm_request_diagnostic(tmp_path)
|
|
270
|
-
|
|
271
|
-
assert chunks == ["Here is ", "the checklist."]
|
|
272
|
-
assert diagnostic["stream"] is True
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
@pytest.mark.anyio
|
|
276
|
-
async def test_development_request_diagnostic_omits_api_key_and_secret_values(
|
|
277
|
-
tmp_path, monkeypatch
|
|
278
|
-
) -> None:
|
|
279
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
280
|
-
monkeypatch.setenv("DEBUG", "true")
|
|
281
|
-
|
|
282
|
-
async def fake_completion(**request: object) -> dict[str, object]:
|
|
283
|
-
return {
|
|
284
|
-
"choices": [
|
|
285
|
-
{
|
|
286
|
-
"message": {
|
|
287
|
-
"content": "Here is the checklist.",
|
|
288
|
-
"role": "assistant",
|
|
289
|
-
},
|
|
290
|
-
}
|
|
291
|
-
]
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
connection = ProviderConnection(
|
|
295
|
-
name="Responses",
|
|
296
|
-
provider=ProviderFormat.OPENAI_RESPONSES,
|
|
297
|
-
model="gpt-5.1",
|
|
298
|
-
secret_reference="sk-provider-secret",
|
|
299
|
-
)
|
|
300
|
-
tools = [
|
|
301
|
-
{
|
|
302
|
-
"type": "function",
|
|
303
|
-
"function": {
|
|
304
|
-
"name": "create_checklist",
|
|
305
|
-
"description": "Uses api_key=sk-tool-secret when configured.",
|
|
306
|
-
},
|
|
307
|
-
}
|
|
308
|
-
]
|
|
309
|
-
|
|
310
|
-
await complete_chat(
|
|
311
|
-
connection,
|
|
312
|
-
[ChatMessage(role="user", content="authorization=Bearer sk-message-secret")],
|
|
313
|
-
completion=fake_completion,
|
|
314
|
-
tools=tools,
|
|
315
|
-
)
|
|
316
|
-
|
|
317
|
-
rendered = next(
|
|
318
|
-
(tmp_path / "logs" / "llm-requests").glob("llm-request-*.json")
|
|
319
|
-
).read_text()
|
|
320
|
-
|
|
321
|
-
assert "api_key" not in rendered
|
|
322
|
-
assert "sk-provider-secret" not in rendered
|
|
323
|
-
assert "sk-tool-secret" not in rendered
|
|
324
|
-
assert "sk-message-secret" not in rendered
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
@pytest.mark.anyio
|
|
328
|
-
async def test_non_development_mode_skips_request_diagnostic_file(
|
|
329
|
-
tmp_path, monkeypatch
|
|
330
|
-
) -> None:
|
|
331
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
332
|
-
monkeypatch.delenv("DEBUG", raising=False)
|
|
333
|
-
|
|
334
|
-
async def fake_completion(**request: object) -> dict[str, object]:
|
|
335
|
-
return {
|
|
336
|
-
"choices": [
|
|
337
|
-
{
|
|
338
|
-
"message": {
|
|
339
|
-
"content": "Here is the checklist.",
|
|
340
|
-
"role": "assistant",
|
|
341
|
-
},
|
|
342
|
-
}
|
|
343
|
-
]
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
connection = ProviderConnection(
|
|
347
|
-
name="Responses",
|
|
348
|
-
provider=ProviderFormat.OPENAI_RESPONSES,
|
|
349
|
-
model="gpt-5.1",
|
|
350
|
-
secret_reference="sk-request-secret",
|
|
351
|
-
)
|
|
352
|
-
|
|
353
|
-
await complete_chat(
|
|
354
|
-
connection,
|
|
355
|
-
[ChatMessage(role="user", content="Create a checklist.")],
|
|
356
|
-
completion=fake_completion,
|
|
357
|
-
)
|
|
358
|
-
|
|
359
|
-
assert not (tmp_path / "logs" / "llm-requests").exists()
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
def test_normalize_system_messages_keeps_multiple_system_messages_for_openai() -> None:
|
|
363
|
-
messages = [
|
|
364
|
-
{"role": "system", "content": "Base prompt."},
|
|
365
|
-
{"role": "system", "content": "Configured prompt."},
|
|
366
|
-
{"role": "user", "content": "Hello."},
|
|
367
|
-
]
|
|
368
|
-
|
|
369
|
-
assert normalize_system_messages(messages, ProviderFormat.OPENAI) == messages
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
def test_normalize_system_messages_converts_additional_system_messages_for_anthropic() -> (
|
|
373
|
-
None
|
|
374
|
-
):
|
|
375
|
-
messages = [
|
|
376
|
-
{"role": "system", "content": "Base prompt."},
|
|
377
|
-
{"role": "system", "content": "Configured prompt."},
|
|
378
|
-
{"role": "system", "content": "Project prompt."},
|
|
379
|
-
{"role": "user", "content": "Hello."},
|
|
380
|
-
]
|
|
381
|
-
|
|
382
|
-
assert normalize_system_messages(messages, ProviderFormat.ANTHROPIC) == [
|
|
383
|
-
{"role": "system", "content": "Base prompt."},
|
|
384
|
-
{"role": "user", "content": "Configured prompt."},
|
|
385
|
-
{"role": "user", "content": "Project prompt."},
|
|
386
|
-
{"role": "user", "content": "Hello."},
|
|
387
|
-
]
|
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
import logging
|
|
2
|
-
import sys
|
|
3
|
-
|
|
4
|
-
from flowent.logging import (
|
|
5
|
-
LITELLM_LOGGER_NAMES,
|
|
6
|
-
TRACE_LEVEL,
|
|
7
|
-
configure_litellm_logging,
|
|
8
|
-
configure_logging,
|
|
9
|
-
ensure_logging_configured,
|
|
10
|
-
redact_log_value,
|
|
11
|
-
sanitize_diagnostic_value,
|
|
12
|
-
)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def test_logging_creates_run_file_under_data_logs(tmp_path, monkeypatch) -> None:
|
|
16
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
17
|
-
|
|
18
|
-
log_file = configure_logging()
|
|
19
|
-
|
|
20
|
-
assert log_file.parent == tmp_path / "logs"
|
|
21
|
-
assert log_file.name.startswith("flowent-")
|
|
22
|
-
assert log_file.suffix == ".log"
|
|
23
|
-
assert log_file.is_file()
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def test_file_logging_accepts_trace_and_console_uses_mode_levels(
|
|
27
|
-
tmp_path, monkeypatch
|
|
28
|
-
) -> None:
|
|
29
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
30
|
-
monkeypatch.setenv("DEBUG", "False")
|
|
31
|
-
|
|
32
|
-
log_file = configure_logging()
|
|
33
|
-
handlers = logging.getLogger().handlers
|
|
34
|
-
file_handler = next(
|
|
35
|
-
handler for handler in handlers if isinstance(handler, logging.FileHandler)
|
|
36
|
-
)
|
|
37
|
-
console_handler = next(
|
|
38
|
-
handler for handler in handlers if not isinstance(handler, logging.FileHandler)
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
assert file_handler.level == TRACE_LEVEL
|
|
42
|
-
assert console_handler.level == logging.INFO
|
|
43
|
-
|
|
44
|
-
logging.getLogger("flowent.test").log(TRACE_LEVEL, "trace-only detail")
|
|
45
|
-
for handler in logging.getLogger().handlers:
|
|
46
|
-
handler.flush()
|
|
47
|
-
|
|
48
|
-
assert "trace-only detail" in log_file.read_text()
|
|
49
|
-
|
|
50
|
-
monkeypatch.setenv("DEBUG", "True")
|
|
51
|
-
configure_logging()
|
|
52
|
-
console_handler = next(
|
|
53
|
-
handler
|
|
54
|
-
for handler in logging.getLogger().handlers
|
|
55
|
-
if not isinstance(handler, logging.FileHandler)
|
|
56
|
-
)
|
|
57
|
-
|
|
58
|
-
assert console_handler.level == logging.DEBUG
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def test_logging_prunes_old_run_logs(tmp_path, monkeypatch) -> None:
|
|
62
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
63
|
-
logs = tmp_path / "logs"
|
|
64
|
-
logs.mkdir(parents=True)
|
|
65
|
-
for index in range(6):
|
|
66
|
-
(logs / f"flowent-20260101-00000{index}-1.log").write_text(str(index))
|
|
67
|
-
|
|
68
|
-
configure_logging()
|
|
69
|
-
|
|
70
|
-
files = sorted(log.name for log in logs.glob("flowent-*.log"))
|
|
71
|
-
assert len(files) == 5
|
|
72
|
-
assert "flowent-20260101-000000-1.log" not in files
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def test_logging_path_follows_flowent_data_dir(tmp_path, monkeypatch) -> None:
|
|
76
|
-
data_dir = tmp_path / "custom-flowent"
|
|
77
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(data_dir))
|
|
78
|
-
|
|
79
|
-
log_file = configure_logging()
|
|
80
|
-
|
|
81
|
-
assert log_file.parent == data_dir / "logs"
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def test_logging_redacts_full_api_key_but_keeps_context(tmp_path, monkeypatch) -> None:
|
|
85
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
86
|
-
log_file = configure_logging()
|
|
87
|
-
|
|
88
|
-
logging.getLogger("flowent.test").log(
|
|
89
|
-
TRACE_LEVEL,
|
|
90
|
-
"provider=OpenAI model=gpt-5 output=hello api_key=sk-secret-value",
|
|
91
|
-
)
|
|
92
|
-
for handler in logging.getLogger().handlers:
|
|
93
|
-
handler.flush()
|
|
94
|
-
|
|
95
|
-
rendered = log_file.read_text()
|
|
96
|
-
|
|
97
|
-
assert "provider=OpenAI" in rendered
|
|
98
|
-
assert "model=gpt-5" in rendered
|
|
99
|
-
assert "output=hello" in rendered
|
|
100
|
-
assert "sk-secret-value" not in rendered
|
|
101
|
-
assert "api_key=[REDACTED]" in rendered
|
|
102
|
-
assert redact_log_value("authorization=Bearer sk-secret-value") == (
|
|
103
|
-
"authorization=[REDACTED]"
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def test_diagnostic_sanitizer_removes_secret_fields_and_values() -> None:
|
|
108
|
-
sanitized = sanitize_diagnostic_value(
|
|
109
|
-
{
|
|
110
|
-
"api_key": "sk-root-secret",
|
|
111
|
-
"messages": [
|
|
112
|
-
{
|
|
113
|
-
"role": "user",
|
|
114
|
-
"content": "authorization=Bearer sk-message-secret",
|
|
115
|
-
}
|
|
116
|
-
],
|
|
117
|
-
"tools": [
|
|
118
|
-
{
|
|
119
|
-
"function": {
|
|
120
|
-
"name": "send_message",
|
|
121
|
-
"description": "Needs api_key=sk-tool-secret.",
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
],
|
|
125
|
-
}
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
rendered = str(sanitized)
|
|
129
|
-
|
|
130
|
-
assert "api_key" not in rendered
|
|
131
|
-
assert "sk-root-secret" not in rendered
|
|
132
|
-
assert "sk-message-secret" not in rendered
|
|
133
|
-
assert "sk-tool-secret" not in rendered
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def test_direct_main_app_import_creates_data_log_file(tmp_path, monkeypatch) -> None:
|
|
137
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
138
|
-
sys.modules.pop("flowent.main", None)
|
|
139
|
-
|
|
140
|
-
try:
|
|
141
|
-
__import__("flowent.main")
|
|
142
|
-
finally:
|
|
143
|
-
sys.modules.pop("flowent.main", None)
|
|
144
|
-
|
|
145
|
-
files = sorted((tmp_path / "logs").glob("flowent-*.log"))
|
|
146
|
-
assert len(files) == 1
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def test_create_app_creates_data_log_file(tmp_path, monkeypatch) -> None:
|
|
150
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
151
|
-
|
|
152
|
-
from flowent.main import create_app
|
|
153
|
-
|
|
154
|
-
create_app(serve_frontend=False)
|
|
155
|
-
|
|
156
|
-
files = sorted((tmp_path / "logs").glob("flowent-*.log"))
|
|
157
|
-
assert len(files) == 1
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def test_create_app_reuses_logging_handlers(tmp_path, monkeypatch) -> None:
|
|
161
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
162
|
-
|
|
163
|
-
from flowent.main import create_app
|
|
164
|
-
|
|
165
|
-
create_app(serve_frontend=False)
|
|
166
|
-
create_app(serve_frontend=False)
|
|
167
|
-
|
|
168
|
-
handlers = logging.getLogger().handlers
|
|
169
|
-
file_handlers = [
|
|
170
|
-
handler for handler in handlers if isinstance(handler, logging.FileHandler)
|
|
171
|
-
]
|
|
172
|
-
console_handlers = [
|
|
173
|
-
handler for handler in handlers if not isinstance(handler, logging.FileHandler)
|
|
174
|
-
]
|
|
175
|
-
files = sorted((tmp_path / "logs").glob("flowent-*.log"))
|
|
176
|
-
|
|
177
|
-
assert len(file_handlers) == 1
|
|
178
|
-
assert len(console_handlers) == 1
|
|
179
|
-
assert len(files) == 1
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
def test_any_logging_path_follows_flowent_data_dir(tmp_path, monkeypatch) -> None:
|
|
183
|
-
data_dir = tmp_path / "custom-flowent"
|
|
184
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(data_dir))
|
|
185
|
-
|
|
186
|
-
log_file = ensure_logging_configured()
|
|
187
|
-
|
|
188
|
-
assert log_file.parent == data_dir / "logs"
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def test_litellm_debug_logs_use_flowent_handlers(tmp_path, monkeypatch, capsys) -> None:
|
|
192
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
193
|
-
monkeypatch.delenv("LITELLM_LOG", raising=False)
|
|
194
|
-
|
|
195
|
-
log_file = configure_logging()
|
|
196
|
-
import litellm # noqa: F401
|
|
197
|
-
|
|
198
|
-
configure_litellm_logging()
|
|
199
|
-
logging.getLogger("LiteLLM").debug("stream chunk detail")
|
|
200
|
-
for handler in logging.getLogger().handlers:
|
|
201
|
-
handler.flush()
|
|
202
|
-
|
|
203
|
-
captured = capsys.readouterr()
|
|
204
|
-
litellm_handlers = [
|
|
205
|
-
handler
|
|
206
|
-
for logger_name in LITELLM_LOGGER_NAMES
|
|
207
|
-
for handler in logging.getLogger(logger_name).handlers
|
|
208
|
-
]
|
|
209
|
-
|
|
210
|
-
assert litellm_handlers == []
|
|
211
|
-
assert "stream chunk detail" not in captured.err
|
|
212
|
-
assert "stream chunk detail" in log_file.read_text()
|