crewplus 0.3.1__tar.gz → 0.3.3__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.3.1 → crewplus-0.3.3}/PKG-INFO +2 -2
  2. crewplus-0.3.3/crewplus/__pycache__/__init__.cpython-311.pyc.1535298853824 +0 -0
  3. crewplus-0.3.3/crewplus/services/__pycache__/__init__.cpython-311.pyc.1535299230960 +0 -0
  4. crewplus-0.3.3/crewplus/services/__pycache__/gemini_chat_model.cpython-311.pyc.1535298773120 +0 -0
  5. crewplus-0.3.3/crewplus/services/__pycache__/runtime_model_factory.cpython-311.pyc.1959047557728 +0 -0
  6. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/services/gemini_chat_model.py +44 -0
  7. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/services/model_load_balancer.py +13 -0
  8. crewplus-0.3.3/crewplus/services/runtime_model_factory.py +400 -0
  9. {crewplus-0.3.1 → crewplus-0.3.3}/pyproject.toml +2 -2
  10. crewplus-0.3.3/tests/output/drawing_ingestion/gpt-5.4_run1.md +88 -0
  11. crewplus-0.3.3/tests/output/drawing_ingestion/gpt-5.4_run2.md +93 -0
  12. crewplus-0.3.3/tests/output/drawing_ingestion/gpt-5.6-luna_run1.md +59 -0
  13. crewplus-0.3.3/tests/output/drawing_ingestion/gpt-5.6-luna_run2.md +46 -0
  14. crewplus-0.3.3/tests/output/drawing_ingestion/gpt-5.6-terra_run1.md +48 -0
  15. crewplus-0.3.3/tests/output/drawing_ingestion/gpt-5.6-terra_run2.md +55 -0
  16. crewplus-0.3.3/tests/output/drawing_ingestion/page24_150dpi.png +0 -0
  17. crewplus-0.3.3/tests/output/drawing_ingestion/report.md +63 -0
  18. crewplus-0.3.3/tests/output/drawing_ingestion/results.json +605 -0
  19. crewplus-0.3.3/tests/output/drawing_ingestion/session1-luna-terra/gpt-5.6-luna_run1.md +79 -0
  20. crewplus-0.3.3/tests/output/drawing_ingestion/session1-luna-terra/gpt-5.6-luna_run2.md +60 -0
  21. crewplus-0.3.3/tests/output/drawing_ingestion/session1-luna-terra/gpt-5.6-terra_run1.md +47 -0
  22. crewplus-0.3.3/tests/output/drawing_ingestion/session1-luna-terra/gpt-5.6-terra_run2.md +46 -0
  23. crewplus-0.3.3/tests/output/drawing_ingestion/session1-luna-terra/report.md +58 -0
  24. crewplus-0.3.3/tests/output/drawing_ingestion/session1-luna-terra/results.json +427 -0
  25. crewplus-0.3.3/tests/output/drawing_ingestion/session2-55-sol/gpt-5.5_run1.md +54 -0
  26. crewplus-0.3.3/tests/output/drawing_ingestion/session2-55-sol/gpt-5.5_run2.md +43 -0
  27. crewplus-0.3.3/tests/output/drawing_ingestion/session2-55-sol/gpt-5.6-sol_run1.md +45 -0
  28. crewplus-0.3.3/tests/output/drawing_ingestion/session2-55-sol/gpt-5.6-sol_run2.md +56 -0
  29. crewplus-0.3.3/tests/output/drawing_ingestion/session2-55-sol/page24_150dpi.png +0 -0
  30. crewplus-0.3.3/tests/output/drawing_ingestion/session2-55-sol/report.md +58 -0
  31. crewplus-0.3.3/tests/output/drawing_ingestion/session2-55-sol/results.json +421 -0
  32. crewplus-0.3.3/tests/output/drawing_ingestion/session2-55-sol/summary-report-55-sol.md +346 -0
  33. crewplus-0.3.3/tests/output/drawing_ingestion/summary-report.md +287 -0
  34. crewplus-0.3.3/tests/test_pdf_drawing_ingestion_gpt55_sol.py +106 -0
  35. crewplus-0.3.3/tests/test_pdf_drawing_ingestion_gpt56.py +397 -0
  36. crewplus-0.3.3/tests/test_runtime_model_factory.py +79 -0
  37. {crewplus-0.3.1 → crewplus-0.3.3}/LICENSE +0 -0
  38. {crewplus-0.3.1 → crewplus-0.3.3}/README.md +0 -0
  39. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/__init__.py +0 -0
  40. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/callbacks/__init__.py +0 -0
  41. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/callbacks/async_langfuse_handler.py +0 -0
  42. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/services/__init__.py +0 -0
  43. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/services/azure_chat_model.py +0 -0
  44. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/services/capabilities/__init__.py +0 -0
  45. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/services/capabilities/base.py +0 -0
  46. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/services/capabilities/bash.py +0 -0
  47. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/services/capabilities/registry.py +0 -0
  48. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/services/capabilities/web_search.py +0 -0
  49. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/services/claude_chat_model.py +0 -0
  50. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/services/feedback.md +0 -0
  51. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/services/feedback_manager.py +0 -0
  52. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/services/init_services.py +0 -0
  53. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/services/oai_claude_chat_model.py +0 -0
  54. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/services/schemas/feedback.py +0 -0
  55. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/services/tracing_manager.py +0 -0
  56. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/utils/__init__.py +0 -0
  57. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/utils/schema_action.py +0 -0
  58. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/utils/schema_document_updater.py +0 -0
  59. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/utils/tracing_util.py +0 -0
  60. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/vectorstores/milvus/__init__.py +0 -0
  61. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/vectorstores/milvus/milvus_schema_manager.py +0 -0
  62. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/vectorstores/milvus/schema_milvus.py +0 -0
  63. {crewplus-0.3.1 → crewplus-0.3.3}/crewplus/vectorstores/milvus/vdb_service.py +0 -0
  64. {crewplus-0.3.1 → crewplus-0.3.3}/docs/GeminiChatModel.md +0 -0
  65. {crewplus-0.3.1 → crewplus-0.3.3}/docs/ModelLoadBalancer.md +0 -0
  66. {crewplus-0.3.1 → crewplus-0.3.3}/docs/VDBService.md +0 -0
  67. {crewplus-0.3.1 → crewplus-0.3.3}/docs/index.md +0 -0
  68. {crewplus-0.3.1 → crewplus-0.3.3}/tests/README.md +0 -0
  69. {crewplus-0.3.1 → crewplus-0.3.3}/tests/conftest.py +0 -0
  70. {crewplus-0.3.1 → crewplus-0.3.3}/tests/test_bash_tools.py +0 -0
  71. {crewplus-0.3.1 → crewplus-0.3.3}/tests/test_claude_api.py +0 -0
  72. {crewplus-0.3.1 → crewplus-0.3.3}/tests/test_claude_custom_endpoint_integration.py +0 -0
  73. {crewplus-0.3.1 → crewplus-0.3.3}/tests/test_claude_langchain_openai.py +0 -0
  74. {crewplus-0.3.1 → crewplus-0.3.3}/tests/test_claude_taskflow_schema.py +0 -0
  75. {crewplus-0.3.1 → crewplus-0.3.3}/tests/test_custom_claude_endpoint.py +0 -0
  76. {crewplus-0.3.1 → crewplus-0.3.3}/tests/test_gemini3_pro_preview.py +0 -0
  77. {crewplus-0.3.1 → crewplus-0.3.3}/tests/test_gemini_bind_tools.py +0 -0
  78. {crewplus-0.3.1 → crewplus-0.3.3}/tests/test_oai_claude_chat_model.py +0 -0
  79. {crewplus-0.3.1 → crewplus-0.3.3}/tests/test_ops_models_streaming.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: crewplus
