flowai-cli 0.2.0__py3-none-any.whl

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 (56) hide show
  1. flowai/__init__.py +72 -0
  2. flowai/__main__.py +8 -0
  3. flowai/agents/__init__.py +21 -0
  4. flowai/agents/base.py +61 -0
  5. flowai/agents/patterns/__init__.py +11 -0
  6. flowai/agents/patterns/debate.py +79 -0
  7. flowai/agents/patterns/reflection.py +74 -0
  8. flowai/agents/specialized.py +87 -0
  9. flowai/agents/standard.py +176 -0
  10. flowai/cli/__init__.py +5 -0
  11. flowai/cli/main.py +562 -0
  12. flowai/cli/ui.py +98 -0
  13. flowai/config/__init__.py +5 -0
  14. flowai/config/settings.py +204 -0
  15. flowai/errors.py +30 -0
  16. flowai/mcp/__init__.py +5 -0
  17. flowai/mcp/client.py +77 -0
  18. flowai/memory/__init__.py +16 -0
  19. flowai/memory/base.py +45 -0
  20. flowai/memory/chroma.py +97 -0
  21. flowai/memory/in_memory.py +65 -0
  22. flowai/memory/qdrant.py +104 -0
  23. flowai/observability/__init__.py +15 -0
  24. flowai/observability/events.py +45 -0
  25. flowai/observability/opentelemetry.py +56 -0
  26. flowai/observability/tracer.py +80 -0
  27. flowai/plugins/__init__.py +5 -0
  28. flowai/plugins/loader.py +31 -0
  29. flowai/providers/__init__.py +35 -0
  30. flowai/providers/anthropic.py +268 -0
  31. flowai/providers/base.py +180 -0
  32. flowai/providers/gemini.py +230 -0
  33. flowai/providers/nvidia.py +57 -0
  34. flowai/providers/ollama.py +237 -0
  35. flowai/providers/openai.py +274 -0
  36. flowai/providers/registry.py +50 -0
  37. flowai/runtime/__init__.py +5 -0
  38. flowai/runtime/session.py +76 -0
  39. flowai/server/__init__.py +5 -0
  40. flowai/server/app.py +133 -0
  41. flowai/tools/__init__.py +39 -0
  42. flowai/tools/base.py +56 -0
  43. flowai/tools/builtin.py +128 -0
  44. flowai/tools/decorator.py +103 -0
  45. flowai/tools/files.py +197 -0
  46. flowai/tools/registry.py +84 -0
  47. flowai/tools/web.py +94 -0
  48. flowai/workflow/__init__.py +15 -0
  49. flowai/workflow/checkpoint.py +73 -0
  50. flowai/workflow/engine.py +144 -0
  51. flowai/workflow/state.py +43 -0
  52. flowai_cli-0.2.0.dist-info/METADATA +223 -0
  53. flowai_cli-0.2.0.dist-info/RECORD +56 -0
  54. flowai_cli-0.2.0.dist-info/WHEEL +4 -0
  55. flowai_cli-0.2.0.dist-info/entry_points.txt +2 -0
  56. flowai_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,45 @@
