aiecs 1.7.17__py3-none-any.whl → 1.8.4__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.
Potentially problematic release.
This version of aiecs might be problematic. Click here for more details.
- aiecs/__init__.py +1 -1
- aiecs/application/knowledge_graph/extractors/llm_entity_extractor.py +5 -1
- aiecs/application/knowledge_graph/retrieval/query_intent_classifier.py +7 -5
- aiecs/config/config.py +3 -0
- aiecs/domain/agent/hybrid_agent.py +93 -12
- aiecs/domain/agent/knowledge_aware_agent.py +3 -2
- aiecs/domain/agent/llm_agent.py +2 -0
- aiecs/llm/callbacks/custom_callbacks.py +9 -4
- aiecs/llm/client_factory.py +14 -6
- aiecs/llm/clients/base_client.py +45 -4
- aiecs/llm/clients/googleai_client.py +105 -4
- aiecs/llm/clients/openai_client.py +12 -0
- aiecs/llm/clients/openai_compatible_mixin.py +42 -2
- aiecs/llm/clients/openrouter_client.py +272 -0
- aiecs/llm/clients/vertex_client.py +79 -5
- aiecs/llm/clients/xai_client.py +41 -3
- aiecs/llm/protocols.py +19 -1
- aiecs/llm/utils/image_utils.py +179 -0
- aiecs/main.py +2 -2
- aiecs/tools/task_tools/scraper_tool.py +39 -2
- {aiecs-1.7.17.dist-info → aiecs-1.8.4.dist-info}/METADATA +4 -2
- {aiecs-1.7.17.dist-info → aiecs-1.8.4.dist-info}/RECORD +26 -24
- {aiecs-1.7.17.dist-info → aiecs-1.8.4.dist-info}/WHEEL +0 -0
- {aiecs-1.7.17.dist-info → aiecs-1.8.4.dist-info}/entry_points.txt +0 -0
- {aiecs-1.7.17.dist-info → aiecs-1.8.4.dist-info}/licenses/LICENSE +0 -0
- {aiecs-1.7.17.dist-info → aiecs-1.8.4.dist-info}/top_level.txt +0 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import logging
|
|
3
3
|
import os
|
|
4
|
-
|
|
4
|
+
import base64
|
|
5
|
+
from typing import Optional, List, AsyncGenerator, Dict, Any
|
|
5
6
|
|
|
6
7
|
from google import genai
|
|
7
8
|
from google.genai import types
|
|
@@ -14,6 +15,7 @@ from aiecs.llm.clients.base_client import (
|
|
|
14
15
|
RateLimitError,
|
|
15
16
|
)
|
|
16
17
|
from aiecs.config.config import get_settings
|
|
18
|
+
from aiecs.llm.utils.image_utils import parse_image_source, ImageContent
|
|
17
19
|
|
|
18
20
|
logger = logging.getLogger(__name__)
|
|
19
21
|
|
|
@@ -91,6 +93,33 @@ class GoogleAIClient(BaseLLMClient):
|
|
|
91
93
|
parts = []
|
|
92
94
|
if msg.content:
|
|
93
95
|
parts.append(types.Part(text=msg.content))
|
|
96
|
+
|
|
97
|
+
# Add images if present
|
|
98
|
+
if msg.images:
|
|
99
|
+
for image_source in msg.images:
|
|
100
|
+
image_content = parse_image_source(image_source)
|
|
101
|
+
|
|
102
|
+
if image_content.is_url():
|
|
103
|
+
# For URLs, use inline_data with downloaded content
|
|
104
|
+
# Note: Google AI SDK may support URL directly, but we'll use base64 for compatibility
|
|
105
|
+
try:
|
|
106
|
+
import urllib.request
|
|
107
|
+
with urllib.request.urlopen(image_content.get_url()) as response:
|
|
108
|
+
image_bytes = response.read()
|
|
109
|
+
parts.append(types.Part.from_bytes(
|
|
110
|
+
data=image_bytes,
|
|
111
|
+
mime_type=image_content.mime_type
|
|
112
|
+
))
|
|
113
|
+
except Exception as e:
|
|
114
|
+
logger.warning(f"Failed to download image from URL: {e}")
|
|
115
|
+
else:
|
|
116
|
+
# Convert to bytes for inline_data
|
|
117
|
+
base64_data = image_content.get_base64_data()
|
|
118
|
+
image_bytes = base64.b64decode(base64_data)
|
|
119
|
+
parts.append(types.Part.from_bytes(
|
|
120
|
+
data=image_bytes,
|
|
121
|
+
mime_type=image_content.mime_type
|
|
122
|
+
))
|
|
94
123
|
|
|
95
124
|
for tool_call in msg.tool_calls:
|
|
96
125
|
func = tool_call.get("function", {})
|
|
@@ -120,10 +149,42 @@ class GoogleAIClient(BaseLLMClient):
|
|
|
120
149
|
# Handle regular messages (user, assistant without tool_calls)
|
|
121
150
|
else:
|
|
122
151
|
role = "model" if msg.role == "assistant" else msg.role
|
|
152
|
+
parts = []
|
|
153
|
+
|
|
154
|
+
# Add text content if present
|
|
123
155
|
if msg.content:
|
|
156
|
+
parts.append(types.Part(text=msg.content))
|
|
157
|
+
|
|
158
|
+
# Add images if present
|
|
159
|
+
if msg.images:
|
|
160
|
+
for image_source in msg.images:
|
|
161
|
+
image_content = parse_image_source(image_source)
|
|
162
|
+
|
|
163
|
+
if image_content.is_url():
|
|
164
|
+
# Download URL and convert to bytes
|
|
165
|
+
try:
|
|
166
|
+
import urllib.request
|
|
167
|
+
with urllib.request.urlopen(image_content.get_url()) as response:
|
|
168
|
+
image_bytes = response.read()
|
|
169
|
+
parts.append(types.Part.from_bytes(
|
|
170
|
+
data=image_bytes,
|
|
171
|
+
mime_type=image_content.mime_type
|
|
172
|
+
))
|
|
173
|
+
except Exception as e:
|
|
174
|
+
logger.warning(f"Failed to download image from URL: {e}")
|
|
175
|
+
else:
|
|
176
|
+
# Convert to bytes for inline_data
|
|
177
|
+
base64_data = image_content.get_base64_data()
|
|
178
|
+
image_bytes = base64.b64decode(base64_data)
|
|
179
|
+
parts.append(types.Part.from_bytes(
|
|
180
|
+
data=image_bytes,
|
|
181
|
+
mime_type=image_content.mime_type
|
|
182
|
+
))
|
|
183
|
+
|
|
184
|
+
if parts:
|
|
124
185
|
contents.append(types.Content(
|
|
125
186
|
role=role,
|
|
126
|
-
parts=
|
|
187
|
+
parts=parts
|
|
127
188
|
))
|
|
128
189
|
|
|
129
190
|
return contents
|
|
@@ -134,10 +195,30 @@ class GoogleAIClient(BaseLLMClient):
|
|
|
134
195
|
model: Optional[str] = None,
|
|
135
196
|
temperature: float = 0.7,
|
|
136
197
|
max_tokens: Optional[int] = None,
|
|
198
|
+
context: Optional[Dict[str, Any]] = None,
|
|
137
199
|
system_instruction: Optional[str] = None,
|
|
138
200
|
**kwargs,
|
|
139
201
|
) -> LLMResponse:
|
|
140
|
-
"""
|
|
202
|
+
"""
|
|
203
|
+
Generate text using Google AI (google.genai SDK).
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
messages: List of conversation messages
|
|
207
|
+
model: Model name (optional, uses default if not provided)
|
|
208
|
+
temperature: Sampling temperature (0.0 to 1.0)
|
|
209
|
+
max_tokens: Maximum tokens to generate
|
|
210
|
+
context: Optional context dictionary containing metadata such as:
|
|
211
|
+
- user_id: User identifier for tracking/billing
|
|
212
|
+
- tenant_id: Tenant identifier for multi-tenant setups
|
|
213
|
+
- request_id: Request identifier for tracing
|
|
214
|
+
- session_id: Session identifier
|
|
215
|
+
- Any other custom metadata for observability or middleware
|
|
216
|
+
system_instruction: System instruction for the model
|
|
217
|
+
**kwargs: Additional provider-specific parameters
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
LLMResponse with generated text and metadata
|
|
221
|
+
"""
|
|
141
222
|
client = self._init_google_ai()
|
|
142
223
|
|
|
143
224
|
# Get model name from config if not provided
|
|
@@ -237,10 +318,30 @@ class GoogleAIClient(BaseLLMClient):
|
|
|
237
318
|
model: Optional[str] = None,
|
|
238
319
|
temperature: float = 0.7,
|
|
239
320
|
max_tokens: Optional[int] = None,
|
|
321
|
+
context: Optional[Dict[str, Any]] = None,
|
|
240
322
|
system_instruction: Optional[str] = None,
|
|
241
323
|
**kwargs,
|
|
242
324
|
) -> AsyncGenerator[str, None]:
|
|
243
|
-
"""
|
|
325
|
+
"""
|
|
326
|
+
Stream text generation using Google AI (google.genai SDK).
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
messages: List of conversation messages
|
|
330
|
+
model: Model name (optional, uses default if not provided)
|
|
331
|
+
temperature: Sampling temperature (0.0 to 1.0)
|
|
332
|
+
max_tokens: Maximum tokens to generate
|
|
333
|
+
context: Optional context dictionary containing metadata such as:
|
|
334
|
+
- user_id: User identifier for tracking/billing
|
|
335
|
+
- tenant_id: Tenant identifier for multi-tenant setups
|
|
336
|
+
- request_id: Request identifier for tracing
|
|
337
|
+
- session_id: Session identifier
|
|
338
|
+
- Any other custom metadata for observability or middleware
|
|
339
|
+
system_instruction: System instruction for the model
|
|
340
|
+
**kwargs: Additional provider-specific parameters
|
|
341
|
+
|
|
342
|
+
Yields:
|
|
343
|
+
Text tokens as they are generated
|
|
344
|
+
"""
|
|
244
345
|
client = self._init_google_ai()
|
|
245
346
|
|
|
246
347
|
# Get model name from config if not provided
|
|
@@ -52,6 +52,7 @@ class OpenAIClient(BaseLLMClient, OpenAICompatibleFunctionCallingMixin):
|
|
|
52
52
|
model: Optional[str] = None,
|
|
53
53
|
temperature: float = 0.7,
|
|
54
54
|
max_tokens: Optional[int] = None,
|
|
55
|
+
context: Optional[Dict[str, Any]] = None,
|
|
55
56
|
functions: Optional[List[Dict[str, Any]]] = None,
|
|
56
57
|
tools: Optional[List[Dict[str, Any]]] = None,
|
|
57
58
|
tool_choice: Optional[Any] = None,
|
|
@@ -65,6 +66,11 @@ class OpenAIClient(BaseLLMClient, OpenAICompatibleFunctionCallingMixin):
|
|
|
65
66
|
model: Model name (optional)
|
|
66
67
|
temperature: Temperature for generation
|
|
67
68
|
max_tokens: Maximum tokens to generate
|
|
69
|
+
context: Optional context dictionary containing metadata such as:
|
|
70
|
+
- user_id: User identifier for tracking/billing
|
|
71
|
+
- tenant_id: Tenant identifier for multi-tenant setups
|
|
72
|
+
- request_id: Request identifier for tracing
|
|
73
|
+
- session_id: Session identifier
|
|
68
74
|
functions: List of function schemas (legacy format)
|
|
69
75
|
tools: List of tool schemas (new format, recommended)
|
|
70
76
|
tool_choice: Tool choice strategy ("auto", "none", or specific tool)
|
|
@@ -103,6 +109,7 @@ class OpenAIClient(BaseLLMClient, OpenAICompatibleFunctionCallingMixin):
|
|
|
103
109
|
model: Optional[str] = None,
|
|
104
110
|
temperature: float = 0.7,
|
|
105
111
|
max_tokens: Optional[int] = None,
|
|
112
|
+
context: Optional[Dict[str, Any]] = None,
|
|
106
113
|
functions: Optional[List[Dict[str, Any]]] = None,
|
|
107
114
|
tools: Optional[List[Dict[str, Any]]] = None,
|
|
108
115
|
tool_choice: Optional[Any] = None,
|
|
@@ -117,6 +124,11 @@ class OpenAIClient(BaseLLMClient, OpenAICompatibleFunctionCallingMixin):
|
|
|
117
124
|
model: Model name (optional)
|
|
118
125
|
temperature: Temperature for generation
|
|
119
126
|
max_tokens: Maximum tokens to generate
|
|
127
|
+
context: Optional context dictionary containing metadata such as:
|
|
128
|
+
- user_id: User identifier for tracking/billing
|
|
129
|
+
- tenant_id: Tenant identifier for multi-tenant setups
|
|
130
|
+
- request_id: Request identifier for tracing
|
|
131
|
+
- session_id: Session identifier
|
|
120
132
|
functions: List of function schemas (legacy format)
|
|
121
133
|
tools: List of tool schemas (new format, recommended)
|
|
122
134
|
tool_choice: Tool choice strategy ("auto", "none", or specific tool)
|
|
@@ -11,6 +11,7 @@ from dataclasses import dataclass
|
|
|
11
11
|
from openai import AsyncOpenAI
|
|
12
12
|
|
|
13
13
|
from .base_client import LLMMessage, LLMResponse
|
|
14
|
+
from aiecs.llm.utils.image_utils import parse_image_source, ImageContent
|
|
14
15
|
|
|
15
16
|
logger = logging.getLogger(__name__)
|
|
16
17
|
|
|
@@ -49,7 +50,7 @@ class OpenAICompatibleFunctionCallingMixin:
|
|
|
49
50
|
|
|
50
51
|
def _convert_messages_to_openai_format(self, messages: List[LLMMessage]) -> List[Dict[str, Any]]:
|
|
51
52
|
"""
|
|
52
|
-
Convert LLMMessage list to OpenAI message format (support tool calls).
|
|
53
|
+
Convert LLMMessage list to OpenAI message format (support tool calls and vision).
|
|
53
54
|
|
|
54
55
|
Args:
|
|
55
56
|
messages: List of LLMMessage objects
|
|
@@ -60,8 +61,47 @@ class OpenAICompatibleFunctionCallingMixin:
|
|
|
60
61
|
openai_messages = []
|
|
61
62
|
for msg in messages:
|
|
62
63
|
msg_dict: Dict[str, Any] = {"role": msg.role}
|
|
63
|
-
|
|
64
|
+
|
|
65
|
+
# Handle multimodal content (text + images)
|
|
66
|
+
if msg.images:
|
|
67
|
+
# Build content array with text and images
|
|
68
|
+
content_array = []
|
|
69
|
+
|
|
70
|
+
# Add text content if present
|
|
71
|
+
if msg.content:
|
|
72
|
+
content_array.append({"type": "text", "text": msg.content})
|
|
73
|
+
|
|
74
|
+
# Add images
|
|
75
|
+
for image_source in msg.images:
|
|
76
|
+
image_content = parse_image_source(image_source)
|
|
77
|
+
|
|
78
|
+
if image_content.is_url():
|
|
79
|
+
# Use URL directly
|
|
80
|
+
content_array.append({
|
|
81
|
+
"type": "image_url",
|
|
82
|
+
"image_url": {
|
|
83
|
+
"url": image_content.get_url(),
|
|
84
|
+
"detail": image_content.detail,
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
else:
|
|
88
|
+
# Convert to base64 data URI
|
|
89
|
+
base64_data = image_content.get_base64_data()
|
|
90
|
+
mime_type = image_content.mime_type
|
|
91
|
+
data_uri = f"data:{mime_type};base64,{base64_data}"
|
|
92
|
+
content_array.append({
|
|
93
|
+
"type": "image_url",
|
|
94
|
+
"image_url": {
|
|
95
|
+
"url": data_uri,
|
|
96
|
+
"detail": image_content.detail,
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
msg_dict["content"] = content_array
|
|
101
|
+
elif msg.content is not None:
|
|
102
|
+
# Text-only content
|
|
64
103
|
msg_dict["content"] = msg.content
|
|
104
|
+
|
|
65
105
|
if msg.tool_calls:
|
|
66
106
|
msg_dict["tool_calls"] = msg.tool_calls
|
|
67
107
|
if msg.tool_call_id:
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
from openai import AsyncOpenAI
|
|
2
|
+
from aiecs.config.config import get_settings
|
|
3
|
+
from aiecs.llm.clients.base_client import (
|
|
4
|
+
BaseLLMClient,
|
|
5
|
+
LLMMessage,
|
|
6
|
+
LLMResponse,
|
|
7
|
+
ProviderNotAvailableError,
|
|
8
|
+
RateLimitError,
|
|
9
|
+
)
|
|
10
|
+
from aiecs.llm.clients.openai_compatible_mixin import (
|
|
11
|
+
OpenAICompatibleFunctionCallingMixin,
|
|
12
|
+
StreamChunk,
|
|
13
|
+
)
|
|
14
|
+
from tenacity import (
|
|
15
|
+
retry,
|
|
16
|
+
stop_after_attempt,
|
|
17
|
+
wait_exponential,
|
|
18
|
+
retry_if_exception_type,
|
|
19
|
+
)
|
|
20
|
+
import logging
|
|
21
|
+
from typing import Dict, Optional, List, AsyncGenerator, cast, Any
|
|
22
|
+
|
|
23
|
+
# Lazy import to avoid circular dependency
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get_config_loader():
|
|
27
|
+
"""Lazy import of config loader to avoid circular dependency"""
|
|
28
|
+
from aiecs.llm.config import get_llm_config_loader
|
|
29
|
+
|
|
30
|
+
return get_llm_config_loader()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OpenRouterClient(BaseLLMClient, OpenAICompatibleFunctionCallingMixin):
|
|
37
|
+
"""OpenRouter provider client using OpenAI-compatible API"""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
super().__init__("OpenRouter")
|
|
41
|
+
self.settings = get_settings()
|
|
42
|
+
self._openai_client: Optional[AsyncOpenAI] = None
|
|
43
|
+
self._model_map: Optional[Dict[str, str]] = None
|
|
44
|
+
|
|
45
|
+
def _get_openai_client(self) -> AsyncOpenAI:
|
|
46
|
+
"""Lazy initialization of OpenAI client for OpenRouter"""
|
|
47
|
+
if not self._openai_client:
|
|
48
|
+
api_key = self._get_api_key()
|
|
49
|
+
self._openai_client = AsyncOpenAI(
|
|
50
|
+
api_key=api_key,
|
|
51
|
+
base_url="https://openrouter.ai/api/v1",
|
|
52
|
+
timeout=360.0,
|
|
53
|
+
)
|
|
54
|
+
return self._openai_client
|
|
55
|
+
|
|
56
|
+
def _get_api_key(self) -> str:
|
|
57
|
+
"""Get API key from settings"""
|
|
58
|
+
api_key = getattr(self.settings, "openrouter_api_key", None)
|
|
59
|
+
if not api_key:
|
|
60
|
+
raise ProviderNotAvailableError("OpenRouter API key not configured. Set OPENROUTER_API_KEY.")
|
|
61
|
+
return api_key
|
|
62
|
+
|
|
63
|
+
def _get_model_map(self) -> Dict[str, str]:
|
|
64
|
+
"""Get model mappings from configuration"""
|
|
65
|
+
if self._model_map is None:
|
|
66
|
+
try:
|
|
67
|
+
loader = _get_config_loader()
|
|
68
|
+
provider_config = loader.get_provider_config("OpenRouter")
|
|
69
|
+
if provider_config and provider_config.model_mappings:
|
|
70
|
+
self._model_map = provider_config.model_mappings
|
|
71
|
+
else:
|
|
72
|
+
self._model_map = {}
|
|
73
|
+
except Exception as e:
|
|
74
|
+
self.logger.warning(f"Failed to load model mappings from config: {e}")
|
|
75
|
+
self._model_map = {}
|
|
76
|
+
return self._model_map
|
|
77
|
+
|
|
78
|
+
def _get_extra_headers(self, **kwargs) -> Dict[str, str]:
|
|
79
|
+
"""
|
|
80
|
+
Get extra headers for OpenRouter API.
|
|
81
|
+
|
|
82
|
+
Supports HTTP-Referer and X-Title headers from kwargs or settings.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
**kwargs: May contain http_referer and x_title
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Dictionary with extra headers
|
|
89
|
+
"""
|
|
90
|
+
extra_headers: Dict[str, str] = {}
|
|
91
|
+
|
|
92
|
+
# Get from kwargs first, then from settings
|
|
93
|
+
http_referer = kwargs.get("http_referer") or getattr(self.settings, "openrouter_http_referer", None)
|
|
94
|
+
x_title = kwargs.get("x_title") or getattr(self.settings, "openrouter_x_title", None)
|
|
95
|
+
|
|
96
|
+
if http_referer:
|
|
97
|
+
extra_headers["HTTP-Referer"] = http_referer
|
|
98
|
+
if x_title:
|
|
99
|
+
extra_headers["X-Title"] = x_title
|
|
100
|
+
|
|
101
|
+
return extra_headers
|
|
102
|
+
|
|
103
|
+
@retry(
|
|
104
|
+
stop=stop_after_attempt(3),
|
|
105
|
+
wait=wait_exponential(multiplier=1, min=4, max=10),
|
|
106
|
+
retry=retry_if_exception_type((Exception, RateLimitError)),
|
|
107
|
+
)
|
|
108
|
+
async def generate_text(
|
|
109
|
+
self,
|
|
110
|
+
messages: List[LLMMessage],
|
|
111
|
+
model: Optional[str] = None,
|
|
112
|
+
temperature: float = 0.7,
|
|
113
|
+
max_tokens: Optional[int] = None,
|
|
114
|
+
functions: Optional[List[Dict[str, Any]]] = None,
|
|
115
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
116
|
+
tool_choice: Optional[Any] = None,
|
|
117
|
+
**kwargs,
|
|
118
|
+
) -> LLMResponse:
|
|
119
|
+
"""
|
|
120
|
+
Generate text using OpenRouter API via OpenAI library.
|
|
121
|
+
|
|
122
|
+
OpenRouter API is OpenAI-compatible, so it supports Function Calling and Vision.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
messages: List of LLM messages
|
|
126
|
+
model: Model name (optional, uses default from config if not provided)
|
|
127
|
+
temperature: Temperature for generation
|
|
128
|
+
max_tokens: Maximum tokens to generate
|
|
129
|
+
functions: List of function schemas (legacy format)
|
|
130
|
+
tools: List of tool schemas (new format, recommended)
|
|
131
|
+
tool_choice: Tool choice strategy ("auto", "none", or specific tool)
|
|
132
|
+
http_referer: Optional HTTP-Referer header for OpenRouter rankings
|
|
133
|
+
x_title: Optional X-Title header for OpenRouter rankings
|
|
134
|
+
**kwargs: Additional arguments passed to OpenRouter API
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
LLMResponse with content and optional function_call information
|
|
138
|
+
"""
|
|
139
|
+
# Check API key availability
|
|
140
|
+
api_key = self._get_api_key()
|
|
141
|
+
if not api_key:
|
|
142
|
+
raise ProviderNotAvailableError("OpenRouter API key is not configured.")
|
|
143
|
+
|
|
144
|
+
client = self._get_openai_client()
|
|
145
|
+
|
|
146
|
+
# Get model name from config if not provided
|
|
147
|
+
selected_model = model or self._get_default_model() or "openai/gpt-4o"
|
|
148
|
+
|
|
149
|
+
# Get model mappings from config
|
|
150
|
+
model_map = self._get_model_map()
|
|
151
|
+
api_model = model_map.get(selected_model, selected_model)
|
|
152
|
+
|
|
153
|
+
# Extract extra headers from kwargs
|
|
154
|
+
extra_headers = self._get_extra_headers(**kwargs)
|
|
155
|
+
|
|
156
|
+
# Remove extra header kwargs to avoid passing them to API
|
|
157
|
+
kwargs_clean = {k: v for k, v in kwargs.items() if k not in ("http_referer", "x_title")}
|
|
158
|
+
|
|
159
|
+
# Add extra_headers to kwargs if present
|
|
160
|
+
if extra_headers:
|
|
161
|
+
kwargs_clean["extra_headers"] = extra_headers
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
# Use mixin method for Function Calling support
|
|
165
|
+
response = await self._generate_text_with_function_calling(
|
|
166
|
+
client=client,
|
|
167
|
+
messages=messages,
|
|
168
|
+
model=api_model,
|
|
169
|
+
temperature=temperature,
|
|
170
|
+
max_tokens=max_tokens,
|
|
171
|
+
functions=functions,
|
|
172
|
+
tools=tools,
|
|
173
|
+
tool_choice=tool_choice,
|
|
174
|
+
**kwargs_clean,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Override provider and model name for OpenRouter
|
|
178
|
+
response.provider = self.provider_name
|
|
179
|
+
response.model = selected_model
|
|
180
|
+
|
|
181
|
+
return response
|
|
182
|
+
|
|
183
|
+
except Exception as e:
|
|
184
|
+
if "rate limit" in str(e).lower() or "429" in str(e):
|
|
185
|
+
raise RateLimitError(f"OpenRouter rate limit exceeded: {str(e)}")
|
|
186
|
+
logger.error(f"OpenRouter API error: {str(e)}")
|
|
187
|
+
raise
|
|
188
|
+
|
|
189
|
+
async def stream_text( # type: ignore[override]
|
|
190
|
+
self,
|
|
191
|
+
messages: List[LLMMessage],
|
|
192
|
+
model: Optional[str] = None,
|
|
193
|
+
temperature: float = 0.7,
|
|
194
|
+
max_tokens: Optional[int] = None,
|
|
195
|
+
functions: Optional[List[Dict[str, Any]]] = None,
|
|
196
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
197
|
+
tool_choice: Optional[Any] = None,
|
|
198
|
+
return_chunks: bool = False,
|
|
199
|
+
**kwargs,
|
|
200
|
+
) -> AsyncGenerator[Any, None]:
|
|
201
|
+
"""
|
|
202
|
+
Stream text using OpenRouter API via OpenAI library.
|
|
203
|
+
|
|
204
|
+
OpenRouter API is OpenAI-compatible, so it supports Function Calling and Vision.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
messages: List of LLM messages
|
|
208
|
+
model: Model name (optional, uses default from config if not provided)
|
|
209
|
+
temperature: Temperature for generation
|
|
210
|
+
max_tokens: Maximum tokens to generate
|
|
211
|
+
functions: List of function schemas (legacy format)
|
|
212
|
+
tools: List of tool schemas (new format, recommended)
|
|
213
|
+
tool_choice: Tool choice strategy ("auto", "none", or specific tool)
|
|
214
|
+
return_chunks: If True, returns StreamChunk objects with tool_calls info; if False, returns str tokens only
|
|
215
|
+
http_referer: Optional HTTP-Referer header for OpenRouter rankings
|
|
216
|
+
x_title: Optional X-Title header for OpenRouter rankings
|
|
217
|
+
**kwargs: Additional arguments passed to OpenRouter API
|
|
218
|
+
|
|
219
|
+
Yields:
|
|
220
|
+
str or StreamChunk: Text tokens as they are generated, or StreamChunk objects if return_chunks=True
|
|
221
|
+
"""
|
|
222
|
+
# Check API key availability
|
|
223
|
+
api_key = self._get_api_key()
|
|
224
|
+
if not api_key:
|
|
225
|
+
raise ProviderNotAvailableError("OpenRouter API key is not configured.")
|
|
226
|
+
|
|
227
|
+
client = self._get_openai_client()
|
|
228
|
+
|
|
229
|
+
# Get model name from config if not provided
|
|
230
|
+
selected_model = model or self._get_default_model() or "openai/gpt-4o"
|
|
231
|
+
|
|
232
|
+
# Get model mappings from config
|
|
233
|
+
model_map = self._get_model_map()
|
|
234
|
+
api_model = model_map.get(selected_model, selected_model)
|
|
235
|
+
|
|
236
|
+
# Extract extra headers from kwargs
|
|
237
|
+
extra_headers = self._get_extra_headers(**kwargs)
|
|
238
|
+
|
|
239
|
+
# Remove extra header kwargs to avoid passing them to API
|
|
240
|
+
kwargs_clean = {k: v for k, v in kwargs.items() if k not in ("http_referer", "x_title")}
|
|
241
|
+
|
|
242
|
+
# Add extra_headers to kwargs if present
|
|
243
|
+
if extra_headers:
|
|
244
|
+
kwargs_clean["extra_headers"] = extra_headers
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
# Use mixin method for Function Calling support
|
|
248
|
+
async for chunk in self._stream_text_with_function_calling(
|
|
249
|
+
client=client,
|
|
250
|
+
messages=messages,
|
|
251
|
+
model=api_model,
|
|
252
|
+
temperature=temperature,
|
|
253
|
+
max_tokens=max_tokens,
|
|
254
|
+
functions=functions,
|
|
255
|
+
tools=tools,
|
|
256
|
+
tool_choice=tool_choice,
|
|
257
|
+
return_chunks=return_chunks,
|
|
258
|
+
**kwargs_clean,
|
|
259
|
+
):
|
|
260
|
+
yield chunk
|
|
261
|
+
|
|
262
|
+
except Exception as e:
|
|
263
|
+
if "rate limit" in str(e).lower() or "429" in str(e):
|
|
264
|
+
raise RateLimitError(f"OpenRouter rate limit exceeded: {str(e)}")
|
|
265
|
+
logger.error(f"OpenRouter API streaming error: {str(e)}")
|
|
266
|
+
raise
|
|
267
|
+
|
|
268
|
+
async def close(self):
|
|
269
|
+
"""Clean up resources"""
|
|
270
|
+
if self._openai_client:
|
|
271
|
+
await self._openai_client.close()
|
|
272
|
+
self._openai_client = None
|
|
@@ -4,6 +4,7 @@ import logging
|
|
|
4
4
|
import os
|
|
5
5
|
import warnings
|
|
6
6
|
import hashlib
|
|
7
|
+
import base64
|
|
7
8
|
from typing import Dict, Any, Optional, List, AsyncGenerator, Union
|
|
8
9
|
import vertexai
|
|
9
10
|
from vertexai.generative_models import (
|
|
@@ -16,6 +17,8 @@ from vertexai.generative_models import (
|
|
|
16
17
|
Part,
|
|
17
18
|
)
|
|
18
19
|
|
|
20
|
+
from aiecs.llm.utils.image_utils import parse_image_source, ImageContent
|
|
21
|
+
|
|
19
22
|
logger = logging.getLogger(__name__)
|
|
20
23
|
|
|
21
24
|
# Try to import CachedContent for prompt caching support
|
|
@@ -450,6 +453,24 @@ class VertexAIClient(BaseLLMClient, GoogleFunctionCallingMixin):
|
|
|
450
453
|
parts = []
|
|
451
454
|
if msg.content:
|
|
452
455
|
parts.append(Part.from_text(msg.content))
|
|
456
|
+
|
|
457
|
+
# Add images if present
|
|
458
|
+
if msg.images:
|
|
459
|
+
for image_source in msg.images:
|
|
460
|
+
image_content = parse_image_source(image_source)
|
|
461
|
+
|
|
462
|
+
if image_content.is_url():
|
|
463
|
+
parts.append(Part.from_uri(
|
|
464
|
+
uri=image_content.get_url(),
|
|
465
|
+
mime_type=image_content.mime_type
|
|
466
|
+
))
|
|
467
|
+
else:
|
|
468
|
+
base64_data = image_content.get_base64_data()
|
|
469
|
+
image_bytes = base64.b64decode(base64_data)
|
|
470
|
+
parts.append(Part.from_bytes(
|
|
471
|
+
data=image_bytes,
|
|
472
|
+
mime_type=image_content.mime_type
|
|
473
|
+
))
|
|
453
474
|
|
|
454
475
|
for tool_call in msg.tool_calls:
|
|
455
476
|
func = tool_call.get("function", {})
|
|
@@ -481,11 +502,34 @@ class VertexAIClient(BaseLLMClient, GoogleFunctionCallingMixin):
|
|
|
481
502
|
# Handle regular messages (user, assistant without tool_calls)
|
|
482
503
|
else:
|
|
483
504
|
role = "model" if msg.role == "assistant" else msg.role
|
|
505
|
+
parts = []
|
|
506
|
+
|
|
507
|
+
# Add text content if present
|
|
484
508
|
if msg.content:
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
509
|
+
parts.append(Part.from_text(msg.content))
|
|
510
|
+
|
|
511
|
+
# Add images if present
|
|
512
|
+
if msg.images:
|
|
513
|
+
for image_source in msg.images:
|
|
514
|
+
image_content = parse_image_source(image_source)
|
|
515
|
+
|
|
516
|
+
if image_content.is_url():
|
|
517
|
+
# Use Part.from_uri for URLs
|
|
518
|
+
parts.append(Part.from_uri(
|
|
519
|
+
uri=image_content.get_url(),
|
|
520
|
+
mime_type=image_content.mime_type
|
|
521
|
+
))
|
|
522
|
+
else:
|
|
523
|
+
# Convert to bytes for inline_data
|
|
524
|
+
base64_data = image_content.get_base64_data()
|
|
525
|
+
image_bytes = base64.b64decode(base64_data)
|
|
526
|
+
parts.append(Part.from_bytes(
|
|
527
|
+
data=image_bytes,
|
|
528
|
+
mime_type=image_content.mime_type
|
|
529
|
+
))
|
|
530
|
+
|
|
531
|
+
if parts:
|
|
532
|
+
contents.append(Content(role=role, parts=parts))
|
|
489
533
|
|
|
490
534
|
return contents
|
|
491
535
|
|
|
@@ -495,13 +539,36 @@ class VertexAIClient(BaseLLMClient, GoogleFunctionCallingMixin):
|
|
|
495
539
|
model: Optional[str] = None,
|
|
496
540
|
temperature: float = 0.7,
|
|
497
541
|
max_tokens: Optional[int] = None,
|
|
542
|
+
context: Optional[Dict[str, Any]] = None,
|
|
498
543
|
functions: Optional[List[Dict[str, Any]]] = None,
|
|
499
544
|
tools: Optional[List[Dict[str, Any]]] = None,
|
|
500
545
|
tool_choice: Optional[Any] = None,
|
|
501
546
|
system_instruction: Optional[str] = None,
|
|
502
547
|
**kwargs,
|
|
503
548
|
) -> LLMResponse:
|
|
504
|
-
"""
|
|
549
|
+
"""
|
|
550
|
+
Generate text using Vertex AI.
|
|
551
|
+
|
|
552
|
+
Args:
|
|
553
|
+
messages: List of conversation messages
|
|
554
|
+
model: Model name (optional, uses default if not provided)
|
|
555
|
+
temperature: Sampling temperature (0.0 to 1.0)
|
|
556
|
+
max_tokens: Maximum tokens to generate
|
|
557
|
+
context: Optional context dictionary containing metadata such as:
|
|
558
|
+
- user_id: User identifier for tracking/billing
|
|
559
|
+
- tenant_id: Tenant identifier for multi-tenant setups
|
|
560
|
+
- request_id: Request identifier for tracing
|
|
561
|
+
- session_id: Session identifier
|
|
562
|
+
- Any other custom metadata for observability or middleware
|
|
563
|
+
functions: List of function schemas (legacy format)
|
|
564
|
+
tools: List of tool schemas (new format, recommended)
|
|
565
|
+
tool_choice: Tool choice strategy
|
|
566
|
+
system_instruction: System instruction for the model
|
|
567
|
+
**kwargs: Additional provider-specific parameters
|
|
568
|
+
|
|
569
|
+
Returns:
|
|
570
|
+
LLMResponse with generated text and metadata
|
|
571
|
+
"""
|
|
505
572
|
self._init_vertex_ai()
|
|
506
573
|
|
|
507
574
|
# Get model name from config if not provided
|
|
@@ -889,6 +956,7 @@ class VertexAIClient(BaseLLMClient, GoogleFunctionCallingMixin):
|
|
|
889
956
|
model: Optional[str] = None,
|
|
890
957
|
temperature: float = 0.7,
|
|
891
958
|
max_tokens: Optional[int] = None,
|
|
959
|
+
context: Optional[Dict[str, Any]] = None,
|
|
892
960
|
functions: Optional[List[Dict[str, Any]]] = None,
|
|
893
961
|
tools: Optional[List[Dict[str, Any]]] = None,
|
|
894
962
|
tool_choice: Optional[Any] = None,
|
|
@@ -904,6 +972,12 @@ class VertexAIClient(BaseLLMClient, GoogleFunctionCallingMixin):
|
|
|
904
972
|
model: Model name (optional)
|
|
905
973
|
temperature: Temperature for generation
|
|
906
974
|
max_tokens: Maximum tokens to generate
|
|
975
|
+
context: Optional context dictionary containing metadata such as:
|
|
976
|
+
- user_id: User identifier for tracking/billing
|
|
977
|
+
- tenant_id: Tenant identifier for multi-tenant setups
|
|
978
|
+
- request_id: Request identifier for tracing
|
|
979
|
+
- session_id: Session identifier
|
|
980
|
+
- Any other custom metadata for observability or middleware
|
|
907
981
|
functions: List of function schemas (legacy format)
|
|
908
982
|
tools: List of tool schemas (new format)
|
|
909
983
|
tool_choice: Tool choice strategy (not used for Google Vertex AI)
|