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.
Files changed (79) hide show
  1. {crewplus-0.2.99 → crewplus-0.3.2}/PKG-INFO +2 -2
  2. crewplus-0.3.2/crewplus/__pycache__/__init__.cpython-311.pyc.1535298853824 +0 -0
  3. crewplus-0.3.2/crewplus/services/__pycache__/__init__.cpython-311.pyc.1535299230960 +0 -0
  4. crewplus-0.3.2/crewplus/services/__pycache__/gemini_chat_model.cpython-311.pyc.1535298773120 +0 -0
  5. crewplus-0.3.2/crewplus/services/__pycache__/runtime_model_factory.cpython-311.pyc.1959047557728 +0 -0
  6. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/gemini_chat_model.py +44 -0
  7. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/model_load_balancer.py +13 -0
  8. crewplus-0.3.2/crewplus/services/runtime_model_factory.py +400 -0
  9. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/vectorstores/milvus/vdb_service.py +76 -1
  10. {crewplus-0.2.99 → crewplus-0.3.2}/pyproject.toml +2 -2
  11. crewplus-0.3.2/tests/output/drawing_ingestion/gpt-5.4_run1.md +88 -0
  12. crewplus-0.3.2/tests/output/drawing_ingestion/gpt-5.4_run2.md +93 -0
  13. crewplus-0.3.2/tests/output/drawing_ingestion/gpt-5.6-luna_run1.md +59 -0
  14. crewplus-0.3.2/tests/output/drawing_ingestion/gpt-5.6-luna_run2.md +46 -0
  15. crewplus-0.3.2/tests/output/drawing_ingestion/gpt-5.6-terra_run1.md +48 -0
  16. crewplus-0.3.2/tests/output/drawing_ingestion/gpt-5.6-terra_run2.md +55 -0
  17. crewplus-0.3.2/tests/output/drawing_ingestion/page24_150dpi.png +0 -0
  18. crewplus-0.3.2/tests/output/drawing_ingestion/report.md +63 -0
  19. crewplus-0.3.2/tests/output/drawing_ingestion/results.json +605 -0
  20. crewplus-0.3.2/tests/output/drawing_ingestion/session1-luna-terra/gpt-5.6-luna_run1.md +79 -0
  21. crewplus-0.3.2/tests/output/drawing_ingestion/session1-luna-terra/gpt-5.6-luna_run2.md +60 -0
  22. crewplus-0.3.2/tests/output/drawing_ingestion/session1-luna-terra/gpt-5.6-terra_run1.md +47 -0
  23. crewplus-0.3.2/tests/output/drawing_ingestion/session1-luna-terra/gpt-5.6-terra_run2.md +46 -0
  24. crewplus-0.3.2/tests/output/drawing_ingestion/session1-luna-terra/report.md +58 -0
  25. crewplus-0.3.2/tests/output/drawing_ingestion/session1-luna-terra/results.json +427 -0
  26. crewplus-0.3.2/tests/output/drawing_ingestion/session2-55-sol/gpt-5.5_run1.md +54 -0
  27. crewplus-0.3.2/tests/output/drawing_ingestion/session2-55-sol/gpt-5.5_run2.md +43 -0
  28. crewplus-0.3.2/tests/output/drawing_ingestion/session2-55-sol/gpt-5.6-sol_run1.md +45 -0
  29. crewplus-0.3.2/tests/output/drawing_ingestion/session2-55-sol/gpt-5.6-sol_run2.md +56 -0
  30. crewplus-0.3.2/tests/output/drawing_ingestion/session2-55-sol/page24_150dpi.png +0 -0
  31. crewplus-0.3.2/tests/output/drawing_ingestion/session2-55-sol/report.md +58 -0
  32. crewplus-0.3.2/tests/output/drawing_ingestion/session2-55-sol/results.json +421 -0
  33. crewplus-0.3.2/tests/output/drawing_ingestion/session2-55-sol/summary-report-55-sol.md +346 -0
  34. crewplus-0.3.2/tests/output/drawing_ingestion/summary-report.md +287 -0
  35. {crewplus-0.2.99 → crewplus-0.3.2}/tests/test_bash_tools.py +532 -532
  36. {crewplus-0.2.99 → crewplus-0.3.2}/tests/test_claude_taskflow_schema.py +688 -688
  37. {crewplus-0.2.99 → crewplus-0.3.2}/tests/test_gemini3_pro_preview.py +563 -563
  38. crewplus-0.3.2/tests/test_ops_models_streaming.py +172 -0
  39. crewplus-0.3.2/tests/test_pdf_drawing_ingestion_gpt55_sol.py +106 -0
  40. crewplus-0.3.2/tests/test_pdf_drawing_ingestion_gpt56.py +397 -0
  41. crewplus-0.3.2/tests/test_runtime_model_factory.py +79 -0
  42. {crewplus-0.2.99 → crewplus-0.3.2}/LICENSE +0 -0
  43. {crewplus-0.2.99 → crewplus-0.3.2}/README.md +0 -0
  44. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/__init__.py +0 -0
  45. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/callbacks/__init__.py +0 -0
  46. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/callbacks/async_langfuse_handler.py +0 -0
  47. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/__init__.py +0 -0
  48. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/azure_chat_model.py +0 -0
  49. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/capabilities/__init__.py +0 -0
  50. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/capabilities/base.py +0 -0
  51. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/capabilities/bash.py +0 -0
  52. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/capabilities/registry.py +0 -0
  53. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/capabilities/web_search.py +0 -0
  54. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/claude_chat_model.py +0 -0
  55. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/feedback.md +0 -0
  56. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/feedback_manager.py +0 -0
  57. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/init_services.py +0 -0
  58. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/oai_claude_chat_model.py +0 -0
  59. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/schemas/feedback.py +0 -0
  60. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/services/tracing_manager.py +0 -0
  61. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/utils/__init__.py +0 -0
  62. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/utils/schema_action.py +0 -0
  63. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/utils/schema_document_updater.py +0 -0
  64. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/utils/tracing_util.py +0 -0
  65. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/vectorstores/milvus/__init__.py +0 -0
  66. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/vectorstores/milvus/milvus_schema_manager.py +0 -0
  67. {crewplus-0.2.99 → crewplus-0.3.2}/crewplus/vectorstores/milvus/schema_milvus.py +0 -0
  68. {crewplus-0.2.99 → crewplus-0.3.2}/docs/GeminiChatModel.md +0 -0
  69. {crewplus-0.2.99 → crewplus-0.3.2}/docs/ModelLoadBalancer.md +0 -0
  70. {crewplus-0.2.99 → crewplus-0.3.2}/docs/VDBService.md +0 -0
  71. {crewplus-0.2.99 → crewplus-0.3.2}/docs/index.md +0 -0
  72. {crewplus-0.2.99 → crewplus-0.3.2}/tests/README.md +0 -0
  73. {crewplus-0.2.99 → crewplus-0.3.2}/tests/conftest.py +0 -0
  74. {crewplus-0.2.99 → crewplus-0.3.2}/tests/test_claude_api.py +0 -0
  75. {crewplus-0.2.99 → crewplus-0.3.2}/tests/test_claude_custom_endpoint_integration.py +0 -0
  76. {crewplus-0.2.99 → crewplus-0.3.2}/tests/test_claude_langchain_openai.py +0 -0
  77. {crewplus-0.2.99 → crewplus-0.3.2}/tests/test_custom_claude_endpoint.py +0 -0
  78. {crewplus-0.2.99 → crewplus-0.3.2}/tests/test_gemini_bind_tools.py +0 -0
  79. {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.99
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.21.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
@@ -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.99"
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.21.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)",