1
+ """Event Bus and Streaming Event definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Callable, Coroutine
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class StreamEvent(BaseModel):
13
+ """Event emitted during execution for observers, webhooks, or SSE."""
14
+ event: str # e.g., "token", "tool_start", "tool_end", "agent_start", "error"
15
+ data: dict[str, Any] = Field(default_factory=dict)
16
+
17
+
18
+ EventHandler = Callable[[StreamEvent], Coroutine[Any, Any, None] | None]
19
+
20
+
21
+ class EventBus:
22
+ """Asynchronous publish-subscribe event bus for real-time telemetry."""
23
+
24
+ def __init__(self):
25
+ self._handlers: list[EventHandler] = []
26
+
27
+ def subscribe(self, handler: EventHandler) -> None:
28
+ """Register an observer handler."""
29
+ self._handlers.append(handler)
30
+
31
+ def unsubscribe(self, handler: EventHandler) -> None:
32
+ """Remove a previously registered handler."""
33
+ if handler in self._handlers:
34
+ self._handlers.remove(handler)
35
+
36
+ async def publish(self, event_name: str, data: dict[str, Any] | None = None) -> None:
37
+ """Broadcast an event to all subscribers."""
38
+ ev = StreamEvent(event=event_name, data=data or {})
39
+ for handler in self._handlers:
40
+ try:
41
+ res = handler(ev)
42
+ if asyncio.iscoroutine(res):
43
+ await res
44
+ except Exception:
45
+ pass # Observers should never crash the core loop
@@ -0,0 +1,56 @@
1
+ """OpenTelemetry and LangSmith integration adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+
8
+ from flowai.observability.tracer import Span
9
+
10
+
11
+ class OpenTelemetryExporter:
12
+ """Exports FlowAI spans to standard OpenTelemetry collector / APM."""
13
+
14
+ def __init__(self, service_name: str = "flowai-agent", endpoint: str | None = None):
15
+ self.service_name = service_name
16
+ self.endpoint = endpoint or os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
17
+ self._tracer = None
18
+
19
+ def export(self, span: Span) -> None:
20
+ """Export completed span to OTel."""
21
+ try:
22
+ from opentelemetry import trace
23
+ tracer = trace.get_tracer("flowai", "0.1.0")
24
+ with tracer.start_as_current_span(span.name) as otel_span:
25
+ otel_span.set_attribute("gen_ai.usage.prompt_tokens", span.prompt_tokens)
26
+ otel_span.set_attribute("gen_ai.usage.completion_tokens", span.completion_tokens)
27
+ otel_span.set_attribute("gen_ai.usage.cost_usd", span.cost_usd)
28
+ for k, v in span.attributes.items():
29
+ otel_span.set_attribute(f"flowai.{k}", str(v))
30
+ except ImportError:
31
+ pass
32
+
33
+
34
+ class LangSmithExporter:
35
+ """Exports agent traces to LangSmith platform."""
36
+
37
+ def __init__(self, project_name: str | None = None):
38
+ self.project_name = project_name or os.getenv("LANGCHAIN_PROJECT", "flowai-default")
39
+ self.api_key = os.getenv("LANGCHAIN_API_KEY")
40
+
41
+ def export_run(self, name: str, run_type: str, inputs: dict[str, Any], outputs: dict[str, Any], **kwargs: Any) -> None:
42
+ if not self.api_key:
43
+ return
44
+ try:
45
+ from langsmith import Client
46
+ client = Client(api_key=self.api_key)
47
+ client.create_run(
48
+ name=name,
49
+ run_type=run_type,
50
+ inputs=inputs,
51
+ outputs=outputs,
52
+ project_name=self.project_name,
53
+ extra=kwargs,
54
+ )
55
+ except Exception:
56
+ pass
@@ -0,0 +1,80 @@
1
+ """Execution Tracer with Token Usage, Cost, and Latency tracking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+ # Pricing per million tokens (estimates in USD)
12
+ MODEL_PRICING: dict[str, dict[str, float]] = {
13
+ "gpt-4o": {"input": 2.50, "output": 10.00},
14
+ "gpt-4o-mini": {"input": 0.15, "output": 0.60},
15
+ "claude-3-5-sonnet-20241022": {"input": 3.00, "output": 15.00},
16
+ "meta/llama-3.1-70b-instruct": {"input": 0.90, "output": 0.90},
17
+ "deepseek-ai/deepseek-r1": {"input": 0.55, "output": 2.19},
18
+ "gemini-2.0-flash": {"input": 0.10, "output": 0.40},
19
+ }
20
+
21
+ # Used when a model is not in the table above: a visible estimate, not a quote.
22
+ DEFAULT_PRICING = {"input": 1.0, "output": 2.0}
23
+
24
+
25
+ class Span(BaseModel):
26
+ """Represents a single traced execution operation."""
27
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()))
28
+ name: str
29
+ start_time: float = Field(default_factory=time.time)
30
+ end_time: float | None = None
31
+ duration_ms: float = 0.0
32
+ attributes: dict[str, Any] = Field(default_factory=dict)
33
+ prompt_tokens: int = 0
34
+ completion_tokens: int = 0
35
+ total_tokens: int = 0
36
+ cost_usd: float = 0.0
37
+
38
+ def finish(self) -> None:
39
+ self.end_time = time.time()
40
+ self.duration_ms = (self.end_time - self.start_time) * 1000.0
41
+
42
+
43
+ class Tracer:
44
+ """Manages spans and calculates total resource spend."""
45
+
46
+ def __init__(self):
47
+ self.spans: list[Span] = []
48
+
49
+ def start_span(self, name: str, **attributes: Any) -> Span:
50
+ span = Span(name=name, attributes=attributes)
51
+ self.spans.append(span)
52
+ return span
53
+
54
+ def record_usage(self, span: Span, model: str, prompt_tokens: int, completion_tokens: int) -> None:
55
+ """Add one model call's usage to a span.
56
+
57
+ Usage accumulates: an agent turn that calls tools makes several model
58
+ calls inside a single span, and all of them cost money.
59
+ """
60
+ span.prompt_tokens += prompt_tokens
61
+ span.completion_tokens += completion_tokens
62
+ span.total_tokens = span.prompt_tokens + span.completion_tokens
63
+
64
+ pricing = MODEL_PRICING.get(model, DEFAULT_PRICING)
65
+ cost = (prompt_tokens * pricing["input"] + completion_tokens * pricing["output"]) / 1_000_000.0
66
+ span.cost_usd = round(span.cost_usd + cost, 6)
67
+
68
+ def total_tokens(self) -> int:
69
+ return sum(s.total_tokens for s in self.spans)
70
+
71
+ def total_cost(self) -> float:
72
+ return sum(s.cost_usd for s in self.spans)
73
+
74
+ def summary(self) -> dict[str, Any]:
75
+ return {
76
+ "total_spans": len(self.spans),
77
+ "total_tokens": self.total_tokens(),
78
+ "total_cost_usd": self.total_cost(),
79
+ "spans": [s.model_dump() for s in self.spans],
80
+ }
@@ -0,0 +1,5 @@
1
+ """FlowAI Plugin Architecture."""
2
+
3
+ from flowai.plugins.loader import PluginLoader
4
+
5
+ __all__ = ["PluginLoader"]
@@ -0,0 +1,31 @@
1
+ """FlowAI Plugin interfaces and dynamic entry_points loader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+
7
+ from flowai.providers.base import BaseProvider
8
+ from flowai.providers.registry import ProviderRegistry
9
+
10
+
11
+ class PluginLoader:
12
+ """Discovers and registers external plugins via Python entry points."""
13
+
14
+ @staticmethod
15
+ def load_all() -> dict[str, list[str]]:
16
+ loaded = {"providers": [], "tools": [], "memory": []}
17
+
18
+ # Discover entry_points in 'flowai.plugins'
19
+ try:
20
+ entry_points = importlib.metadata.entry_points()
21
+ flowai_eps = entry_points.select(group="flowai.plugins") if hasattr(entry_points, "select") else entry_points.get("flowai.plugins", [])
22
+ for ep in flowai_eps:
23
+ plugin_obj = ep.load()
24
+ name = ep.name
25
+ if issubclass(plugin_obj, BaseProvider):
26
+ ProviderRegistry.register(name, plugin_obj)
27
+ loaded["providers"].append(name)
28
+ except Exception:
29
+ pass
30
+
31
+ return loaded
@@ -0,0 +1,35 @@
1
+ """FlowAI Providers Package."""
2
+
3
+ from flowai.providers.anthropic import AnthropicProvider
4
+ from flowai.providers.base import (
5
+ BaseProvider,
6
+ Message,
7
+ ProviderResponse,
8
+ ProviderUsage,
9
+ StreamChunk,
10
+ ToolCall,
11
+ ToolResult,
12
+ )
13
+ from flowai.providers.gemini import GeminiProvider
14
+ from flowai.providers.nvidia import NvidiaProvider
15
+ from flowai.providers.ollama import OllamaProvider
16
+ from flowai.providers.openai import OpenAIProvider, OpenRouterProvider
17
+ from flowai.providers.registry import ProviderRegistry, get_provider
18
+
19
+ __all__ = [
20
+ "BaseProvider",
21
+ "Message",
22
+ "ToolCall",
23
+ "ToolResult",
24
+ "ProviderResponse",
25
+ "ProviderUsage",
26
+ "StreamChunk",
27
+ "NvidiaProvider",
28
+ "OllamaProvider",
29
+ "OpenAIProvider",
30
+ "OpenRouterProvider",
31
+ "AnthropicProvider",
32
+ "GeminiProvider",
33
+ "ProviderRegistry",
34
+ "get_provider",
35
+ ]
@@ -0,0 +1,268 @@
1
+ """Anthropic Claude Provider implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from collections.abc import AsyncIterator, Sequence
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from flowai.errors import ProviderError
13
+ from flowai.providers.base import (
14
+ BaseProvider,
15
+ Message,
16
+ ProviderResponse,
17
+ ProviderUsage,
18
+ StreamChunk,
19
+ ToolCall,
20
+ )
21
+
22
+
23
+ class AnthropicProvider(BaseProvider):
24
+ """Anthropic Provider for the Claude model family."""
25
+
26
+ DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
27
+ api_key_env = "ANTHROPIC_API_KEY"
28
+
29
+ def __init__(
30
+ self,
31
+ model: str = "claude-sonnet-5-5",
32
+ api_key: str | None = None,
33
+ base_url: str | None = None,
34
+ timeout: float = 60.0,
35
+ **kwargs: Any,
36
+ ):
37
+ resolved_key = api_key or os.getenv("ANTHROPIC_API_KEY", "")
38
+ resolved_url = base_url or os.getenv("ANTHROPIC_BASE_URL", self.DEFAULT_BASE_URL)
39
+ super().__init__(
40
+ model=model,
41
+ api_key=resolved_key,
42
+ base_url=resolved_url,
43
+ timeout=timeout,
44
+ **kwargs,
45
+ )
46
+
47
+ def _get_headers(self) -> dict[str, str]:
48
+ headers = {
49
+ "Content-Type": "application/json",
50
+ "anthropic-version": "2023-06-01",
51
+ }
52
+ if self.api_key:
53
+ headers["x-api-key"] = self.api_key
54
+ return headers
55
+
56
+ def _convert_messages(self, messages: Sequence[Message]) -> tuple[str, list[dict[str, Any]]]:
57
+ system_prompts = []
58
+ anthropic_messages = []
59
+
60
+ for m in messages:
61
+ if m.role == "system":
62
+ if m.content:
63
+ system_prompts.append(m.content)
64
+ elif m.role == "tool":
65
+ anthropic_messages.append({
66
+ "role": "user",
67
+ "content": [
68
+ {
69
+ "type": "tool_result",
70
+ "tool_use_id": m.tool_call_id,
71
+ "content": m.content or "",
72
+ }
73
+ ],
74
+ })
75
+ elif m.role == "assistant":
76
+ content_blocks: list[dict[str, Any]] = []
77
+ if m.content:
78
+ content_blocks.append({"type": "text", "text": m.content})
79
+ for tc in m.tool_calls:
80
+ content_blocks.append({
81
+ "type": "tool_use",
82
+ "id": tc.id,
83
+ "name": tc.name,
84
+ "input": tc.arguments,
85
+ })
86
+ anthropic_messages.append({"role": "assistant", "content": content_blocks})
87
+ else:
88
+ anthropic_messages.append({"role": "user", "content": m.content or ""})
89
+
90
+ system = "\n\n".join(system_prompts)
91
+ return system, anthropic_messages
92
+
93
+ def _convert_tools(self, tools: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
94
+ anthropic_tools = []
95
+ for t in tools:
96
+ # Handle standard OpenAI format or direct schema
97
+ if "function" in t:
98
+ fn = t["function"]
99
+ anthropic_tools.append({
100
+ "name": fn.get("name"),
101
+ "description": fn.get("description", ""),
102
+ "input_schema": fn.get("parameters", {"type": "object", "properties": {}}),
103
+ })
104
+ else:
105
+ anthropic_tools.append({
106
+ "name": t.get("name"),
107
+ "description": t.get("description", ""),
108
+ "input_schema": t.get("parameters", {"type": "object", "properties": {}}),
109
+ })
110
+ return anthropic_tools
111
+
112
+ async def generate(
113
+ self,
114
+ messages: Sequence[Message],
115
+ tools: Sequence[dict[str, Any]] | None = None,
116
+ temperature: float = 0.7,
117
+ max_tokens: int | None = None,
118
+ **kwargs: Any,
119
+ ) -> ProviderResponse:
120
+ url = f"{self.base_url.rstrip('/')}/messages"
121
+ system, formatted_msgs = self._convert_messages(messages)
122
+ payload: dict[str, Any] = {
123
+ "model": self.model,
124
+ "messages": formatted_msgs,
125
+ "max_tokens": max_tokens or 4096,
126
+ "temperature": temperature,
127
+ }
128
+ if system:
129
+ payload["system"] = system
130
+ if tools:
131
+ payload["tools"] = self._convert_tools(tools)
132
+
133
+ self.require_api_key()
134
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
135
+ resp = await client.post(url, headers=self._get_headers(), json=payload)
136
+ await self._raise_for_status(resp)
137
+ data = resp.json()
138
+
139
+ text_parts = []
140
+ tool_calls: list[ToolCall] = []
141
+ for block in data.get("content", []):
142
+ if block.get("type") == "text":
143
+ text_parts.append(block.get("text", ""))
144
+ elif block.get("type") == "tool_use":
145
+ tool_calls.append(
146
+ ToolCall(
147
+ id=block.get("id", ""),
148
+ name=block.get("name", ""),
149
+ arguments=block.get("input", {}),
150
+ )
151
+ )
152
+
153
+ raw_usage = data.get("usage", {})
154
+ usage = ProviderUsage(
155
+ prompt_tokens=raw_usage.get("input_tokens", 0),
156
+ completion_tokens=raw_usage.get("output_tokens", 0),
157
+ total_tokens=raw_usage.get("input_tokens", 0) + raw_usage.get("output_tokens", 0),
158
+ )
159
+
160
+ return ProviderResponse(
161
+ content="".join(text_parts),
162
+ tool_calls=tool_calls,
163
+ finish_reason=data.get("stop_reason", "end_turn"),
164
+ usage=usage,
165
+ )
166
+
167
+ async def stream(
168
+ self,
169
+ messages: Sequence[Message],
170
+ tools: Sequence[dict[str, Any]] | None = None,
171
+ temperature: float = 0.7,
172
+ max_tokens: int | None = None,
173
+ **kwargs: Any,
174
+ ) -> AsyncIterator[StreamChunk]:
175
+ url = f"{self.base_url.rstrip('/')}/messages"
176
+ system, formatted_msgs = self._convert_messages(messages)
177
+ payload: dict[str, Any] = {
178
+ "model": self.model,
179
+ "messages": formatted_msgs,
180
+ "max_tokens": max_tokens or 4096,
181
+ "temperature": temperature,
182
+ "stream": True,
183
+ }
184
+ if system:
185
+ payload["system"] = system
186
+ if tools:
187
+ payload["tools"] = self._convert_tools(tools)
188
+
189
+ self.require_api_key()
190
+ # A tool_use block arrives as a start event, then its input as a run of
191
+ # partial JSON deltas; it is assembled here and emitted once, complete.
192
+ pending: dict[int, dict[str, Any]] = {}
193
+
194
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
195
+ async with client.stream("POST", url, headers=self._get_headers(), json=payload) as response:
196
+ await self._raise_for_status(response)
197
+ async for line in response.aiter_lines():
198
+ line = line.strip()
199
+ if not line.startswith("data: "):
200
+ continue
201
+ try:
202
+ event = json.loads(line[6:])
203
+ except json.JSONDecodeError:
204
+ continue
205
+
206
+ ev_type = event.get("type")
207
+
208
+ if ev_type == "content_block_start":
209
+ block = event.get("content_block", {})
210
+ if block.get("type") == "tool_use":
211
+ pending[event.get("index", 0)] = {
212
+ "id": block.get("id", ""),
213
+ "name": block.get("name", ""),
214
+ "json": "",
215
+ }
216
+
217
+ elif ev_type == "content_block_delta":
218
+ delta = event.get("delta", {})
219
+ if delta.get("type") == "text_delta":
220
+ yield StreamChunk(text_delta=delta.get("text", ""))
221
+ elif delta.get("type") == "input_json_delta":
222
+ slot = pending.get(event.get("index", 0))
223
+ if slot is not None:
224
+ slot["json"] += delta.get("partial_json", "")
225
+
226
+ elif ev_type == "content_block_stop":
227
+ slot = pending.pop(event.get("index", 0), None)
228
+ if slot:
229
+ yield StreamChunk(tool_call_delta=self._assemble_tool_call(slot))
230
+
231
+ elif ev_type == "message_delta":
232
+ raw_usage = event.get("usage") or {}
233
+ if raw_usage:
234
+ yield StreamChunk(
235
+ usage=ProviderUsage(
236
+ prompt_tokens=raw_usage.get("input_tokens", 0),
237
+ completion_tokens=raw_usage.get("output_tokens", 0),
238
+ total_tokens=raw_usage.get("input_tokens", 0) + raw_usage.get("output_tokens", 0),
239
+ ),
240
+ finish_reason=(event.get("delta") or {}).get("stop_reason"),
241
+ )
242
+
243
+ elif ev_type == "message_stop":
244
+ yield StreamChunk(finish_reason="end_turn")
245
+
246
+ elif ev_type == "error":
247
+ detail = (event.get("error") or {}).get("message", "unknown streaming error")
248
+ raise ProviderError(self.provider_name, None, detail)
249
+
250
+ @staticmethod
251
+ def _assemble_tool_call(slot: dict[str, Any]) -> ToolCall:
252
+ """Turn an accumulated tool_use block into a complete ToolCall."""
253
+ raw = slot.get("json") or ""
254
+ try:
255
+ arguments = json.loads(raw) if raw.strip() else {}
256
+ except json.JSONDecodeError:
257
+ arguments = {"raw": raw}
258
+ if not isinstance(arguments, dict):
259
+ arguments = {"value": arguments}
260
+ return ToolCall(id=slot.get("id", ""), name=slot.get("name", ""), arguments=arguments)
261
+
262
+ async def embeddings(
263
+ self,
264
+ texts: list[str],
265
+ model: str | None = None,
266
+ **kwargs: Any,
267
+ ) -> list[list[float]]:
268
+ raise NotImplementedError("Anthropic does not natively host vector embeddings API; use an embedding provider.")