3
- Version: 0.3.1
3
+ Version: 0.3.3
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.75.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
@@ -6,7 +6,7 @@ build-backend = "pdm.backend"
6
6
 
7
7
  [project]
8
8
  name = "crewplus"
9
- version = "0.3.01"
9
+ version = "0.3.3"
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.75.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)",
@@ -0,0 +1,88 @@
1
+ Technical drawing page content extraction
2
+
3
+ Main drawing
4
+ - Assembly drawing of a rotating shaft support arrangement.
5
+ - A long horizontal shaft is shown mounted between two pedestal-like supports on a base plate.
6
+ - Left side: a support with a small side block/retainer and several numbered callouts.
7
+ - Right side: a sectional view through the support and bearing arrangement, heavily hatched to indicate cut material.
8
+ - A centerline runs horizontally through the shaft.
9
+ - Section indicator text near the sectional view: A-A, h/rechts
10
+ - Visible dimensions:
11
+ - 150
12
+ - 51
13
+
14
+ Numbered callouts visible on drawing
15
+ - Left support area:
16
+ - 11
17
+ - 1
18
+ - 5
19
+ - 12
20
+ - 13
21
+ - Right sectional support area:
22
+ - 2
23
+ - 7
24
+ - 8
25
+ - 9
26
+ - 4
27
+ - 10
28
+ - 3
29
+ - Base area:
30
+ - 6
31
+
32
+ Bill of materials / parts list table
33
+ Columns visible:
34
+ - Pos.
35
+ - St.
36
+ - Benennung
37
+ - Norm / Maße / Werkstoff
38
+ - Zeichnungs-Nr.
39
+
40
+ Rows extracted as visible:
41
+ - 13 | 4 | Unterlegscheibe | HB 200 |
42
+ - 12 | 4 | Sechskantschr. MGS | 8.8, M20x30 |
43
+ - 11 | 4 | Flachrundschr. MGS | 8.8, M10x55 |
44
+ - 10 | 2 | Unterlegscheibe | HB 200 |
45
+ - 9 | 3 | Zylinderschr. MGS | M8x25 |
46
+ - 8 | 3 | Unterlegscheibe | HB 200 |
47
+ - 7 | 2 | Paßfeder | 18x11x90, PRK 6x6x61 |
48
+ - 6 | 1 | Platte | 1650-06 |
49
+ - 5 | 2 | Lagerbock | Stahl, ST37M |
50
+ - 4 | 3 | Büchse | | VKT-071004-B
51
+ - 3 | 2 | Radkranz | | VKT-071004-A/4
52
+ - 2 | 1 | Nabe | | VKT-071004-A/3
53
+ - 1 | 2 | Nabensicherung | | VKT-071004-A/2
54
+
55
+ Title block / metadata
56
+ - Maßstab: 1:1
57
+ - Format: A3
58
+ - Werkstoff / material field: St
59
+ - Benennung / title:
60
+ - RFL-Lin
61
+ - rotating shaft - Assembly
62
+ - Zeichnungs-Nr. / drawing number:
63
+ - VKT-071005-A
64
+
65
+ Revision / issue table
66
+ - Gez. / date fields visible but handwritten/typed entries are faint.
67
+ - Name visible: MKT
68
+ - Datum visible in one field: 04/18
69
+ - Aenderung durchgefuehrt / geprueft o.ä. field present at lower left title area.
70
+ - Freimaß / tolerance or standards field present but not fully legible.
71
+
72
+ Detailed image/diagram description
73
+ - The page is a monochrome engineering drawing sheet with a border and title block.
74
+ - The assembly consists of a cylindrical shaft supported by two tapered or trapezoidal pedestals fixed to a rectangular base plate.
75
+ - The left support is shown externally. A side-mounted fastening arrangement appears to include a flat-head or round-head screw, washer, and retaining/nabensicherung component. A pedestal labeled by callout 5 sits on the base plate 6. Above it is a wheel/ring-like component mounted on the shaft.
76
+ - The right support is shown in cross-section. The section reveals:
77
+ - A central shaft passing vertically through the cut assembly view.
78
+ - A hub/nabe around the shaft.
79
+ - A radial key/Paßfeder between shaft and hub.
80
+ - A large outer ring/radkranz surrounding the hub area.
81
+ - Bushings/büchsen and washers stacked near the top.
82
+ - Multiple screws entering from the top and side to secure the components.
83
+ - The hatched regions indicate the cut metallic parts of the support and surrounding structure.
84
+ - The shaft extends across the page between the two supports and appears aligned along a dashed centerline.
85
+ - The base plate is a long rectangular member beneath both supports, with one horizontal dimension of 150 shown between support-related reference points.
86
+ - The sectional support at right includes a top clamping/cover region with bolts and washers, and an internal fit around the shaft that suggests a keyed hub or wheel mounting.
87
+ - Number balloons point to nearly all major components, corresponding to the parts list.
88
+ - The drawing is rotated relative to normal reading orientation in the provided image; the title block and parts list are on the right side when viewed as provided.