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.
- flowai/__init__.py +72 -0
- flowai/__main__.py +8 -0
- flowai/agents/__init__.py +21 -0
- flowai/agents/base.py +61 -0
- flowai/agents/patterns/__init__.py +11 -0
- flowai/agents/patterns/debate.py +79 -0
- flowai/agents/patterns/reflection.py +74 -0
- flowai/agents/specialized.py +87 -0
- flowai/agents/standard.py +176 -0
- flowai/cli/__init__.py +5 -0
- flowai/cli/main.py +562 -0
- flowai/cli/ui.py +98 -0
- flowai/config/__init__.py +5 -0
- flowai/config/settings.py +204 -0
- flowai/errors.py +30 -0
- flowai/mcp/__init__.py +5 -0
- flowai/mcp/client.py +77 -0
- flowai/memory/__init__.py +16 -0
- flowai/memory/base.py +45 -0
- flowai/memory/chroma.py +97 -0
- flowai/memory/in_memory.py +65 -0
- flowai/memory/qdrant.py +104 -0
- flowai/observability/__init__.py +15 -0
- flowai/observability/events.py +45 -0
- flowai/observability/opentelemetry.py +56 -0
- flowai/observability/tracer.py +80 -0
- flowai/plugins/__init__.py +5 -0
- flowai/plugins/loader.py +31 -0
- flowai/providers/__init__.py +35 -0
- flowai/providers/anthropic.py +268 -0
- flowai/providers/base.py +180 -0
- flowai/providers/gemini.py +230 -0
- flowai/providers/nvidia.py +57 -0
- flowai/providers/ollama.py +237 -0
- flowai/providers/openai.py +274 -0
- flowai/providers/registry.py +50 -0
- flowai/runtime/__init__.py +5 -0
- flowai/runtime/session.py +76 -0
- flowai/server/__init__.py +5 -0
- flowai/server/app.py +133 -0
- flowai/tools/__init__.py +39 -0
- flowai/tools/base.py +56 -0
- flowai/tools/builtin.py +128 -0
- flowai/tools/decorator.py +103 -0
- flowai/tools/files.py +197 -0
- flowai/tools/registry.py +84 -0
- flowai/tools/web.py +94 -0
- flowai/workflow/__init__.py +15 -0
- flowai/workflow/checkpoint.py +73 -0
- flowai/workflow/engine.py +144 -0
- flowai/workflow/state.py +43 -0
- flowai_cli-0.2.0.dist-info/METADATA +223 -0
- flowai_cli-0.2.0.dist-info/RECORD +56 -0
- flowai_cli-0.2.0.dist-info/WHEEL +4 -0
- flowai_cli-0.2.0.dist-info/entry_points.txt +2 -0
- flowai_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Ollama Local Inference 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 ConfigurationError
|
|
13
|
+
from flowai.providers.base import (
|
|
14
|
+
BaseProvider,
|
|
15
|
+
Message,
|
|
16
|
+
ProviderResponse,
|
|
17
|
+
ProviderUsage,
|
|
18
|
+
StreamChunk,
|
|
19
|
+
ToolCall,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OllamaProvider(BaseProvider):
|
|
24
|
+
"""Local Ollama Provider running at http://localhost:11434."""
|
|
25
|
+
|
|
26
|
+
DEFAULT_BASE_URL = "http://localhost:11434"
|
|
27
|
+
requires_api_key = False
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
model: str = "llama3.2",
|
|
32
|
+
api_key: str | None = None,
|
|
33
|
+
base_url: str | None = None,
|
|
34
|
+
timeout: float = 120.0,
|
|
35
|
+
**kwargs: Any,
|
|
36
|
+
):
|
|
37
|
+
resolved_url = base_url or os.getenv("OLLAMA_BASE_URL", self.DEFAULT_BASE_URL)
|
|
38
|
+
super().__init__(
|
|
39
|
+
model=model,
|
|
40
|
+
api_key=api_key,
|
|
41
|
+
base_url=resolved_url,
|
|
42
|
+
timeout=timeout,
|
|
43
|
+
**kwargs,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def _convert_messages(self, messages: Sequence[Message]) -> list[dict[str, Any]]:
|
|
47
|
+
out = []
|
|
48
|
+
for m in messages:
|
|
49
|
+
msg: dict[str, Any] = {"role": m.role, "content": m.content or ""}
|
|
50
|
+
if m.tool_calls:
|
|
51
|
+
msg["tool_calls"] = [
|
|
52
|
+
{
|
|
53
|
+
"function": {
|
|
54
|
+
"name": tc.name,
|
|
55
|
+
"arguments": tc.arguments,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for tc in m.tool_calls
|
|
59
|
+
]
|
|
60
|
+
out.append(msg)
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
async def generate(
|
|
64
|
+
self,
|
|
65
|
+
messages: Sequence[Message],
|
|
66
|
+
tools: Sequence[dict[str, Any]] | None = None,
|
|
67
|
+
temperature: float = 0.7,
|
|
68
|
+
max_tokens: int | None = None,
|
|
69
|
+
**kwargs: Any,
|
|
70
|
+
) -> ProviderResponse:
|
|
71
|
+
url = f"{self.base_url.rstrip('/')}/api/chat"
|
|
72
|
+
payload: dict[str, Any] = {
|
|
73
|
+
"model": self.model,
|
|
74
|
+
"messages": self._convert_messages(messages),
|
|
75
|
+
"stream": False,
|
|
76
|
+
"options": {"temperature": temperature},
|
|
77
|
+
}
|
|
78
|
+
if max_tokens:
|
|
79
|
+
payload["options"]["num_predict"] = max_tokens
|
|
80
|
+
if tools:
|
|
81
|
+
# Transform OpenAI-style tools to Ollama tools if needed
|
|
82
|
+
ollama_tools = []
|
|
83
|
+
for t in tools:
|
|
84
|
+
if "function" in t:
|
|
85
|
+
ollama_tools.append(t)
|
|
86
|
+
else:
|
|
87
|
+
ollama_tools.append({"type": "function", "function": t})
|
|
88
|
+
payload["tools"] = ollama_tools
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
92
|
+
resp = await client.post(url, json=payload)
|
|
93
|
+
await self._raise_for_status(resp)
|
|
94
|
+
data = resp.json()
|
|
95
|
+
except httpx.ConnectError as exc:
|
|
96
|
+
raise self._not_running(exc) from exc
|
|
97
|
+
|
|
98
|
+
raw_msg = data.get("message", {})
|
|
99
|
+
content = raw_msg.get("content", "")
|
|
100
|
+
tool_calls: list[ToolCall] = []
|
|
101
|
+
|
|
102
|
+
if raw_msg.get("tool_calls"):
|
|
103
|
+
for i, tc in enumerate(raw_msg["tool_calls"]):
|
|
104
|
+
fn = tc.get("function", {})
|
|
105
|
+
args = fn.get("arguments", {})
|
|
106
|
+
if isinstance(args, str):
|
|
107
|
+
try:
|
|
108
|
+
args = json.loads(args)
|
|
109
|
+
except Exception:
|
|
110
|
+
args = {"raw": args}
|
|
111
|
+
tool_calls.append(
|
|
112
|
+
ToolCall(
|
|
113
|
+
id=f"call_{i}_{fn.get('name')}",
|
|
114
|
+
name=fn.get("name", ""),
|
|
115
|
+
arguments=args,
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
prompt_eval = data.get("prompt_eval_count", 0)
|
|
120
|
+
eval_count = data.get("eval_count", 0)
|
|
121
|
+
usage = ProviderUsage(
|
|
122
|
+
prompt_tokens=prompt_eval,
|
|
123
|
+
completion_tokens=eval_count,
|
|
124
|
+
total_tokens=prompt_eval + eval_count,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return ProviderResponse(
|
|
128
|
+
content=content,
|
|
129
|
+
tool_calls=tool_calls,
|
|
130
|
+
finish_reason="stop" if data.get("done") else "length",
|
|
131
|
+
usage=usage,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
async def stream(
|
|
135
|
+
self,
|
|
136
|
+
messages: Sequence[Message],
|
|
137
|
+
tools: Sequence[dict[str, Any]] | None = None,
|
|
138
|
+
temperature: float = 0.7,
|
|
139
|
+
max_tokens: int | None = None,
|
|
140
|
+
**kwargs: Any,
|
|
141
|
+
) -> AsyncIterator[StreamChunk]:
|
|
142
|
+
url = f"{self.base_url.rstrip('/')}/api/chat"
|
|
143
|
+
payload: dict[str, Any] = {
|
|
144
|
+
"model": self.model,
|
|
145
|
+
"messages": self._convert_messages(messages),
|
|
146
|
+
"stream": True,
|
|
147
|
+
"options": {"temperature": temperature},
|
|
148
|
+
}
|
|
149
|
+
if max_tokens:
|
|
150
|
+
payload["options"]["num_predict"] = max_tokens
|
|
151
|
+
if tools:
|
|
152
|
+
payload["tools"] = tools
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
156
|
+
async with client.stream("POST", url, json=payload) as response:
|
|
157
|
+
await self._raise_for_status(response)
|
|
158
|
+
call_index = 0
|
|
159
|
+
async for line in response.aiter_lines():
|
|
160
|
+
line = line.strip()
|
|
161
|
+
if not line:
|
|
162
|
+
continue
|
|
163
|
+
try:
|
|
164
|
+
data = json.loads(line)
|
|
165
|
+
except json.JSONDecodeError:
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
raw_msg = data.get("message", {})
|
|
169
|
+
content_delta = raw_msg.get("content", "")
|
|
170
|
+
done = bool(data.get("done"))
|
|
171
|
+
|
|
172
|
+
# Ollama sends each tool call whole, so emit them all.
|
|
173
|
+
for raw_tc in raw_msg.get("tool_calls") or []:
|
|
174
|
+
fn = raw_tc.get("function", {})
|
|
175
|
+
call_index += 1
|
|
176
|
+
yield StreamChunk(
|
|
177
|
+
tool_call_delta=ToolCall(
|
|
178
|
+
id=f"call_{call_index}_{fn.get('name', '')}",
|
|
179
|
+
name=fn.get("name", ""),
|
|
180
|
+
arguments=self._parse_arguments(fn.get("arguments", {})),
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if content_delta or done:
|
|
185
|
+
yield StreamChunk(
|
|
186
|
+
text_delta=content_delta,
|
|
187
|
+
finish_reason="stop" if done else None,
|
|
188
|
+
usage=self._usage_from(data) if done else None,
|
|
189
|
+
)
|
|
190
|
+
except httpx.ConnectError as exc:
|
|
191
|
+
raise self._not_running(exc) from exc
|
|
192
|
+
|
|
193
|
+
async def embeddings(
|
|
194
|
+
self,
|
|
195
|
+
texts: list[str],
|
|
196
|
+
model: str | None = None,
|
|
197
|
+
**kwargs: Any,
|
|
198
|
+
) -> list[list[float]]:
|
|
199
|
+
url = f"{self.base_url.rstrip('/')}/api/embed"
|
|
200
|
+
payload = {
|
|
201
|
+
"model": model or self.model,
|
|
202
|
+
"input": texts,
|
|
203
|
+
}
|
|
204
|
+
try:
|
|
205
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
206
|
+
resp = await client.post(url, json=payload)
|
|
207
|
+
await self._raise_for_status(resp)
|
|
208
|
+
data = resp.json()
|
|
209
|
+
except httpx.ConnectError as exc:
|
|
210
|
+
raise self._not_running(exc) from exc
|
|
211
|
+
return data.get("embeddings", [])
|
|
212
|
+
|
|
213
|
+
def _not_running(self, exc: Exception) -> ConfigurationError:
|
|
214
|
+
"""Ollama is local: a refused connection means it is not started."""
|
|
215
|
+
return ConfigurationError(
|
|
216
|
+
f"Cannot reach Ollama at {self.base_url} ({exc}). "
|
|
217
|
+
"Start it with 'ollama serve', or point FlowAI elsewhere with OLLAMA_BASE_URL."
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _parse_arguments(args):
|
|
222
|
+
if isinstance(args, str):
|
|
223
|
+
try:
|
|
224
|
+
args = json.loads(args)
|
|
225
|
+
except json.JSONDecodeError:
|
|
226
|
+
return {"raw": args}
|
|
227
|
+
return args if isinstance(args, dict) else {"value": args}
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _usage_from(data: dict[str, Any]) -> ProviderUsage:
|
|
231
|
+
prompt_eval = data.get("prompt_eval_count", 0)
|
|
232
|
+
eval_count = data.get("eval_count", 0)
|
|
233
|
+
return ProviderUsage(
|
|
234
|
+
prompt_tokens=prompt_eval,
|
|
235
|
+
completion_tokens=eval_count,
|
|
236
|
+
total_tokens=prompt_eval + eval_count,
|
|
237
|
+
)
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""OpenAI and OpenRouter Provider implementations."""
|
|
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 OpenAIProvider(BaseProvider):
|
|
23
|
+
"""Standard OpenAI Provider (GPT-4o, GPT-4o-mini, o1, etc.)."""
|
|
24
|
+
|
|
25
|
+
DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
|
26
|
+
api_key_env = "OPENAI_API_KEY"
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
model: str = "gpt-4o",
|
|
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("OPENAI_API_KEY", "")
|
|
37
|
+
resolved_url = base_url or os.getenv("OPENAI_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 _get_headers(self) -> dict[str, str]:
|
|
47
|
+
headers = {
|
|
48
|
+
"Content-Type": "application/json",
|
|
49
|
+
"Accept": "application/json",
|
|
50
|
+
}
|
|
51
|
+
if self.api_key:
|
|
52
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
53
|
+
return headers
|
|
54
|
+
|
|
55
|
+
def _convert_messages(self, messages: Sequence[Message]) -> list[dict[str, Any]]:
|
|
56
|
+
out = []
|
|
57
|
+
for m in messages:
|
|
58
|
+
msg: dict[str, Any] = {"role": m.role}
|
|
59
|
+
if m.content is not None:
|
|
60
|
+
msg["content"] = m.content
|
|
61
|
+
if m.tool_calls:
|
|
62
|
+
msg["tool_calls"] = [
|
|
63
|
+
{
|
|
64
|
+
"id": tc.id,
|
|
65
|
+
"type": "function",
|
|
66
|
+
"function": {
|
|
67
|
+
"name": tc.name,
|
|
68
|
+
"arguments": json.dumps(tc.arguments),
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
for tc in m.tool_calls
|
|
72
|
+
]
|
|
73
|
+
if m.tool_call_id:
|
|
74
|
+
msg["tool_call_id"] = m.tool_call_id
|
|
75
|
+
if m.name:
|
|
76
|
+
msg["name"] = m.name
|
|
77
|
+
out.append(msg)
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
async def generate(
|
|
81
|
+
self,
|
|
82
|
+
messages: Sequence[Message],
|
|
83
|
+
tools: Sequence[dict[str, Any]] | None = None,
|
|
84
|
+
temperature: float = 0.7,
|
|
85
|
+
max_tokens: int | None = None,
|
|
86
|
+
**kwargs: Any,
|
|
87
|
+
) -> ProviderResponse:
|
|
88
|
+
url = f"{self.base_url.rstrip('/')}/chat/completions"
|
|
89
|
+
payload: dict[str, Any] = {
|
|
90
|
+
"model": self.model,
|
|
91
|
+
"messages": self._convert_messages(messages),
|
|
92
|
+
"temperature": temperature,
|
|
93
|
+
}
|
|
94
|
+
if max_tokens:
|
|
95
|
+
payload["max_tokens"] = max_tokens
|
|
96
|
+
if tools:
|
|
97
|
+
payload["tools"] = tools
|
|
98
|
+
|
|
99
|
+
self.require_api_key()
|
|
100
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
101
|
+
resp = await client.post(url, headers=self._get_headers(), json=payload)
|
|
102
|
+
await self._raise_for_status(resp)
|
|
103
|
+
data = resp.json()
|
|
104
|
+
|
|
105
|
+
choice = data["choices"][0]
|
|
106
|
+
raw_msg = choice.get("message", {})
|
|
107
|
+
content = raw_msg.get("content") or ""
|
|
108
|
+
|
|
109
|
+
tool_calls: list[ToolCall] = []
|
|
110
|
+
if raw_msg.get("tool_calls"):
|
|
111
|
+
for tc in raw_msg["tool_calls"]:
|
|
112
|
+
tool_calls.append(self.parse_tool_call(tc))
|
|
113
|
+
|
|
114
|
+
raw_usage = data.get("usage", {})
|
|
115
|
+
usage = ProviderUsage(
|
|
116
|
+
prompt_tokens=raw_usage.get("prompt_tokens", 0),
|
|
117
|
+
completion_tokens=raw_usage.get("completion_tokens", 0),
|
|
118
|
+
total_tokens=raw_usage.get("total_tokens", 0),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return ProviderResponse(
|
|
122
|
+
content=content,
|
|
123
|
+
tool_calls=tool_calls,
|
|
124
|
+
finish_reason=choice.get("finish_reason", "stop"),
|
|
125
|
+
usage=usage,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
async def stream(
|
|
129
|
+
self,
|
|
130
|
+
messages: Sequence[Message],
|
|
131
|
+
tools: Sequence[dict[str, Any]] | None = None,
|
|
132
|
+
temperature: float = 0.7,
|
|
133
|
+
max_tokens: int | None = None,
|
|
134
|
+
**kwargs: Any,
|
|
135
|
+
) -> AsyncIterator[StreamChunk]:
|
|
136
|
+
url = f"{self.base_url.rstrip('/')}/chat/completions"
|
|
137
|
+
payload: dict[str, Any] = {
|
|
138
|
+
"model": self.model,
|
|
139
|
+
"messages": self._convert_messages(messages),
|
|
140
|
+
"temperature": temperature,
|
|
141
|
+
"stream": True,
|
|
142
|
+
"stream_options": {"include_usage": True},
|
|
143
|
+
}
|
|
144
|
+
if max_tokens:
|
|
145
|
+
payload["max_tokens"] = max_tokens
|
|
146
|
+
if tools:
|
|
147
|
+
payload["tools"] = tools
|
|
148
|
+
|
|
149
|
+
self.require_api_key()
|
|
150
|
+
|
|
151
|
+
# Tool calls arrive in fragments keyed by index: the name in one chunk,
|
|
152
|
+
# the arguments spread over many. They are assembled here and emitted
|
|
153
|
+
# once complete, so the agent always sees a whole call.
|
|
154
|
+
pending: dict[int, dict[str, str]] = {}
|
|
155
|
+
|
|
156
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
157
|
+
async with client.stream("POST", url, headers=self._get_headers(), json=payload) as response:
|
|
158
|
+
await self._raise_for_status(response)
|
|
159
|
+
async for line in response.aiter_lines():
|
|
160
|
+
line = line.strip()
|
|
161
|
+
if not line.startswith("data: "):
|
|
162
|
+
continue
|
|
163
|
+
data_str = line[6:]
|
|
164
|
+
if data_str == "[DONE]":
|
|
165
|
+
break
|
|
166
|
+
try:
|
|
167
|
+
chunk_dict = json.loads(data_str)
|
|
168
|
+
except json.JSONDecodeError:
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
usage = None
|
|
172
|
+
if chunk_dict.get("usage"):
|
|
173
|
+
raw = chunk_dict["usage"]
|
|
174
|
+
usage = ProviderUsage(
|
|
175
|
+
prompt_tokens=raw.get("prompt_tokens", 0),
|
|
176
|
+
completion_tokens=raw.get("completion_tokens", 0),
|
|
177
|
+
total_tokens=raw.get("total_tokens", 0),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
choices = chunk_dict.get("choices") or []
|
|
181
|
+
if not choices:
|
|
182
|
+
if usage:
|
|
183
|
+
yield StreamChunk(usage=usage)
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
choice = choices[0]
|
|
187
|
+
delta = choice.get("delta", {})
|
|
188
|
+
finish_reason = choice.get("finish_reason")
|
|
189
|
+
|
|
190
|
+
for raw_tc in delta.get("tool_calls") or []:
|
|
191
|
+
index = raw_tc.get("index", 0)
|
|
192
|
+
slot = pending.setdefault(index, {"id": "", "name": "", "arguments": ""})
|
|
193
|
+
if raw_tc.get("id"):
|
|
194
|
+
slot["id"] = raw_tc["id"]
|
|
195
|
+
fn = raw_tc.get("function") or {}
|
|
196
|
+
if fn.get("name"):
|
|
197
|
+
slot["name"] = fn["name"]
|
|
198
|
+
if fn.get("arguments"):
|
|
199
|
+
slot["arguments"] += fn["arguments"]
|
|
200
|
+
|
|
201
|
+
text_delta = delta.get("content") or ""
|
|
202
|
+
if text_delta or usage:
|
|
203
|
+
yield StreamChunk(text_delta=text_delta, usage=usage)
|
|
204
|
+
|
|
205
|
+
if finish_reason:
|
|
206
|
+
for slot in pending.values():
|
|
207
|
+
yield StreamChunk(tool_call_delta=self._assemble_tool_call(slot))
|
|
208
|
+
pending.clear()
|
|
209
|
+
yield StreamChunk(finish_reason=finish_reason)
|
|
210
|
+
|
|
211
|
+
# A stream cut short before finish_reason still carries usable calls.
|
|
212
|
+
for slot in pending.values():
|
|
213
|
+
yield StreamChunk(tool_call_delta=self._assemble_tool_call(slot))
|
|
214
|
+
|
|
215
|
+
@staticmethod
|
|
216
|
+
def _assemble_tool_call(slot: dict[str, str]) -> ToolCall:
|
|
217
|
+
"""Turn an accumulated streaming fragment into a complete ToolCall."""
|
|
218
|
+
raw_args = slot.get("arguments") or "{}"
|
|
219
|
+
try:
|
|
220
|
+
arguments = json.loads(raw_args) if raw_args.strip() else {}
|
|
221
|
+
except json.JSONDecodeError:
|
|
222
|
+
arguments = {"raw": raw_args}
|
|
223
|
+
if not isinstance(arguments, dict):
|
|
224
|
+
arguments = {"value": arguments}
|
|
225
|
+
return ToolCall(id=slot.get("id") or "", name=slot.get("name") or "", arguments=arguments)
|
|
226
|
+
|
|
227
|
+
async def embeddings(
|
|
228
|
+
self,
|
|
229
|
+
texts: list[str],
|
|
230
|
+
model: str | None = None,
|
|
231
|
+
**kwargs: Any,
|
|
232
|
+
) -> list[list[float]]:
|
|
233
|
+
url = f"{self.base_url.rstrip('/')}/embeddings"
|
|
234
|
+
payload = {
|
|
235
|
+
"input": texts,
|
|
236
|
+
"model": model or "text-embedding-3-small",
|
|
237
|
+
}
|
|
238
|
+
self.require_api_key()
|
|
239
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
240
|
+
resp = await client.post(url, headers=self._get_headers(), json=payload)
|
|
241
|
+
await self._raise_for_status(resp)
|
|
242
|
+
data = resp.json()
|
|
243
|
+
return [item["embedding"] for item in data.get("data", [])]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class OpenRouterProvider(OpenAIProvider):
|
|
247
|
+
"""OpenRouter Provider for routing across hundreds of open/commercial models."""
|
|
248
|
+
|
|
249
|
+
DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
|
|
250
|
+
api_key_env = "OPENROUTER_API_KEY"
|
|
251
|
+
|
|
252
|
+
def __init__(
|
|
253
|
+
self,
|
|
254
|
+
model: str = "deepseek/deepseek-r1:free",
|
|
255
|
+
api_key: str | None = None,
|
|
256
|
+
base_url: str | None = None,
|
|
257
|
+
timeout: float = 60.0,
|
|
258
|
+
**kwargs: Any,
|
|
259
|
+
):
|
|
260
|
+
resolved_key = api_key or os.getenv("OPENROUTER_API_KEY", "")
|
|
261
|
+
resolved_url = base_url or os.getenv("OPENROUTER_BASE_URL", self.DEFAULT_BASE_URL)
|
|
262
|
+
super().__init__(
|
|
263
|
+
model=model,
|
|
264
|
+
api_key=resolved_key,
|
|
265
|
+
base_url=resolved_url,
|
|
266
|
+
timeout=timeout,
|
|
267
|
+
**kwargs,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def _get_headers(self) -> dict[str, str]:
|
|
271
|
+
headers = super()._get_headers()
|
|
272
|
+
headers["HTTP-Referer"] = "https://github.com/Ajayramaswamy2005/FlowAI"
|
|
273
|
+
headers["X-Title"] = "FlowAI Agent Harness"
|
|
274
|
+
return headers
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Provider Registry and Dynamic Factory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from flowai.providers.anthropic import AnthropicProvider
|
|
6
|
+
from flowai.providers.base import BaseProvider
|
|
7
|
+
from flowai.providers.gemini import GeminiProvider
|
|
8
|
+
from flowai.providers.nvidia import NvidiaProvider
|
|
9
|
+
from flowai.providers.ollama import OllamaProvider
|
|
10
|
+
from flowai.providers.openai import OpenAIProvider, OpenRouterProvider
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProviderRegistry:
|
|
14
|
+
"""Registry maintaining active provider classes and factory instantiations."""
|
|
15
|
+
|
|
16
|
+
_registry: dict[str, type[BaseProvider]] = {
|
|
17
|
+
"nvidia": NvidiaProvider,
|
|
18
|
+
"ollama": OllamaProvider,
|
|
19
|
+
"openai": OpenAIProvider,
|
|
20
|
+
"anthropic": AnthropicProvider,
|
|
21
|
+
"gemini": GeminiProvider,
|
|
22
|
+
"openrouter": OpenRouterProvider,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def register(cls, name: str, provider_cls: type[BaseProvider]) -> None:
|
|
27
|
+
"""Register a custom provider class."""
|
|
28
|
+
cls._registry[name.lower()] = provider_cls
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def get(cls, name: str) -> type[BaseProvider]:
|
|
32
|
+
"""Fetch provider class by name."""
|
|
33
|
+
provider_name = name.lower()
|
|
34
|
+
if provider_name not in cls._registry:
|
|
35
|
+
available = ", ".join(cls._registry.keys())
|
|
36
|
+
raise ValueError(f"Unknown provider '{name}'. Available providers: {available}")
|
|
37
|
+
return cls._registry[provider_name]
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def create(cls, name: str, model: str, **kwargs) -> BaseProvider:
|
|
41
|
+
"""Instantiate a provider by name."""
|
|
42
|
+
provider_cls = cls.get(name)
|
|
43
|
+
return provider_cls(model=model, **kwargs)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_provider(provider_or_name: str | BaseProvider, model: str = "gpt-4o", **kwargs) -> BaseProvider:
|
|
47
|
+
"""Convenience helper to resolve provider instance or name."""
|
|
48
|
+
if isinstance(provider_or_name, BaseProvider):
|
|
49
|
+
return provider_or_name
|
|
50
|
+
return ProviderRegistry.create(provider_or_name, model=model, **kwargs)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Session management and persistent conversation storage on disk."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from flowai.providers.base import Message
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SessionStore:
|
|
13
|
+
"""Manages persistent conversation sessions on disk (stored in ~/.flowai/sessions/)."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, storage_dir: Path | str | None = None):
|
|
16
|
+
if storage_dir is None:
|
|
17
|
+
self.storage_dir = Path.home() / ".flowai" / "sessions"
|
|
18
|
+
else:
|
|
19
|
+
self.storage_dir = Path(storage_dir)
|
|
20
|
+
self.storage_dir.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
def _get_path(self, session_id: str) -> Path:
|
|
23
|
+
return self.storage_dir / f"{session_id}.json"
|
|
24
|
+
|
|
25
|
+
def save(self, session_id: str, messages: list[Message], metadata: dict[str, Any] | None = None) -> Path:
|
|
26
|
+
"""Persist message history and metadata to disk."""
|
|
27
|
+
data = {
|
|
28
|
+
"session_id": session_id,
|
|
29
|
+
"metadata": metadata or {},
|
|
30
|
+
"messages": [m.model_dump() for m in messages],
|
|
31
|
+
}
|
|
32
|
+
path = self._get_path(session_id)
|
|
33
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
34
|
+
|
|
35
|
+
# Update a 'latest' pointer for instant resumption
|
|
36
|
+
latest_path = self.storage_dir / "latest.txt"
|
|
37
|
+
latest_path.write_text(session_id, encoding="utf-8")
|
|
38
|
+
return path
|
|
39
|
+
|
|
40
|
+
def load(self, session_id: str) -> list[Message]:
|
|
41
|
+
"""Load messages from an existing disk session."""
|
|
42
|
+
path = self._get_path(session_id)
|
|
43
|
+
if not path.exists():
|
|
44
|
+
return []
|
|
45
|
+
try:
|
|
46
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
47
|
+
return [Message(**m) for m in raw.get("messages", [])]
|
|
48
|
+
except Exception:
|
|
49
|
+
return []
|
|
50
|
+
|
|
51
|
+
def get_latest_session_id(self) -> str | None:
|
|
52
|
+
"""Retrieve ID of the most recent session."""
|
|
53
|
+
latest_path = self.storage_dir / "latest.txt"
|
|
54
|
+
if latest_path.exists():
|
|
55
|
+
sid = latest_path.read_text(encoding="utf-8").strip()
|
|
56
|
+
if self._get_path(sid).exists():
|
|
57
|
+
return sid
|
|
58
|
+
# Fallback to the newest json file in the directory
|
|
59
|
+
files = sorted(self.storage_dir.glob("*.json"), key=lambda f: f.stat().st_mtime, reverse=True)
|
|
60
|
+
return files[0].stem if files else None
|
|
61
|
+
|
|
62
|
+
def list_sessions(self) -> list[dict[str, Any]]:
|
|
63
|
+
"""List all saved sessions with summary statistics."""
|
|
64
|
+
sessions = []
|
|
65
|
+
for p in self.storage_dir.glob("*.json"):
|
|
66
|
+
try:
|
|
67
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
68
|
+
sessions.append({
|
|
69
|
+
"session_id": data.get("session_id", p.stem),
|
|
70
|
+
"turns": len(data.get("messages", [])),
|
|
71
|
+
"path": str(p),
|
|
72
|
+
"modified": p.stat().st_mtime,
|
|
73
|
+
})
|
|
74
|
+
except Exception:
|
|
75
|
+
continue
|
|
76
|
+
return sorted(sessions, key=lambda s: s["modified"], reverse=True)
|