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,180 @@
1
+ """Provider Abstraction Layer - Core data structures and BaseProvider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from abc import ABC, abstractmethod
7
+ from collections.abc import AsyncIterator, Sequence
8
+ from typing import Any
9
+
10
+ import httpx
11
+ from pydantic import BaseModel, Field
12
+
13
+ from flowai.errors import ConfigurationError, ProviderError
14
+
15
+
16
+ class ToolCall(BaseModel):
17
+ """Represents a model tool/function call request."""
18
+ id: str
19
+ name: str
20
+ arguments: dict[str, Any] = Field(default_factory=dict)
21
+
22
+
23
+ class ToolResult(BaseModel):
24
+ """Result of an executed tool call."""
25
+ tool_call_id: str
26
+ name: str
27
+ content: str
28
+ is_error: bool = False
29
+
30
+
31
+ class Message(BaseModel):
32
+ """Unified message structure across all LLM providers."""
33
+ role: str # "system" | "user" | "assistant" | "tool"
34
+ content: str | None = None
35
+ tool_calls: list[ToolCall] = Field(default_factory=list)
36
+ tool_call_id: str | None = None
37
+ name: str | None = None
38
+
39
+
40
+ class ProviderUsage(BaseModel):
41
+ """Token and cost telemetry for a model invocation."""
42
+ prompt_tokens: int = 0
43
+ completion_tokens: int = 0
44
+ total_tokens: int = 0
45
+ estimated_cost_usd: float = 0.0
46
+
47
+
48
+ class ProviderResponse(BaseModel):
49
+ """Standard non-streaming completion output."""
50
+ content: str = ""
51
+ tool_calls: list[ToolCall] = Field(default_factory=list)
52
+ finish_reason: str = "stop"
53
+ usage: ProviderUsage = Field(default_factory=ProviderUsage)
54
+
55
+
56
+ class StreamChunk(BaseModel):
57
+ """Incremental chunk emitted during streaming."""
58
+ text_delta: str = ""
59
+ tool_call_delta: ToolCall | None = None
60
+ finish_reason: str | None = None
61
+ usage: ProviderUsage | None = None
62
+
63
+
64
+ class BaseProvider(ABC):
65
+ """Abstract Base Class for all LLM Provider implementations in FlowAI.
66
+
67
+ Subclasses must implement:
68
+ - generate(): non-streaming text/tool generation
69
+ - stream(): asynchronous token/tool streaming
70
+ - embeddings(): vector embedding generation
71
+
72
+ Streaming contract: a provider yields text as `text_delta` chunks, and
73
+ emits every tool call the model requested exactly once as a *complete*
74
+ `tool_call_delta` (fragments are assembled inside the provider). Agents
75
+ rely on that, so they can act on a streamed tool call.
76
+ """
77
+
78
+ #: Environment variable holding this provider's key, if it needs one.
79
+ api_key_env: str | None = None
80
+ #: Local providers such as Ollama need no key.
81
+ requires_api_key: bool = True
82
+
83
+ def __init__(
84
+ self,
85
+ model: str,
86
+ api_key: str | None = None,
87
+ base_url: str | None = None,
88
+ timeout: float = 60.0,
89
+ **kwargs: Any,
90
+ ):
91
+ self.model = model
92
+ self.api_key = api_key
93
+ self.base_url = base_url
94
+ self.timeout = timeout
95
+ self.extra_kwargs = kwargs
96
+
97
+ @abstractmethod
98
+ async def generate(
99
+ self,
100
+ messages: Sequence[Message],
101
+ tools: Sequence[dict[str, Any]] | None = None,
102
+ temperature: float = 0.7,
103
+ max_tokens: int | None = None,
104
+ **kwargs: Any,
105
+ ) -> ProviderResponse:
106
+ """Execute a non-streaming completion."""
107
+ ...
108
+
109
+ @abstractmethod
110
+ async def stream(
111
+ self,
112
+ messages: Sequence[Message],
113
+ tools: Sequence[dict[str, Any]] | None = None,
114
+ temperature: float = 0.7,
115
+ max_tokens: int | None = None,
116
+ **kwargs: Any,
117
+ ) -> AsyncIterator[StreamChunk]:
118
+ """Execute a streaming completion yielding token/tool deltas."""
119
+ ...
120
+
121
+ @abstractmethod
122
+ async def embeddings(
123
+ self,
124
+ texts: list[str],
125
+ model: str | None = None,
126
+ **kwargs: Any,
127
+ ) -> list[list[float]]:
128
+ """Compute text vector embeddings."""
129
+ ...
130
+
131
+ @property
132
+ def provider_name(self) -> str:
133
+ """Short lowercase name, e.g. 'anthropic' for AnthropicProvider."""
134
+ return self.__class__.__name__.replace("Provider", "").lower()
135
+
136
+ def require_api_key(self) -> None:
137
+ """Fail early, and in plain words, when the key for this provider is missing."""
138
+ if not self.requires_api_key or self.api_key:
139
+ return
140
+ env_hint = f" Set {self.api_key_env}," if self.api_key_env else ""
141
+ raise ConfigurationError(
142
+ f"No API key for the {self.provider_name} provider.{env_hint}"
143
+ " or run 'flowai configure' to save one."
144
+ )
145
+
146
+ async def _raise_for_status(self, response: httpx.Response) -> None:
147
+ """Turn an HTTP error into a ProviderError that quotes the API's own message."""
148
+ if response.status_code < 400:
149
+ return
150
+ # A streamed response has not been read yet; read it before touching .text.
151
+ if not response.is_closed:
152
+ await response.aread()
153
+ detail = response.text.strip()
154
+ try:
155
+ payload = json.loads(detail)
156
+ error = payload.get("error", payload)
157
+ if isinstance(error, dict):
158
+ detail = error.get("message") or json.dumps(error)
159
+ elif isinstance(error, str):
160
+ detail = error
161
+ except (json.JSONDecodeError, AttributeError):
162
+ pass
163
+ if len(detail) > 500:
164
+ detail = detail[:500] + "..."
165
+ raise ProviderError(self.provider_name, response.status_code, detail or response.reason_phrase)
166
+
167
+ def parse_tool_call(self, raw_call: dict[str, Any]) -> ToolCall:
168
+ """Helper to safely parse tool call dictionary into ToolCall object."""
169
+ tc_id = raw_call.get("id", "")
170
+ function_data = raw_call.get("function", {})
171
+ name = function_data.get("name", "")
172
+ args_raw = function_data.get("arguments", "{}")
173
+ if isinstance(args_raw, str):
174
+ try:
175
+ args = json.loads(args_raw)
176
+ except Exception:
177
+ args = {"raw": args_raw}
178
+ else:
179
+ args = args_raw or {}
180
+ return ToolCall(id=tc_id, name=name, arguments=args)
@@ -0,0 +1,230 @@
1
+ """Google Gemini 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.providers.base import (
13
+ BaseProvider,
14
+ Message,
15
+ ProviderResponse,
16
+ ProviderUsage,
17
+ StreamChunk,
18
+ ToolCall,
19
+ )
20
+
21
+
22
+ class GeminiProvider(BaseProvider):
23
+ """Google Gemini Provider (Gemini 2.5 Flash, Gemini 1.5 Pro, etc.)."""
24
+
25
+ DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
26
+ api_key_env = "GEMINI_API_KEY"
27
+
28
+ def __init__(
29
+ self,
30
+ model: str = "gemini-2.0-flash",
31
+ api_key: str | None = None,
32
+ base_url: str | None = None,
33
+ timeout: float = 60.0,
34
+ **kwargs: Any,
35
+ ):
36
+ resolved_key = api_key or os.getenv("GEMINI_API_KEY", "")
37
+ resolved_url = base_url or os.getenv("GEMINI_BASE_URL", self.DEFAULT_BASE_URL)
38
+ super().__init__(
39
+ model=model,
40
+ api_key=resolved_key,
41
+ base_url=resolved_url,
42
+ timeout=timeout,
43
+ **kwargs,
44
+ )
45
+
46
+ def _convert_messages(self, messages: Sequence[Message]) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
47
+ system_instruction = None
48
+ contents = []
49
+
50
+ for m in messages:
51
+ if m.role == "system":
52
+ system_instruction = {"parts": [{"text": m.content or ""}]}
53
+ elif m.role == "tool":
54
+ contents.append({
55
+ "role": "function",
56
+ "parts": [{
57
+ "functionResponse": {
58
+ "name": m.name or "tool",
59
+ "response": {"result": m.content or ""},
60
+ }
61
+ }],
62
+ })
63
+ elif m.role == "assistant":
64
+ parts: list[dict[str, Any]] = []
65
+ if m.content:
66
+ parts.append({"text": m.content})
67
+ for tc in m.tool_calls:
68
+ parts.append({
69
+ "functionCall": {
70
+ "name": tc.name,
71
+ "args": tc.arguments,
72
+ }
73
+ })
74
+ contents.append({"role": "model", "parts": parts})
75
+ else:
76
+ contents.append({"role": "user", "parts": [{"text": m.content or ""}]})
77
+
78
+ return system_instruction, contents
79
+
80
+ def _convert_tools(self, tools: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
81
+ """Translate OpenAI-style tool schemas into Gemini functionDeclarations."""
82
+ declarations = []
83
+ for t in tools:
84
+ fn = t.get("function", t)
85
+ parameters = dict(fn.get("parameters") or {"type": "object", "properties": {}})
86
+ # Gemini rejects an empty-property object schema, so omit it entirely.
87
+ if not parameters.get("properties"):
88
+ parameters = None
89
+ declaration = {
90
+ "name": fn.get("name"),
91
+ "description": fn.get("description", ""),
92
+ }
93
+ if parameters:
94
+ declaration["parameters"] = parameters
95
+ declarations.append(declaration)
96
+ return [{"functionDeclarations": declarations}]
97
+
98
+ @staticmethod
99
+ def _usage_from(meta: dict[str, Any]) -> ProviderUsage:
100
+ return ProviderUsage(
101
+ prompt_tokens=meta.get("promptTokenCount", 0),
102
+ completion_tokens=meta.get("candidatesTokenCount", 0),
103
+ total_tokens=meta.get("totalTokenCount", 0),
104
+ )
105
+
106
+ async def generate(
107
+ self,
108
+ messages: Sequence[Message],
109
+ tools: Sequence[dict[str, Any]] | None = None,
110
+ temperature: float = 0.7,
111
+ max_tokens: int | None = None,
112
+ **kwargs: Any,
113
+ ) -> ProviderResponse:
114
+ url = f"{self.base_url.rstrip('/')}/models/{self.model}:generateContent?key={self.api_key}"
115
+ system_instruction, contents = self._convert_messages(messages)
116
+ payload: dict[str, Any] = {
117
+ "contents": contents,
118
+ "generationConfig": {"temperature": temperature},
119
+ }
120
+ if max_tokens:
121
+ payload["generationConfig"]["maxOutputTokens"] = max_tokens
122
+ if system_instruction:
123
+ payload["systemInstruction"] = system_instruction
124
+ if tools:
125
+ payload["tools"] = self._convert_tools(tools)
126
+
127
+ self.require_api_key()
128
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
129
+ resp = await client.post(url, json=payload)
130
+ await self._raise_for_status(resp)
131
+ data = resp.json()
132
+
133
+ candidates = data.get("candidates", [])
134
+ content_text = ""
135
+ tool_calls: list[ToolCall] = []
136
+
137
+ if candidates:
138
+ cand = candidates[0]
139
+ parts = cand.get("content", {}).get("parts", [])
140
+ for p in parts:
141
+ if "text" in p:
142
+ content_text += p["text"]
143
+ elif "functionCall" in p:
144
+ fc = p["functionCall"]
145
+ tool_calls.append(
146
+ ToolCall(
147
+ id=fc.get("name", "call"),
148
+ name=fc.get("name", ""),
149
+ arguments=fc.get("args", {}),
150
+ )
151
+ )
152
+
153
+ usage = self._usage_from(data.get("usageMetadata", {}))
154
+
155
+ return ProviderResponse(
156
+ content=content_text,
157
+ tool_calls=tool_calls,
158
+ finish_reason="stop",
159
+ usage=usage,
160
+ )
161
+
162
+ async def stream(
163
+ self,
164
+ messages: Sequence[Message],
165
+ tools: Sequence[dict[str, Any]] | None = None,
166
+ temperature: float = 0.7,
167
+ max_tokens: int | None = None,
168
+ **kwargs: Any,
169
+ ) -> AsyncIterator[StreamChunk]:
170
+ url = f"{self.base_url.rstrip('/')}/models/{self.model}:streamGenerateContent?key={self.api_key}&alt=sse"
171
+ system_instruction, contents = self._convert_messages(messages)
172
+ payload: dict[str, Any] = {
173
+ "contents": contents,
174
+ "generationConfig": {"temperature": temperature},
175
+ }
176
+ if max_tokens:
177
+ payload["generationConfig"]["maxOutputTokens"] = max_tokens
178
+ if system_instruction:
179
+ payload["systemInstruction"] = system_instruction
180
+ if tools:
181
+ payload["tools"] = self._convert_tools(tools)
182
+
183
+ self.require_api_key()
184
+ call_index = 0
185
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
186
+ async with client.stream("POST", url, json=payload) as response:
187
+ await self._raise_for_status(response)
188
+ async for line in response.aiter_lines():
189
+ line = line.strip()
190
+ if not line.startswith("data: "):
191
+ continue
192
+ try:
193
+ data = json.loads(line[6:])
194
+ except json.JSONDecodeError:
195
+ continue
196
+
197
+ for candidate in data.get("candidates", []):
198
+ for part in candidate.get("content", {}).get("parts", []):
199
+ if "text" in part:
200
+ yield StreamChunk(text_delta=part["text"])
201
+ elif "functionCall" in part:
202
+ fc = part["functionCall"]
203
+ call_index += 1
204
+ yield StreamChunk(
205
+ tool_call_delta=ToolCall(
206
+ id=f"{fc.get('name', 'call')}_{call_index}",
207
+ name=fc.get("name", ""),
208
+ arguments=fc.get("args", {}) or {},
209
+ )
210
+ )
211
+ if candidate.get("finishReason"):
212
+ yield StreamChunk(finish_reason=candidate["finishReason"])
213
+
214
+ if data.get("usageMetadata"):
215
+ yield StreamChunk(usage=self._usage_from(data["usageMetadata"]))
216
+
217
+ async def embeddings(
218
+ self,
219
+ texts: list[str],
220
+ model: str | None = None,
221
+ **kwargs: Any,
222
+ ) -> list[list[float]]:
223
+ url = f"{self.base_url.rstrip('/')}/models/{model or 'text-embedding-004'}:batchEmbedContents?key={self.api_key}"
224
+ requests = [{"model": f"models/{model or 'text-embedding-004'}", "content": {"parts": [{"text": t}]}} for t in texts]
225
+ self.require_api_key()
226
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
227
+ resp = await client.post(url, json={"requests": requests})
228
+ await self._raise_for_status(resp)
229
+ data = resp.json()
230
+ return [e["values"] for e in data.get("embeddings", [])]
@@ -0,0 +1,57 @@
1
+ """NVIDIA API Catalog Provider implementation.
2
+
3
+ The catalog speaks the OpenAI chat-completions protocol, so this provider is
4
+ the OpenAI one pointed at NVIDIA's endpoint. Only the embeddings call differs:
5
+ NVIDIA's models require an input_type.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+ from flowai.providers.openai import OpenAIProvider
16
+
17
+
18
+ class NvidiaProvider(OpenAIProvider):
19
+ """NVIDIA API Catalog Provider for models like DeepSeek-R1 and Llama 3.1."""
20
+
21
+ DEFAULT_BASE_URL = "https://integrate.api.nvidia.com/v1"
22
+ api_key_env = "NVIDIA_API_KEY"
23
+
24
+ def __init__(
25
+ self,
26
+ model: str = "meta/llama-3.1-70b-instruct",
27
+ api_key: str | None = None,
28
+ base_url: str | None = None,
29
+ timeout: float = 60.0,
30
+ **kwargs: Any,
31
+ ):
32
+ super().__init__(
33
+ model=model,
34
+ api_key=api_key or os.getenv("NVIDIA_API_KEY", ""),
35
+ base_url=base_url or os.getenv("NVIDIA_BASE_URL", self.DEFAULT_BASE_URL),
36
+ timeout=timeout,
37
+ **kwargs,
38
+ )
39
+
40
+ async def embeddings(
41
+ self,
42
+ texts: list[str],
43
+ model: str | None = None,
44
+ **kwargs: Any,
45
+ ) -> list[list[float]]:
46
+ url = f"{self.base_url.rstrip('/')}/embeddings"
47
+ payload = {
48
+ "input": texts,
49
+ "model": model or "nvidia/nv-embed-v1",
50
+ "input_type": kwargs.get("input_type", "query"),
51
+ }
52
+ self.require_api_key()
53
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
54
+ resp = await client.post(url, headers=self._get_headers(), json=payload)
55
+ await self._raise_for_status(resp)
56
+ data = resp.json()
57
+ return [item["embedding"] for item in data.get("data", [])]