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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flowent"
3
- version = "0.3.0"
3
+ version = "0.3.1"
4
4
  description = "A workflow orchestration platform for multi-agent collaboration"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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
- models = model_lister(
265
- api_base=normalize_provider_base_url(provider, base_url),
266
- api_key=secret_reference,
267
- check_provider_endpoint=True,
268
- custom_llm_provider=provider_litellm_name(provider),
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 ProviderModelsRequest, ProviderModelsResponse
4
- from flowent.llm import list_provider_models
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
- return ProviderModelsResponse(
21
- models=list_provider_models(
22
- base_url=request.base_url,
23
- provider=request.provider,
24
- secret_reference=request.secret_reference,
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
- active_run = runtime.active_run()
31
+ active_response = runtime.current_response()
32
32
  update: dict[str, object] = {
33
- "active_run_event_index": active_run.latest_event_index
34
- if active_run
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, run = runtime.edit_message(
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.notify_cleared_runs()
76
+ await runtime.notify_cleared_response()
59
77
  return WorkspaceClearResponse(messages=messages)
60
78
 
61
- @app.post("/api/workspace/runs")
62
- async def start_workspace_run(
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
- run = runtime.run_by_id(run_id)
83
+ response = runtime.stream_current_response()
76
84
  return StreamingResponse(
77
- runtime.run_stream(run, after),
85
+ runtime.response_stream(response, after),
78
86
  media_type="text/event-stream",
79
87
  )
80
88
 
81
- @app.post("/api/workspace/runs/{run_id}/stop")
82
- async def stop_workspace_run(run_id: str) -> dict[str, bool]:
83
- runtime.stop_run(run_id)
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
- run = runtime.create_run(request.content, message_id=request.message_id)
109
+ response = runtime.start_response(
110
+ request.content, message_id=request.message_id
111
+ )
102
112
  return StreamingResponse(
103
- runtime.run_stream(run, include_snapshots=False),
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
- active_run_event_index: int = 0
245
- active_run_id: str | None = None
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
  ),