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,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
|
+
}
|
flowai/plugins/loader.py
ADDED
|
@@ -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.")
|