crewplus 0.2.99__tar.gz → 0.3.2__tar.gz
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.
- {crewplus-0.2.99 → crewplus-0.3.2}/PKG-INFO +2 -2
- crewplus-0.3.2/crewplus/__pycache__/__init__.cpython-311.pyc.1535298853824 +0 -0
- crewplus-0.3.2/crewplus/services/__pycache__/__init__.cpython-311.pyc.1535299230960 +0 -0
- crewplus-0.3.2/crewplus/services/__pycache__/gemini_chat_model.cpython-311.pyc.1535298773120 +0 -0
- crewplus-0.3.2/crewplus/services/__pycache__/runtime_model_factory.cpython-311.pyc.1959047557728 +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/gemini_chat_model.py +44 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/model_load_balancer.py +13 -0
- crewplus-0.3.2/crewplus/services/runtime_model_factory.py +400 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/vectorstores/milvus/vdb_service.py +76 -1
- {crewplus-0.2.99 → crewplus-0.3.2}/pyproject.toml +2 -2
- crewplus-0.3.2/tests/output/drawing_ingestion/gpt-5.4_run1.md +88 -0
- crewplus-0.3.2/tests/output/drawing_ingestion/gpt-5.4_run2.md +93 -0
- crewplus-0.3.2/tests/output/drawing_ingestion/gpt-5.6-luna_run1.md +59 -0
- crewplus-0.3.2/tests/output/drawing_ingestion/gpt-5.6-luna_run2.md +46 -0
- crewplus-0.3.2/tests/output/drawing_ingestion/gpt-5.6-terra_run1.md +48 -0
- crewplus-0.3.2/tests/output/drawing_ingestion/gpt-5.6-terra_run2.md +55 -0
- crewplus-0.3.2/tests/output/drawing_ingestion/page24_150dpi.png +0 -0
- crewplus-0.3.2/tests/output/drawing_ingestion/report.md +63 -0
- crewplus-0.3.2/tests/output/drawing_ingestion/results.json +605 -0
- crewplus-0.3.2/tests/output/drawing_ingestion/session1-luna-terra/gpt-5.6-luna_run1.md +79 -0
- crewplus-0.3.2/tests/output/drawing_ingestion/session1-luna-terra/gpt-5.6-luna_run2.md +60 -0
- crewplus-0.3.2/tests/output/drawing_ingestion/session1-luna-terra/gpt-5.6-terra_run1.md +47 -0
- crewplus-0.3.2/tests/output/drawing_ingestion/session1-luna-terra/gpt-5.6-terra_run2.md +46 -0
- crewplus-0.3.2/tests/output/drawing_ingestion/session1-luna-terra/report.md +58 -0
- crewplus-0.3.2/tests/output/drawing_ingestion/session1-luna-terra/results.json +427 -0
- crewplus-0.3.2/tests/output/drawing_ingestion/session2-55-sol/gpt-5.5_run1.md +54 -0
- crewplus-0.3.2/tests/output/drawing_ingestion/session2-55-sol/gpt-5.5_run2.md +43 -0
- crewplus-0.3.2/tests/output/drawing_ingestion/session2-55-sol/gpt-5.6-sol_run1.md +45 -0
- crewplus-0.3.2/tests/output/drawing_ingestion/session2-55-sol/gpt-5.6-sol_run2.md +56 -0
- crewplus-0.3.2/tests/output/drawing_ingestion/session2-55-sol/page24_150dpi.png +0 -0
- crewplus-0.3.2/tests/output/drawing_ingestion/session2-55-sol/report.md +58 -0
- crewplus-0.3.2/tests/output/drawing_ingestion/session2-55-sol/results.json +421 -0
- crewplus-0.3.2/tests/output/drawing_ingestion/session2-55-sol/summary-report-55-sol.md +346 -0
- crewplus-0.3.2/tests/output/drawing_ingestion/summary-report.md +287 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/tests/test_bash_tools.py +532 -532
- {crewplus-0.2.99 → crewplus-0.3.2}/tests/test_claude_taskflow_schema.py +688 -688
- {crewplus-0.2.99 → crewplus-0.3.2}/tests/test_gemini3_pro_preview.py +563 -563
- crewplus-0.3.2/tests/test_ops_models_streaming.py +172 -0
- crewplus-0.3.2/tests/test_pdf_drawing_ingestion_gpt55_sol.py +106 -0
- crewplus-0.3.2/tests/test_pdf_drawing_ingestion_gpt56.py +397 -0
- crewplus-0.3.2/tests/test_runtime_model_factory.py +79 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/LICENSE +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/README.md +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/__init__.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/callbacks/__init__.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/callbacks/async_langfuse_handler.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/__init__.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/azure_chat_model.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/capabilities/__init__.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/capabilities/base.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/capabilities/bash.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/capabilities/registry.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/capabilities/web_search.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/claude_chat_model.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/feedback.md +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/feedback_manager.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/init_services.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/oai_claude_chat_model.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/schemas/feedback.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/tracing_manager.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/utils/__init__.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/utils/schema_action.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/utils/schema_document_updater.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/utils/tracing_util.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/vectorstores/milvus/__init__.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/vectorstores/milvus/milvus_schema_manager.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/vectorstores/milvus/schema_milvus.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/docs/GeminiChatModel.md +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/docs/ModelLoadBalancer.md +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/docs/VDBService.md +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/docs/index.md +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/tests/README.md +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/tests/conftest.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/tests/test_claude_api.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/tests/test_claude_custom_endpoint_integration.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/tests/test_claude_langchain_openai.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/tests/test_custom_claude_endpoint.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/tests/test_gemini_bind_tools.py +0 -0
- {crewplus-0.2.99 → crewplus-0.3.2}/tests/test_oai_claude_chat_model.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: crewplus
|
|
3
|
-
Version: 0.2
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Base services for CrewPlus AI applications
|
|
5
5
|
Author-Email: Tim Liu <tim@opsmateai.com>
|
|
6
6
|
License: MIT
|
|
@@ -11,7 +11,7 @@ Project-URL: Issues, https://github.com/your-org/crewplus-base/issues
|
|
|
11
11
|
Requires-Python: <4.0,>=3.11
|
|
12
12
|
Requires-Dist: langchain<1.0.0,>=0.3.25
|
|
13
13
|
Requires-Dist: langchain-openai>=0.3.24
|
|
14
|
-
Requires-Dist: google-genai>=1.
|
|
14
|
+
Requires-Dist: google-genai>=1.51.0
|
|
15
15
|
Requires-Dist: anthropic[vertex]>=0.76.0
|
|
16
16
|
Requires-Dist: langchain-milvus<0.3.0,>=0.2.1
|
|
17
17
|
Requires-Dist: langfuse<4.0.0,>=3.1.3
|
|
Binary file
|
|
Binary file
|
crewplus-0.3.2/crewplus/services/__pycache__/runtime_model_factory.cpython-311.pyc.1959047557728
ADDED
|
Binary file
|
|
@@ -257,6 +257,17 @@ class GeminiChatModel(BaseChatModel):
|
|
|
257
257
|
max_tokens: Optional[int] = Field(default=None, description="Maximum tokens to generate")
|
|
258
258
|
top_p: Optional[float] = Field(default=None, description="Top-p sampling parameter")
|
|
259
259
|
top_k: Optional[int] = Field(default=None, description="Top-k sampling parameter")
|
|
260
|
+
|
|
261
|
+
# Thinking configuration. All optional: when none of the three is set, no
|
|
262
|
+
# thinking_config is sent at all and the model's own default applies.
|
|
263
|
+
# Gemini 3.x takes thinking_level, Gemini 2.5 takes thinking_budget; sending
|
|
264
|
+
# both is rejected by the API, so _build_thinking_config picks one by model family.
|
|
265
|
+
thinking_level: Optional[str] = Field(
|
|
266
|
+
default=None, description="MINIMAL/LOW/MEDIUM/HIGH, Gemini 3.x only")
|
|
267
|
+
thinking_budget: Optional[int] = Field(
|
|
268
|
+
default=None, description="Thinking token budget, Gemini 2.5 only")
|
|
269
|
+
include_thoughts: Optional[bool] = Field(
|
|
270
|
+
default=None, description="Whether thought parts are returned in the response")
|
|
260
271
|
|
|
261
272
|
# Vertex AI specific configuration
|
|
262
273
|
use_vertex_ai: bool = Field(default=False, description="Use Vertex AI instead of Google AI Platform")
|
|
@@ -804,6 +815,33 @@ class GeminiChatModel(BaseChatModel):
|
|
|
804
815
|
except Exception as e:
|
|
805
816
|
self.logger.error(f"Failed to process message part at index {i}: {part_spec}. Error: {e}", exc_info=True)
|
|
806
817
|
|
|
818
|
+
def _build_thinking_config(self) -> Optional[types.ThinkingConfig]:
|
|
819
|
+
"""Builds the thinking config for the current model family.
|
|
820
|
+
|
|
821
|
+
Gemini 3.x accepts ``thinking_level`` (MINIMAL/LOW/MEDIUM/HIGH), Gemini 2.5
|
|
822
|
+
accepts ``thinking_budget`` (an integer). Passing both is rejected by the
|
|
823
|
+
service, so the parameter for the other family is ignored rather than being
|
|
824
|
+
an error -- that is what lets a single model config entry serve both
|
|
825
|
+
generations, so switching models only means changing deployment_name.
|
|
826
|
+
|
|
827
|
+
Returns None when nothing is configured, in which case the caller omits
|
|
828
|
+
thinking_config entirely and the model's default behaviour applies.
|
|
829
|
+
"""
|
|
830
|
+
is_gemini3 = self.model_name.startswith("gemini-3")
|
|
831
|
+
kwargs: Dict[str, Any] = {}
|
|
832
|
+
|
|
833
|
+
value = self.thinking_level if is_gemini3 else self.thinking_budget
|
|
834
|
+
if value is not None:
|
|
835
|
+
if is_gemini3:
|
|
836
|
+
kwargs["thinking_level"] = str(value).upper()
|
|
837
|
+
else:
|
|
838
|
+
kwargs["thinking_budget"] = int(value)
|
|
839
|
+
|
|
840
|
+
if self.include_thoughts is not None:
|
|
841
|
+
kwargs["include_thoughts"] = self.include_thoughts
|
|
842
|
+
|
|
843
|
+
return types.ThinkingConfig(**kwargs) if kwargs else None
|
|
844
|
+
|
|
807
845
|
def _prepare_generation_config(
|
|
808
846
|
self,
|
|
809
847
|
messages: List[BaseMessage],
|
|
@@ -826,6 +864,12 @@ class GeminiChatModel(BaseChatModel):
|
|
|
826
864
|
if stop:
|
|
827
865
|
config_params["stop_sequences"] = stop
|
|
828
866
|
|
|
867
|
+
# Omit thinking_config entirely when nothing is configured, so the model
|
|
868
|
+
# default applies instead of us pinning a tier.
|
|
869
|
+
thinking_config = self._build_thinking_config()
|
|
870
|
+
if thinking_config is not None:
|
|
871
|
+
config_params["thinking_config"] = thinking_config
|
|
872
|
+
|
|
829
873
|
# Handle system instructions
|
|
830
874
|
system_prompts = [msg.content for msg in messages if isinstance(msg, SystemMessage) and msg.content]
|
|
831
875
|
if system_prompts:
|
|
@@ -57,6 +57,13 @@ class ModelLoadBalancer:
|
|
|
57
57
|
"code_execution": None,
|
|
58
58
|
"url_context": None,
|
|
59
59
|
}
|
|
60
|
+
|
|
61
|
+
# Gemini thinking parameters. Passed through only when present in the model
|
|
62
|
+
# config, so an entry that omits them leaves the model fields at None and no
|
|
63
|
+
# thinking_config reaches the API. GeminiChatModel picks thinking_level for
|
|
64
|
+
# Gemini 3.x and thinking_budget for Gemini 2.5, ignoring the other one.
|
|
65
|
+
GEMINI_THINKING_KEYS = ("thinking_level", "thinking_budget", "include_thoughts")
|
|
66
|
+
|
|
60
67
|
def __init__(self,
|
|
61
68
|
config_path: Optional[str] = "config/models_config.json",
|
|
62
69
|
config_data: Optional[Dict] = None,
|
|
@@ -267,6 +274,9 @@ class ModelLoadBalancer:
|
|
|
267
274
|
kwargs['temperature'] = model_config['temperature']
|
|
268
275
|
if 'max_tokens' in model_config:
|
|
269
276
|
kwargs['max_tokens'] = model_config['max_tokens']
|
|
277
|
+
for key in self.GEMINI_THINKING_KEYS:
|
|
278
|
+
if key in model_config:
|
|
279
|
+
kwargs[key] = model_config[key]
|
|
270
280
|
if disable_streaming:
|
|
271
281
|
kwargs['disable_streaming'] = True
|
|
272
282
|
return GeminiChatModel(**kwargs)
|
|
@@ -334,6 +344,9 @@ class ModelLoadBalancer:
|
|
|
334
344
|
kwargs['temperature'] = model_config['temperature']
|
|
335
345
|
if 'max_tokens' in model_config:
|
|
336
346
|
kwargs['max_tokens'] = model_config['max_tokens']
|
|
347
|
+
for key in self.GEMINI_THINKING_KEYS:
|
|
348
|
+
if key in model_config:
|
|
349
|
+
kwargs[key] = model_config[key]
|
|
337
350
|
if disable_streaming:
|
|
338
351
|
kwargs['disable_streaming'] = True
|
|
339
352
|
return GeminiChatModel(**kwargs)
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import random
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from langchain_openai import AzureOpenAIEmbeddings, ChatOpenAI, OpenAIEmbeddings
|
|
9
|
+
|
|
10
|
+
from .azure_chat_model import TracedAzureChatOpenAI
|
|
11
|
+
from .claude_chat_model import ClaudeChatModel
|
|
12
|
+
from .gemini_chat_model import GeminiChatModel
|
|
13
|
+
from .oai_claude_chat_model import OAIClaudeChatModel
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RuntimeModelConfigError(ValueError):
|
|
17
|
+
"""Raised when a runtime model policy is missing required fields."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _first_present(data: Dict[str, Any], *keys: str) -> Any:
|
|
21
|
+
for key in keys:
|
|
22
|
+
value = data.get(key)
|
|
23
|
+
if value is not None and value != "":
|
|
24
|
+
return value
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _credentials(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
29
|
+
creds = config.get("credentials") or {}
|
|
30
|
+
if not isinstance(creds, dict):
|
|
31
|
+
raise RuntimeModelConfigError("model credentials must be a dictionary")
|
|
32
|
+
return creds
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _api_key(config: Dict[str, Any]) -> str:
|
|
36
|
+
creds = _credentials(config)
|
|
37
|
+
value = _first_present(config, "api_key", "apiKey") or _first_present(creds, "api_key", "apiKey")
|
|
38
|
+
if not value:
|
|
39
|
+
raise RuntimeModelConfigError("model apiKey is required")
|
|
40
|
+
return str(value)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _provider_type(config: Dict[str, Any]) -> str:
|
|
44
|
+
provider = _first_present(config, "providerType", "provider_type", "provider")
|
|
45
|
+
if not provider:
|
|
46
|
+
raise RuntimeModelConfigError("model providerType is required")
|
|
47
|
+
return str(provider).strip().lower().replace("-", "_")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _model_id(config: Dict[str, Any]) -> str:
|
|
51
|
+
value = _first_present(config, "modelId", "model_id", "model", "modelName", "model_name")
|
|
52
|
+
if not value:
|
|
53
|
+
raise RuntimeModelConfigError("modelId is required")
|
|
54
|
+
return str(value)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _deployment_name(config: Dict[str, Any]) -> str:
|
|
58
|
+
return str(_first_present(config, "deploymentName", "deployment_name", "deployment") or _model_id(config))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _base_url(config: Dict[str, Any]) -> Optional[str]:
|
|
62
|
+
value = _first_present(config, "baseUrl", "base_url", "apiBase", "api_base", "azureEndpoint", "azure_endpoint")
|
|
63
|
+
return str(value).rstrip("/") if value else None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _api_version(config: Dict[str, Any]) -> Optional[str]:
|
|
67
|
+
value = _first_present(config, "apiVersion", "api_version", "openaiApiVersion", "openai_api_version")
|
|
68
|
+
return str(value) if value else None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _dimensions(config: Dict[str, Any]) -> Optional[int]:
|
|
72
|
+
value = _first_present(config, "dimensions", "embeddingDimensions", "embedding_dims")
|
|
73
|
+
if value is None:
|
|
74
|
+
return None
|
|
75
|
+
return int(value)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _with_v1(base_url: str) -> str:
|
|
79
|
+
normalized = base_url.rstrip("/")
|
|
80
|
+
if normalized.endswith("/v1"):
|
|
81
|
+
return normalized
|
|
82
|
+
return f"{normalized}/v1"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def model_config_fingerprint(config: Dict[str, Any]) -> str:
|
|
86
|
+
"""Return a stable fingerprint that avoids storing secrets in cache keys."""
|
|
87
|
+
redacted = dict(config)
|
|
88
|
+
creds = _credentials(config) if isinstance(config.get("credentials"), dict) else {}
|
|
89
|
+
if creds:
|
|
90
|
+
secret_payload = json.dumps(creds, sort_keys=True, default=str)
|
|
91
|
+
redacted["credentials"] = {
|
|
92
|
+
"secret_hash": hashlib.sha256(secret_payload.encode("utf-8")).hexdigest()
|
|
93
|
+
}
|
|
94
|
+
payload = json.dumps(redacted, sort_keys=True, default=str)
|
|
95
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class RuntimeModelFactory:
|
|
99
|
+
"""
|
|
100
|
+
Creates model instances from runtime dictionaries without modifying the
|
|
101
|
+
process-wide ModelLoadBalancer or environment variables.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self, logger: Optional[logging.Logger] = None):
|
|
105
|
+
self.logger = logger or logging.getLogger(__name__)
|
|
106
|
+
self._cache: Dict[str, Any] = {}
|
|
107
|
+
|
|
108
|
+
def create_embedding_model(self, model_config: Dict[str, Any], *, cache_scope: str = "runtime"):
|
|
109
|
+
cache_key = self._cache_key(cache_scope, "embedding", model_config)
|
|
110
|
+
if cache_key not in self._cache:
|
|
111
|
+
self._cache[cache_key] = self._instantiate_embedding(model_config)
|
|
112
|
+
return self._cache[cache_key]
|
|
113
|
+
|
|
114
|
+
def create_vision_model(self, model_config: Dict[str, Any], *, cache_scope: str = "runtime"):
|
|
115
|
+
cache_key = self._cache_key(cache_scope, "vision", model_config)
|
|
116
|
+
if cache_key not in self._cache:
|
|
117
|
+
self._cache[cache_key] = self._instantiate_chat_model(model_config)
|
|
118
|
+
return self._cache[cache_key]
|
|
119
|
+
|
|
120
|
+
def create_chat_model(self, model_config: Dict[str, Any], *, cache_scope: str = "runtime"):
|
|
121
|
+
cache_key = self._cache_key(cache_scope, "chat", model_config)
|
|
122
|
+
if cache_key not in self._cache:
|
|
123
|
+
self._cache[cache_key] = self._instantiate_chat_model(model_config)
|
|
124
|
+
return self._cache[cache_key]
|
|
125
|
+
|
|
126
|
+
def _cache_key(self, scope: str, capability: str, model_config: Dict[str, Any]) -> str:
|
|
127
|
+
tenant = _first_present(model_config, "tenantId", "tenant_id") or scope
|
|
128
|
+
connection_id = _first_present(model_config, "connectionId", "connection_id") or "no-connection"
|
|
129
|
+
return f"{tenant}:{connection_id}:{capability}:{model_config_fingerprint(model_config)}"
|
|
130
|
+
|
|
131
|
+
def _instantiate_embedding(self, model_config: Dict[str, Any]):
|
|
132
|
+
provider = _provider_type(model_config)
|
|
133
|
+
model = _model_id(model_config)
|
|
134
|
+
api_key = _api_key(model_config)
|
|
135
|
+
dimensions = _dimensions(model_config)
|
|
136
|
+
|
|
137
|
+
if provider in {"azure_openai", "azure_openai_embeddings"}:
|
|
138
|
+
api_version = _api_version(model_config)
|
|
139
|
+
base_url = _base_url(model_config)
|
|
140
|
+
if not api_version:
|
|
141
|
+
raise RuntimeModelConfigError("apiVersion is required for Azure OpenAI embeddings")
|
|
142
|
+
if not base_url:
|
|
143
|
+
raise RuntimeModelConfigError("baseUrl is required for Azure OpenAI embeddings")
|
|
144
|
+
|
|
145
|
+
kwargs = {
|
|
146
|
+
"model": model,
|
|
147
|
+
"azure_deployment": _deployment_name(model_config),
|
|
148
|
+
"openai_api_version": api_version,
|
|
149
|
+
"api_key": api_key,
|
|
150
|
+
"azure_endpoint": base_url,
|
|
151
|
+
"chunk_size": int(model_config.get("chunk_size", 16)),
|
|
152
|
+
"request_timeout": int(model_config.get("request_timeout", 60)),
|
|
153
|
+
"max_retries": int(model_config.get("max_retries", 2)),
|
|
154
|
+
}
|
|
155
|
+
if dimensions is not None:
|
|
156
|
+
kwargs["dimensions"] = dimensions
|
|
157
|
+
return AzureOpenAIEmbeddings(**kwargs)
|
|
158
|
+
|
|
159
|
+
if provider == "openai":
|
|
160
|
+
kwargs = {"model": model, "api_key": api_key}
|
|
161
|
+
if dimensions is not None:
|
|
162
|
+
kwargs["dimensions"] = dimensions
|
|
163
|
+
return OpenAIEmbeddings(**kwargs)
|
|
164
|
+
|
|
165
|
+
if provider == "openai_compat":
|
|
166
|
+
base_url = _base_url(model_config)
|
|
167
|
+
if not base_url:
|
|
168
|
+
raise RuntimeModelConfigError("baseUrl is required for OpenAI-compatible embeddings")
|
|
169
|
+
kwargs = {"model": model, "api_key": api_key, "base_url": _with_v1(base_url)}
|
|
170
|
+
if dimensions is not None:
|
|
171
|
+
kwargs["dimensions"] = dimensions
|
|
172
|
+
return OpenAIEmbeddings(**kwargs)
|
|
173
|
+
|
|
174
|
+
raise RuntimeModelConfigError(f"Unsupported runtime embedding provider: {provider}")
|
|
175
|
+
|
|
176
|
+
def _instantiate_chat_model(self, model_config: Dict[str, Any]):
|
|
177
|
+
provider = _provider_type(model_config)
|
|
178
|
+
model = _model_id(model_config)
|
|
179
|
+
api_key = _api_key(model_config) if provider not in {"vertex_google", "vertex_anthropic", "vertex_ai"} else None
|
|
180
|
+
temperature = float(model_config.get("temperature", 0.0))
|
|
181
|
+
max_tokens = model_config.get("max_tokens")
|
|
182
|
+
disable_streaming = bool(model_config.get("disable_streaming", False))
|
|
183
|
+
|
|
184
|
+
if provider == "azure_openai":
|
|
185
|
+
api_version = _api_version(model_config)
|
|
186
|
+
base_url = _base_url(model_config)
|
|
187
|
+
if not api_version:
|
|
188
|
+
raise RuntimeModelConfigError("apiVersion is required for Azure OpenAI models")
|
|
189
|
+
if not base_url:
|
|
190
|
+
raise RuntimeModelConfigError("baseUrl is required for Azure OpenAI models")
|
|
191
|
+
kwargs = {
|
|
192
|
+
"azure_deployment": _deployment_name(model_config),
|
|
193
|
+
"openai_api_version": api_version,
|
|
194
|
+
"azure_endpoint": base_url,
|
|
195
|
+
"openai_api_key": api_key,
|
|
196
|
+
"temperature": temperature,
|
|
197
|
+
}
|
|
198
|
+
if disable_streaming:
|
|
199
|
+
kwargs["disable_streaming"] = True
|
|
200
|
+
return TracedAzureChatOpenAI(**kwargs)
|
|
201
|
+
|
|
202
|
+
if provider == "openai":
|
|
203
|
+
kwargs = {"model": model, "openai_api_key": api_key, "temperature": temperature}
|
|
204
|
+
if max_tokens is not None:
|
|
205
|
+
kwargs["max_tokens"] = int(max_tokens)
|
|
206
|
+
if disable_streaming:
|
|
207
|
+
kwargs["disable_streaming"] = True
|
|
208
|
+
return ChatOpenAI(**kwargs)
|
|
209
|
+
|
|
210
|
+
if provider == "openai_compat":
|
|
211
|
+
base_url = _base_url(model_config)
|
|
212
|
+
if not base_url:
|
|
213
|
+
raise RuntimeModelConfigError("baseUrl is required for OpenAI-compatible models")
|
|
214
|
+
kwargs = {
|
|
215
|
+
"model": model,
|
|
216
|
+
"openai_api_key": api_key,
|
|
217
|
+
"base_url": _with_v1(base_url),
|
|
218
|
+
"temperature": temperature,
|
|
219
|
+
}
|
|
220
|
+
if max_tokens is not None:
|
|
221
|
+
kwargs["max_tokens"] = int(max_tokens)
|
|
222
|
+
if disable_streaming:
|
|
223
|
+
kwargs["disable_streaming"] = True
|
|
224
|
+
return ChatOpenAI(**kwargs)
|
|
225
|
+
|
|
226
|
+
if provider == "google":
|
|
227
|
+
kwargs = {
|
|
228
|
+
"google_api_key": api_key,
|
|
229
|
+
"model_name": _deployment_name(model_config),
|
|
230
|
+
"temperature": temperature,
|
|
231
|
+
}
|
|
232
|
+
if max_tokens is not None:
|
|
233
|
+
kwargs["max_tokens"] = int(max_tokens)
|
|
234
|
+
if disable_streaming:
|
|
235
|
+
kwargs["disable_streaming"] = True
|
|
236
|
+
return GeminiChatModel(**kwargs)
|
|
237
|
+
|
|
238
|
+
if provider in {"vertex_google", "vertex_ai"}:
|
|
239
|
+
project_id = _first_present(model_config, "projectId", "project_id", "gcpProjectId", "gcp_project_id")
|
|
240
|
+
location = _first_present(model_config, "location", "region", "gcpRegion", "gcp_region")
|
|
241
|
+
if not project_id or not location:
|
|
242
|
+
raise RuntimeModelConfigError("projectId and location are required for Vertex AI models")
|
|
243
|
+
kwargs = {
|
|
244
|
+
"use_vertex_ai": True,
|
|
245
|
+
"model_name": _deployment_name(model_config).split("@")[0],
|
|
246
|
+
"project_id": str(project_id),
|
|
247
|
+
"location": str(location),
|
|
248
|
+
"temperature": temperature,
|
|
249
|
+
}
|
|
250
|
+
service_account_file = _first_present(model_config, "serviceAccountFile", "service_account_file")
|
|
251
|
+
if service_account_file:
|
|
252
|
+
kwargs["service_account_file"] = str(service_account_file)
|
|
253
|
+
if max_tokens is not None:
|
|
254
|
+
kwargs["max_tokens"] = int(max_tokens)
|
|
255
|
+
if disable_streaming:
|
|
256
|
+
kwargs["disable_streaming"] = True
|
|
257
|
+
return GeminiChatModel(**kwargs)
|
|
258
|
+
|
|
259
|
+
if provider == "vertex_anthropic":
|
|
260
|
+
project_id = _first_present(model_config, "projectId", "project_id", "gcpProjectId", "gcp_project_id")
|
|
261
|
+
location = _first_present(model_config, "location", "region", "gcpRegion", "gcp_region")
|
|
262
|
+
if not project_id or not location:
|
|
263
|
+
raise RuntimeModelConfigError("projectId and location are required for Vertex Anthropic models")
|
|
264
|
+
kwargs = {
|
|
265
|
+
"model_name": _deployment_name(model_config).split("@")[0],
|
|
266
|
+
"project_id": str(project_id),
|
|
267
|
+
"location": str(location),
|
|
268
|
+
"temperature": temperature,
|
|
269
|
+
}
|
|
270
|
+
service_account_file = _first_present(model_config, "serviceAccountFile", "service_account_file")
|
|
271
|
+
if service_account_file:
|
|
272
|
+
kwargs["service_account_file"] = str(service_account_file)
|
|
273
|
+
if max_tokens is not None:
|
|
274
|
+
kwargs["max_tokens"] = int(max_tokens)
|
|
275
|
+
if disable_streaming:
|
|
276
|
+
kwargs["disable_streaming"] = True
|
|
277
|
+
return ClaudeChatModel(**kwargs)
|
|
278
|
+
|
|
279
|
+
if provider == "anthropic_compat":
|
|
280
|
+
base_url = _base_url(model_config)
|
|
281
|
+
if not base_url:
|
|
282
|
+
raise RuntimeModelConfigError("baseUrl is required for Anthropic-compatible models")
|
|
283
|
+
kwargs = {
|
|
284
|
+
"model_name": _deployment_name(model_config),
|
|
285
|
+
"use_custom_endpoint": True,
|
|
286
|
+
"api_key": api_key,
|
|
287
|
+
"base_url": base_url,
|
|
288
|
+
"temperature": temperature,
|
|
289
|
+
}
|
|
290
|
+
if max_tokens is not None:
|
|
291
|
+
kwargs["max_tokens"] = int(max_tokens)
|
|
292
|
+
if disable_streaming:
|
|
293
|
+
kwargs["disable_streaming"] = True
|
|
294
|
+
return ClaudeChatModel(**kwargs)
|
|
295
|
+
|
|
296
|
+
if provider == "custom_openai":
|
|
297
|
+
base_url = _base_url(model_config)
|
|
298
|
+
if not base_url:
|
|
299
|
+
raise RuntimeModelConfigError("baseUrl is required for custom OpenAI-compatible Claude models")
|
|
300
|
+
kwargs = {
|
|
301
|
+
"model": _deployment_name(model_config),
|
|
302
|
+
"api_key": api_key,
|
|
303
|
+
"base_url": _with_v1(base_url),
|
|
304
|
+
"capabilities_config": model_config.get("capabilities", {}),
|
|
305
|
+
"temperature": temperature,
|
|
306
|
+
}
|
|
307
|
+
if max_tokens is not None:
|
|
308
|
+
kwargs["max_tokens"] = int(max_tokens)
|
|
309
|
+
if disable_streaming:
|
|
310
|
+
kwargs["streaming"] = False
|
|
311
|
+
return OAIClaudeChatModel(**kwargs)
|
|
312
|
+
|
|
313
|
+
raise RuntimeModelConfigError(f"Unsupported runtime chat provider: {provider}")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class RuntimeModelPool:
|
|
317
|
+
"""Selects runtime models from a capability-specific model pool."""
|
|
318
|
+
|
|
319
|
+
RETRYABLE_STATUS_MARKERS = ("429", "408", "500", "502", "503", "504", "timeout", "temporarily", "rate limit")
|
|
320
|
+
|
|
321
|
+
def __init__(
|
|
322
|
+
self,
|
|
323
|
+
factory: Optional[RuntimeModelFactory] = None,
|
|
324
|
+
logger: Optional[logging.Logger] = None,
|
|
325
|
+
cooldown_seconds: int = 60,
|
|
326
|
+
max_cooldown_seconds: int = 300,
|
|
327
|
+
):
|
|
328
|
+
self.factory = factory or RuntimeModelFactory(logger=logger)
|
|
329
|
+
self.logger = logger or logging.getLogger(__name__)
|
|
330
|
+
self.cooldown_seconds = cooldown_seconds
|
|
331
|
+
self.max_cooldown_seconds = max_cooldown_seconds
|
|
332
|
+
self._cooldowns: Dict[str, float] = {}
|
|
333
|
+
self._failures: Dict[str, int] = {}
|
|
334
|
+
|
|
335
|
+
def select_model(self, policy: Dict[str, Any], capability: str) -> Dict[str, Any]:
|
|
336
|
+
candidates = self._active_candidates(policy, capability)
|
|
337
|
+
strategy = str((policy.get(capability) or {}).get("selectionStrategy") or policy.get("selectionStrategy") or "random")
|
|
338
|
+
if strategy == "weighted_random":
|
|
339
|
+
return self._weighted_random(candidates)
|
|
340
|
+
return random.choice(candidates)
|
|
341
|
+
|
|
342
|
+
def get_model(self, policy: Dict[str, Any], capability: str, *, cache_scope: str = "runtime") -> Tuple[Any, Dict[str, Any]]:
|
|
343
|
+
selected = self.select_model(policy, capability)
|
|
344
|
+
if capability == "embedding":
|
|
345
|
+
model = self.factory.create_embedding_model(selected, cache_scope=cache_scope)
|
|
346
|
+
elif capability == "vision":
|
|
347
|
+
model = self.factory.create_vision_model(selected, cache_scope=cache_scope)
|
|
348
|
+
else:
|
|
349
|
+
model = self.factory.create_chat_model(selected, cache_scope=cache_scope)
|
|
350
|
+
return model, selected
|
|
351
|
+
|
|
352
|
+
def report_success(self, model_key: str) -> None:
|
|
353
|
+
self._failures.pop(model_key, None)
|
|
354
|
+
|
|
355
|
+
def report_failure(self, model_key: str, error: Exception) -> None:
|
|
356
|
+
self._failures[model_key] = self._failures.get(model_key, 0) + 1
|
|
357
|
+
if self.is_retryable_error(error):
|
|
358
|
+
retry_after = self._retry_after_seconds(error)
|
|
359
|
+
cooldown = retry_after if retry_after is not None else self.cooldown_seconds
|
|
360
|
+
cooldown = min(cooldown, self.max_cooldown_seconds)
|
|
361
|
+
self._cooldowns[model_key] = time.time() + cooldown
|
|
362
|
+
|
|
363
|
+
def model_key(self, model_config: Dict[str, Any]) -> str:
|
|
364
|
+
connection_id = _first_present(model_config, "connectionId", "connection_id") or "no-connection"
|
|
365
|
+
return f"{connection_id}:{_provider_type(model_config)}:{_deployment_name(model_config)}:{model_config_fingerprint(model_config)}"
|
|
366
|
+
|
|
367
|
+
def is_retryable_error(self, error: Exception) -> bool:
|
|
368
|
+
text = str(error).lower()
|
|
369
|
+
return any(marker in text for marker in self.RETRYABLE_STATUS_MARKERS)
|
|
370
|
+
|
|
371
|
+
def _active_candidates(self, policy: Dict[str, Any], capability: str) -> List[Dict[str, Any]]:
|
|
372
|
+
section = policy.get(capability) or {}
|
|
373
|
+
models = section.get("models")
|
|
374
|
+
if not isinstance(models, list) or not models:
|
|
375
|
+
raise RuntimeModelConfigError(f"No runtime models configured for capability: {capability}")
|
|
376
|
+
|
|
377
|
+
now = time.time()
|
|
378
|
+
active = [model for model in models if self._cooldowns.get(self.model_key(model), 0) <= now]
|
|
379
|
+
return active or models
|
|
380
|
+
|
|
381
|
+
def _weighted_random(self, candidates: Iterable[Dict[str, Any]]) -> Dict[str, Any]:
|
|
382
|
+
items = list(candidates)
|
|
383
|
+
weights = [max(float(item.get("weight", 1) or 1), 0.0) for item in items]
|
|
384
|
+
if not any(weights):
|
|
385
|
+
return random.choice(items)
|
|
386
|
+
return random.choices(items, weights=weights, k=1)[0]
|
|
387
|
+
|
|
388
|
+
def _retry_after_seconds(self, error: Exception) -> Optional[int]:
|
|
389
|
+
retry_after = getattr(error, "retry_after", None)
|
|
390
|
+
if retry_after is None:
|
|
391
|
+
response = getattr(error, "response", None)
|
|
392
|
+
headers = getattr(response, "headers", None)
|
|
393
|
+
if headers:
|
|
394
|
+
retry_after = headers.get("Retry-After") or headers.get("retry-after")
|
|
395
|
+
if retry_after is None:
|
|
396
|
+
return None
|
|
397
|
+
try:
|
|
398
|
+
return int(float(retry_after))
|
|
399
|
+
except (TypeError, ValueError):
|
|
400
|
+
return None
|
|
@@ -9,7 +9,7 @@ import logging
|
|
|
9
9
|
import time
|
|
10
10
|
import uuid
|
|
11
11
|
from collections import defaultdict
|
|
12
|
-
from typing import List, Dict, Optional
|
|
12
|
+
from typing import AsyncIterator, List, Dict, Optional, Any
|
|
13
13
|
|
|
14
14
|
from langchain_core.documents import Document
|
|
15
15
|
from langchain_core.embeddings import Embeddings
|
|
@@ -797,6 +797,81 @@ class VDBService(object):
|
|
|
797
797
|
except Exception as e:
|
|
798
798
|
raise RuntimeError(f"delete collection data failed: {str(e)}")
|
|
799
799
|
|
|
800
|
+
async def aquery_rows(
|
|
801
|
+
self,
|
|
802
|
+
*,
|
|
803
|
+
collection_name: str,
|
|
804
|
+
filter_expr: str,
|
|
805
|
+
output_fields: List[str],
|
|
806
|
+
limit: int,
|
|
807
|
+
offset: int = 0,
|
|
808
|
+
) -> List[dict[str, Any]]:
|
|
809
|
+
if not collection_name:
|
|
810
|
+
raise ValueError("collection_name must be provided.")
|
|
811
|
+
if limit <= 0:
|
|
812
|
+
return []
|
|
813
|
+
store = await self.aget_vector_store(collection_name)
|
|
814
|
+
collection = getattr(store, "col", None)
|
|
815
|
+
if collection is None:
|
|
816
|
+
raise RuntimeError(
|
|
817
|
+
"The vector store instance does not expose a pymilvus Collection via `.col`."
|
|
818
|
+
)
|
|
819
|
+
return await asyncio.to_thread(
|
|
820
|
+
collection.query,
|
|
821
|
+
expr=filter_expr,
|
|
822
|
+
output_fields=output_fields,
|
|
823
|
+
limit=limit,
|
|
824
|
+
offset=offset,
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
async def aiter_query_rows(
|
|
828
|
+
self,
|
|
829
|
+
*,
|
|
830
|
+
collection_name: str,
|
|
831
|
+
filter_expr: str,
|
|
832
|
+
output_fields: List[str],
|
|
833
|
+
batch_size: int,
|
|
834
|
+
) -> AsyncIterator[List[dict[str, Any]]]:
|
|
835
|
+
if not collection_name:
|
|
836
|
+
raise ValueError("collection_name must be provided.")
|
|
837
|
+
if batch_size <= 0:
|
|
838
|
+
return
|
|
839
|
+
store = await self.aget_vector_store(collection_name)
|
|
840
|
+
collection = getattr(store, "col", None)
|
|
841
|
+
if collection is None:
|
|
842
|
+
raise RuntimeError(
|
|
843
|
+
"The vector store instance does not expose a pymilvus Collection via `.col`."
|
|
844
|
+
)
|
|
845
|
+
iterator = await asyncio.to_thread(
|
|
846
|
+
collection.query_iterator,
|
|
847
|
+
expr=filter_expr,
|
|
848
|
+
output_fields=output_fields,
|
|
849
|
+
batch_size=batch_size,
|
|
850
|
+
)
|
|
851
|
+
try:
|
|
852
|
+
while True:
|
|
853
|
+
batch_rows = await asyncio.to_thread(iterator.next)
|
|
854
|
+
if not batch_rows:
|
|
855
|
+
break
|
|
856
|
+
yield batch_rows
|
|
857
|
+
finally:
|
|
858
|
+
await asyncio.to_thread(iterator.close)
|
|
859
|
+
|
|
860
|
+
async def asimilarity_search_with_score(
|
|
861
|
+
self,
|
|
862
|
+
*,
|
|
863
|
+
collection_name: str,
|
|
864
|
+
query: str,
|
|
865
|
+
k: int,
|
|
866
|
+
expr: str | None = None,
|
|
867
|
+
embeddings: Embeddings | None = None,
|
|
868
|
+
) -> list[tuple[Document, float]]:
|
|
869
|
+
store = await self.aget_vector_store(collection_name, embeddings)
|
|
870
|
+
kwargs: dict[str, Any] = {"k": k}
|
|
871
|
+
if expr:
|
|
872
|
+
kwargs["expr"] = expr
|
|
873
|
+
return await asyncio.to_thread(store.similarity_search_with_score, query, **kwargs)
|
|
874
|
+
|
|
800
875
|
async def aget_docs_by_ids(self, collection_names: List[str], ids: List[str], embeddings: Embeddings, output_fields: List[str] = None) -> List[Document]:
|
|
801
876
|
"""
|
|
802
877
|
Retrieves documents from multiple collections by their primary key IDs using a direct query.
|
|
@@ -6,7 +6,7 @@ build-backend = "pdm.backend"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "crewplus"
|
|
9
|
-
version = "0.2
|
|
9
|
+
version = "0.3.2"
|
|
10
10
|
description = "Base services for CrewPlus AI applications"
|
|
11
11
|
authors = [
|
|
12
12
|
{ name = "Tim Liu", email = "tim@opsmateai.com" },
|
|
@@ -16,7 +16,7 @@ requires-python = ">=3.11,<4.0"
|
|
|
16
16
|
dependencies = [
|
|
17
17
|
"langchain>=0.3.25, <1.0.0",
|
|
18
18
|
"langchain-openai>=0.3.24",
|
|
19
|
-
"google-genai>=1.
|
|
19
|
+
"google-genai>=1.51.0",
|
|
20
20
|
"anthropic[vertex]>=0.76.0",
|
|
21
21
|
"langchain-milvus (>=0.2.1,<0.3.0)",
|
|
22
22
|
"langfuse (>=3.1.3,<4.0.0)",
|