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
flowai/providers/base.py
ADDED
|
@@ -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", [])]
|