flowent 0.3.0 → 0.3.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 +1 -1
- package/backend/src/flowent/api_models.py +13 -8
- package/backend/src/flowent/llm.py +50 -6
- package/backend/src/flowent/routes/providers.py +33 -10
- package/backend/src/flowent/routes/system.py +5 -6
- package/backend/src/flowent/routes/workspace.py +33 -23
- package/backend/src/flowent/state/models.py +3 -2
- package/backend/src/flowent/state/schema.py +5 -0
- package/backend/src/flowent/state/store.py +9 -3
- package/backend/src/flowent/static/assets/index-BaZmIi2Y.js +98 -0
- package/backend/src/flowent/static/assets/index-EC37agAH.css +2 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/workspace/context.py +128 -42
- package/backend/src/flowent/workspace/events.py +5 -7
- package/backend/src/flowent/workspace/output.py +125 -3
- package/backend/src/flowent/workspace/runtime.py +299 -180
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/index-BaZmIi2Y.js +98 -0
- package/dist/frontend/assets/index-EC37agAH.css +2 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +1 -1
- package/backend/src/flowent/static/assets/index-CvWZZMtK.css +0 -2
- package/backend/src/flowent/static/assets/index-ma2v8oW7.js +0 -90
- package/dist/frontend/assets/index-CvWZZMtK.css +0 -2
- package/dist/frontend/assets/index-ma2v8oW7.js +0 -90
package/backend/pyproject.toml
CHANGED
|
@@ -19,6 +19,18 @@ class ProviderModelsResponse(BaseModel):
|
|
|
19
19
|
models: list[str]
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
class ProviderModelsFailureResponse(BaseModel):
|
|
23
|
+
model_config = ConfigDict(extra="forbid")
|
|
24
|
+
|
|
25
|
+
code: Literal[
|
|
26
|
+
"connection_failed",
|
|
27
|
+
"access_denied",
|
|
28
|
+
"rate_limited",
|
|
29
|
+
"provider_unavailable",
|
|
30
|
+
"request_failed",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
22
34
|
class WorkspaceMessagesRequest(BaseModel):
|
|
23
35
|
model_config = ConfigDict(extra="forbid")
|
|
24
36
|
|
|
@@ -35,8 +47,8 @@ class WorkspaceMessageEditRequest(BaseModel):
|
|
|
35
47
|
class WorkspaceMessageEditResponse(BaseModel):
|
|
36
48
|
model_config = ConfigDict(extra="forbid")
|
|
37
49
|
|
|
50
|
+
is_responding: bool = False
|
|
38
51
|
messages: list[StoredMessage]
|
|
39
|
-
run_id: str | None = None
|
|
40
52
|
|
|
41
53
|
|
|
42
54
|
class WorkspaceRespondRequest(BaseModel):
|
|
@@ -46,16 +58,9 @@ class WorkspaceRespondRequest(BaseModel):
|
|
|
46
58
|
message_id: str | None = None
|
|
47
59
|
|
|
48
60
|
|
|
49
|
-
class WorkspaceRunResponse(BaseModel):
|
|
50
|
-
model_config = ConfigDict(extra="forbid")
|
|
51
|
-
|
|
52
|
-
run_id: str
|
|
53
|
-
|
|
54
|
-
|
|
55
61
|
class WorkspaceClearResponse(BaseModel):
|
|
56
62
|
model_config = ConfigDict(extra="forbid")
|
|
57
63
|
|
|
58
|
-
active_run_id: str | None = None
|
|
59
64
|
messages: list[StoredMessage]
|
|
60
65
|
usage_info: TokenUsageInfo | None = None
|
|
61
66
|
|
|
@@ -85,6 +85,20 @@ class LLMStreamError(RuntimeError):
|
|
|
85
85
|
pass
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
class ProviderModelFetchFailure(StrEnum):
|
|
89
|
+
CONNECTION_FAILED = "connection_failed"
|
|
90
|
+
ACCESS_DENIED = "access_denied"
|
|
91
|
+
RATE_LIMITED = "rate_limited"
|
|
92
|
+
PROVIDER_UNAVAILABLE = "provider_unavailable"
|
|
93
|
+
REQUEST_FAILED = "request_failed"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ProviderModelFetchError(RuntimeError):
|
|
97
|
+
def __init__(self, failure: ProviderModelFetchFailure) -> None:
|
|
98
|
+
super().__init__(failure.value)
|
|
99
|
+
self.failure = failure
|
|
100
|
+
|
|
101
|
+
|
|
88
102
|
async def wait_before_llm_retry(attempt_number: int) -> None:
|
|
89
103
|
await asyncio.sleep(LLM_RETRY_BASE_DELAY_SECONDS * attempt_number)
|
|
90
104
|
|
|
@@ -248,6 +262,31 @@ def unique_model_names(provider: ProviderFormat, models: Sequence[str]) -> list[
|
|
|
248
262
|
return normalized_models
|
|
249
263
|
|
|
250
264
|
|
|
265
|
+
def classify_provider_model_fetch_failure(
|
|
266
|
+
exception: Exception,
|
|
267
|
+
) -> ProviderModelFetchFailure:
|
|
268
|
+
try:
|
|
269
|
+
import litellm
|
|
270
|
+
except Exception:
|
|
271
|
+
return ProviderModelFetchFailure.REQUEST_FAILED
|
|
272
|
+
|
|
273
|
+
if isinstance(exception, (litellm.APIConnectionError, litellm.Timeout)):
|
|
274
|
+
return ProviderModelFetchFailure.CONNECTION_FAILED
|
|
275
|
+
if isinstance(
|
|
276
|
+
exception, (litellm.AuthenticationError, litellm.PermissionDeniedError)
|
|
277
|
+
):
|
|
278
|
+
return ProviderModelFetchFailure.ACCESS_DENIED
|
|
279
|
+
if isinstance(exception, litellm.RateLimitError):
|
|
280
|
+
return ProviderModelFetchFailure.RATE_LIMITED
|
|
281
|
+
if isinstance(
|
|
282
|
+
exception, (litellm.ServiceUnavailableError, litellm.InternalServerError)
|
|
283
|
+
):
|
|
284
|
+
return ProviderModelFetchFailure.PROVIDER_UNAVAILABLE
|
|
285
|
+
if isinstance(exception, (ConnectionError, TimeoutError)):
|
|
286
|
+
return ProviderModelFetchFailure.CONNECTION_FAILED
|
|
287
|
+
return ProviderModelFetchFailure.REQUEST_FAILED
|
|
288
|
+
|
|
289
|
+
|
|
251
290
|
def list_provider_models(
|
|
252
291
|
*,
|
|
253
292
|
provider: ProviderFormat,
|
|
@@ -261,12 +300,17 @@ def list_provider_models(
|
|
|
261
300
|
configure_litellm_logging()
|
|
262
301
|
model_lister = get_valid_models
|
|
263
302
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
303
|
+
try:
|
|
304
|
+
models = model_lister(
|
|
305
|
+
api_base=normalize_provider_base_url(provider, base_url),
|
|
306
|
+
api_key=secret_reference,
|
|
307
|
+
check_provider_endpoint=True,
|
|
308
|
+
custom_llm_provider=provider_litellm_name(provider),
|
|
309
|
+
)
|
|
310
|
+
except Exception as exc:
|
|
311
|
+
raise ProviderModelFetchError(
|
|
312
|
+
classify_provider_model_fetch_failure(exc)
|
|
313
|
+
) from exc
|
|
270
314
|
return unique_model_names(provider, models)
|
|
271
315
|
|
|
272
316
|
|
|
@@ -1,9 +1,25 @@
|
|
|
1
|
-
from fastapi import FastAPI
|
|
1
|
+
from fastapi import FastAPI, HTTPException, status
|
|
2
2
|
|
|
3
|
-
from flowent.api_models import
|
|
4
|
-
|
|
3
|
+
from flowent.api_models import (
|
|
4
|
+
ProviderModelsFailureResponse,
|
|
5
|
+
ProviderModelsRequest,
|
|
6
|
+
ProviderModelsResponse,
|
|
7
|
+
)
|
|
8
|
+
from flowent.llm import (
|
|
9
|
+
ProviderModelFetchError,
|
|
10
|
+
ProviderModelFetchFailure,
|
|
11
|
+
list_provider_models,
|
|
12
|
+
)
|
|
5
13
|
from flowent.storage import StateStore, StoredProvider, StoredSettings
|
|
6
14
|
|
|
15
|
+
PROVIDER_MODEL_FAILURE_STATUS: dict[ProviderModelFetchFailure, int] = {
|
|
16
|
+
ProviderModelFetchFailure.CONNECTION_FAILED: status.HTTP_502_BAD_GATEWAY,
|
|
17
|
+
ProviderModelFetchFailure.ACCESS_DENIED: status.HTTP_403_FORBIDDEN,
|
|
18
|
+
ProviderModelFetchFailure.RATE_LIMITED: status.HTTP_429_TOO_MANY_REQUESTS,
|
|
19
|
+
ProviderModelFetchFailure.PROVIDER_UNAVAILABLE: status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
20
|
+
ProviderModelFetchFailure.REQUEST_FAILED: status.HTTP_400_BAD_REQUEST,
|
|
21
|
+
}
|
|
22
|
+
|
|
7
23
|
|
|
8
24
|
def register_provider_routes(app: FastAPI, *, store: StateStore) -> None:
|
|
9
25
|
@app.post("/api/providers")
|
|
@@ -17,13 +33,20 @@ def register_provider_routes(app: FastAPI, *, store: StateStore) -> None:
|
|
|
17
33
|
|
|
18
34
|
@app.post("/api/providers/models")
|
|
19
35
|
async def provider_models(request: ProviderModelsRequest) -> ProviderModelsResponse:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
36
|
+
try:
|
|
37
|
+
return ProviderModelsResponse(
|
|
38
|
+
models=list_provider_models(
|
|
39
|
+
base_url=request.base_url,
|
|
40
|
+
provider=request.provider,
|
|
41
|
+
secret_reference=request.secret_reference,
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
except ProviderModelFetchError as exc:
|
|
45
|
+
detail = ProviderModelsFailureResponse(code=exc.failure.value)
|
|
46
|
+
raise HTTPException(
|
|
47
|
+
detail=detail.model_dump(),
|
|
48
|
+
status_code=PROVIDER_MODEL_FAILURE_STATUS[exc.failure],
|
|
49
|
+
) from exc
|
|
27
50
|
|
|
28
51
|
@app.put("/api/settings")
|
|
29
52
|
async def save_settings(settings: StoredSettings) -> StoredSettings:
|
|
@@ -28,14 +28,13 @@ def register_system_routes(
|
|
|
28
28
|
@app.get("/api/state")
|
|
29
29
|
async def app_state() -> StoredState:
|
|
30
30
|
state = state_with_current_model_context_window(store.read_state())
|
|
31
|
-
|
|
31
|
+
active_response = runtime.current_response()
|
|
32
32
|
update: dict[str, object] = {
|
|
33
|
-
"
|
|
34
|
-
|
|
33
|
+
"is_responding": active_response is not None
|
|
34
|
+
and not active_response.is_done,
|
|
35
|
+
"response_event_index": active_response.latest_event_index
|
|
36
|
+
if active_response
|
|
35
37
|
else 0,
|
|
36
|
-
"active_run_id": active_run.id
|
|
37
|
-
if active_run and not active_run.is_done
|
|
38
|
-
else None,
|
|
39
38
|
"mcp_servers": mcp_manager.servers_with_status(state.mcp_servers),
|
|
40
39
|
"skills": discover_skills(cwd, store),
|
|
41
40
|
}
|
|
@@ -9,7 +9,6 @@ from flowent.api_models import (
|
|
|
9
9
|
WorkspaceMessageEditResponse,
|
|
10
10
|
WorkspaceMessagesRequest,
|
|
11
11
|
WorkspaceRespondRequest,
|
|
12
|
-
WorkspaceRunResponse,
|
|
13
12
|
)
|
|
14
13
|
from flowent.logging import TRACE_LEVEL
|
|
15
14
|
from flowent.storage import StateStore
|
|
@@ -42,45 +41,54 @@ def register_workspace_routes(
|
|
|
42
41
|
len(request.content),
|
|
43
42
|
)
|
|
44
43
|
logger.log(TRACE_LEVEL, "Workspace edited user content=%r", request.content)
|
|
45
|
-
messages,
|
|
44
|
+
messages, response = runtime.edit_message(
|
|
46
45
|
message_id,
|
|
47
46
|
action=request.action,
|
|
48
47
|
content=request.content,
|
|
49
48
|
)
|
|
50
49
|
return WorkspaceMessageEditResponse(
|
|
50
|
+
is_responding=response is not None,
|
|
51
|
+
messages=messages,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@app.post("/api/workspace/messages/{message_id}/errors/{error_id}/retry")
|
|
55
|
+
async def retry_workspace_error(
|
|
56
|
+
message_id: str,
|
|
57
|
+
error_id: str,
|
|
58
|
+
) -> WorkspaceMessageEditResponse:
|
|
59
|
+
logger.info(
|
|
60
|
+
"Workspace error retry requested message_id=%s error_id=%s",
|
|
61
|
+
message_id,
|
|
62
|
+
error_id,
|
|
63
|
+
)
|
|
64
|
+
messages, response = runtime.retry_error(
|
|
65
|
+
message_id,
|
|
66
|
+
error_id=error_id,
|
|
67
|
+
)
|
|
68
|
+
return WorkspaceMessageEditResponse(
|
|
69
|
+
is_responding=response is not None,
|
|
51
70
|
messages=messages,
|
|
52
|
-
run_id=run.id if run else None,
|
|
53
71
|
)
|
|
54
72
|
|
|
55
73
|
@app.post("/api/workspace/clear")
|
|
56
74
|
async def clear_workspace() -> WorkspaceClearResponse:
|
|
57
75
|
messages = runtime.clear()
|
|
58
|
-
await runtime.
|
|
76
|
+
await runtime.notify_cleared_response()
|
|
59
77
|
return WorkspaceClearResponse(messages=messages)
|
|
60
78
|
|
|
61
|
-
@app.
|
|
62
|
-
async def
|
|
63
|
-
request: WorkspaceRespondRequest,
|
|
64
|
-
) -> WorkspaceRunResponse:
|
|
65
|
-
logger.info("Workspace run requested content_length=%s", len(request.content))
|
|
66
|
-
logger.log(TRACE_LEVEL, "Workspace user content=%r", request.content)
|
|
67
|
-
run = runtime.create_run(request.content, message_id=request.message_id)
|
|
68
|
-
return WorkspaceRunResponse(run_id=run.id)
|
|
69
|
-
|
|
70
|
-
@app.get("/api/workspace/runs/{run_id}/stream")
|
|
71
|
-
async def stream_workspace_run(
|
|
72
|
-
run_id: str,
|
|
79
|
+
@app.get("/api/workspace/stream")
|
|
80
|
+
async def stream_workspace_response(
|
|
73
81
|
after: int = Query(default=0, ge=0),
|
|
74
82
|
) -> StreamingResponse:
|
|
75
|
-
|
|
83
|
+
response = runtime.stream_current_response()
|
|
76
84
|
return StreamingResponse(
|
|
77
|
-
runtime.
|
|
85
|
+
runtime.response_stream(response, after),
|
|
78
86
|
media_type="text/event-stream",
|
|
79
87
|
)
|
|
80
88
|
|
|
81
|
-
@app.post("/api/workspace/
|
|
82
|
-
async def
|
|
83
|
-
runtime.
|
|
89
|
+
@app.post("/api/workspace/stop")
|
|
90
|
+
async def stop_workspace_response() -> dict[str, bool]:
|
|
91
|
+
runtime.stop_response()
|
|
84
92
|
return {"ok": True}
|
|
85
93
|
|
|
86
94
|
@app.post("/api/workspace/compact", response_class=StreamingResponse)
|
|
@@ -98,8 +106,10 @@ def register_workspace_routes(
|
|
|
98
106
|
"Workspace response requested content_length=%s", len(request.content)
|
|
99
107
|
)
|
|
100
108
|
logger.log(TRACE_LEVEL, "Workspace user content=%r", request.content)
|
|
101
|
-
|
|
109
|
+
response = runtime.start_response(
|
|
110
|
+
request.content, message_id=request.message_id
|
|
111
|
+
)
|
|
102
112
|
return StreamingResponse(
|
|
103
|
-
runtime.
|
|
113
|
+
runtime.response_stream(response, include_snapshots=False),
|
|
104
114
|
media_type="text/event-stream",
|
|
105
115
|
)
|
|
@@ -217,6 +217,7 @@ class StoredMessage(BaseModel):
|
|
|
217
217
|
status: str = Field(
|
|
218
218
|
default="completed", exclude_if=lambda value: value == "completed"
|
|
219
219
|
)
|
|
220
|
+
summary: str = Field(default="", exclude_if=lambda value: value == "")
|
|
220
221
|
thinking: str = Field(default="", exclude_if=lambda value: value == "")
|
|
221
222
|
tools: list[StoredToolItem] = Field(default_factory=list)
|
|
222
223
|
usage_info: TokenUsageInfo | None = Field(
|
|
@@ -241,8 +242,8 @@ class StoredCompactionCheckpoint(BaseModel):
|
|
|
241
242
|
class StoredState(BaseModel):
|
|
242
243
|
model_config = ConfigDict(extra="forbid")
|
|
243
244
|
|
|
244
|
-
|
|
245
|
-
|
|
245
|
+
is_responding: bool = False
|
|
246
|
+
response_event_index: int = 0
|
|
246
247
|
is_compacting: bool = False
|
|
247
248
|
mcp_servers: list[StoredMcpServer]
|
|
248
249
|
messages: list[StoredMessage]
|
|
@@ -83,6 +83,7 @@ def migrate(connection: sqlite3.Connection) -> None:
|
|
|
83
83
|
id TEXT PRIMARY KEY,
|
|
84
84
|
author TEXT NOT NULL,
|
|
85
85
|
content TEXT NOT NULL,
|
|
86
|
+
summary TEXT NOT NULL DEFAULT '',
|
|
86
87
|
status TEXT NOT NULL DEFAULT 'completed',
|
|
87
88
|
usage_info TEXT,
|
|
88
89
|
position INTEGER NOT NULL
|
|
@@ -154,6 +155,10 @@ def migrate(connection: sqlite3.Connection) -> None:
|
|
|
154
155
|
)
|
|
155
156
|
if "usage_info" not in message_columns:
|
|
156
157
|
connection.execute("ALTER TABLE messages ADD COLUMN usage_info TEXT")
|
|
158
|
+
if "summary" not in message_columns:
|
|
159
|
+
connection.execute(
|
|
160
|
+
"ALTER TABLE messages ADD COLUMN summary TEXT NOT NULL DEFAULT ''"
|
|
161
|
+
)
|
|
157
162
|
settings_columns = table_columns(connection, "settings")
|
|
158
163
|
if "reasoning_effort" not in settings_columns:
|
|
159
164
|
connection.execute(
|
|
@@ -79,6 +79,7 @@ class StateStore:
|
|
|
79
79
|
],
|
|
80
80
|
id=row["id"],
|
|
81
81
|
status=row["status"],
|
|
82
|
+
summary=row["summary"],
|
|
82
83
|
thinking=row["thinking"],
|
|
83
84
|
tools=[
|
|
84
85
|
StoredToolItem.model_validate(tool)
|
|
@@ -90,7 +91,7 @@ class StateStore:
|
|
|
90
91
|
)
|
|
91
92
|
for row in connection.execute(
|
|
92
93
|
"""
|
|
93
|
-
SELECT id, author, content, tools, thinking, groups, status, usage_info
|
|
94
|
+
SELECT id, author, content, summary, tools, thinking, groups, status, usage_info
|
|
94
95
|
FROM messages
|
|
95
96
|
ORDER BY position, id
|
|
96
97
|
"""
|
|
@@ -576,6 +577,7 @@ class StateStore:
|
|
|
576
577
|
id,
|
|
577
578
|
author,
|
|
578
579
|
content,
|
|
580
|
+
summary,
|
|
579
581
|
tools,
|
|
580
582
|
thinking,
|
|
581
583
|
groups,
|
|
@@ -583,13 +585,14 @@ class StateStore:
|
|
|
583
585
|
usage_info,
|
|
584
586
|
position
|
|
585
587
|
)
|
|
586
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
588
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
587
589
|
""",
|
|
588
590
|
[
|
|
589
591
|
(
|
|
590
592
|
message.id,
|
|
591
593
|
message.author,
|
|
592
594
|
message.content,
|
|
595
|
+
message.summary,
|
|
593
596
|
json.dumps(
|
|
594
597
|
[
|
|
595
598
|
tool.model_dump(exclude_none=True)
|
|
@@ -635,6 +638,7 @@ class StateStore:
|
|
|
635
638
|
id,
|
|
636
639
|
author,
|
|
637
640
|
content,
|
|
641
|
+
summary,
|
|
638
642
|
tools,
|
|
639
643
|
thinking,
|
|
640
644
|
groups,
|
|
@@ -642,10 +646,11 @@ class StateStore:
|
|
|
642
646
|
usage_info,
|
|
643
647
|
position
|
|
644
648
|
)
|
|
645
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
649
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
646
650
|
ON CONFLICT(id) DO UPDATE SET
|
|
647
651
|
author = excluded.author,
|
|
648
652
|
content = excluded.content,
|
|
653
|
+
summary = excluded.summary,
|
|
649
654
|
tools = excluded.tools,
|
|
650
655
|
thinking = excluded.thinking,
|
|
651
656
|
groups = excluded.groups,
|
|
@@ -657,6 +662,7 @@ class StateStore:
|
|
|
657
662
|
message.id,
|
|
658
663
|
message.author,
|
|
659
664
|
message.content,
|
|
665
|
+
message.summary,
|
|
660
666
|
json.dumps(
|
|
661
667
|
[tool.model_dump(exclude_none=True) for tool in message.tools]
|
|
662
668
|
),
|