crewplus 0.3.4__tar.gz → 0.3.6__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 (84) hide show
  1. {crewplus-0.3.4 → crewplus-0.3.6}/PKG-INFO +3 -1
  2. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/__init__.py +12 -0
  3. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/model_load_balancer.py +8 -1
  4. crewplus-0.3.6/crewplus/services/rerankers/__init__.py +19 -0
  5. crewplus-0.3.6/crewplus/services/rerankers/base.py +51 -0
  6. crewplus-0.3.6/crewplus/services/rerankers/cohere_foundry.py +337 -0
  7. crewplus-0.3.6/crewplus/services/rerankers/factory.py +61 -0
  8. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/vectorstores/milvus/vdb_service.py +162 -30
  9. {crewplus-0.3.4 → crewplus-0.3.6}/pyproject.toml +3 -1
  10. crewplus-0.3.6/tests/test_cohere_foundry_reranker.py +425 -0
  11. crewplus-0.3.4/crewplus/__pycache__/__init__.cpython-311.pyc.1535298853824 +0 -0
  12. crewplus-0.3.4/crewplus/services/__pycache__/__init__.cpython-311.pyc.1535299230960 +0 -0
  13. crewplus-0.3.4/crewplus/services/__pycache__/gemini_chat_model.cpython-311.pyc.1535298773120 +0 -0
  14. crewplus-0.3.4/crewplus/services/__pycache__/runtime_model_factory.cpython-311.pyc.1959047557728 +0 -0
  15. crewplus-0.3.4/crewplus/services/runtime_model_factory.py +0 -400
  16. crewplus-0.3.4/tests/test_runtime_model_factory.py +0 -79
  17. {crewplus-0.3.4 → crewplus-0.3.6}/LICENSE +0 -0
  18. {crewplus-0.3.4 → crewplus-0.3.6}/README.md +0 -0
  19. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/__init__.py +0 -0
  20. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/callbacks/__init__.py +0 -0
  21. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/callbacks/async_langfuse_handler.py +0 -0
  22. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/azure_chat_model.py +0 -0
  23. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/capabilities/__init__.py +0 -0
  24. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/capabilities/base.py +0 -0
  25. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/capabilities/bash.py +0 -0
  26. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/capabilities/registry.py +0 -0
  27. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/capabilities/web_search.py +0 -0
  28. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/claude_chat_model.py +0 -0
  29. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/feedback.md +0 -0
  30. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/feedback_manager.py +0 -0
  31. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/gemini_chat_model.py +0 -0
  32. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/init_services.py +0 -0
  33. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/oai_claude_chat_model.py +0 -0
  34. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/schemas/feedback.py +0 -0
  35. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/tracing_manager.py +0 -0
  36. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/utils/__init__.py +0 -0
  37. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/utils/schema_action.py +0 -0
  38. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/utils/schema_document_updater.py +0 -0
  39. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/utils/tracing_util.py +0 -0
  40. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/vectorstores/milvus/__init__.py +0 -0
  41. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/vectorstores/milvus/milvus_schema_manager.py +0 -0
  42. {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/vectorstores/milvus/schema_milvus.py +0 -0
  43. {crewplus-0.3.4 → crewplus-0.3.6}/docs/GeminiChatModel.md +0 -0
  44. {crewplus-0.3.4 → crewplus-0.3.6}/docs/ModelLoadBalancer.md +0 -0
  45. {crewplus-0.3.4 → crewplus-0.3.6}/docs/VDBService.md +0 -0
  46. {crewplus-0.3.4 → crewplus-0.3.6}/docs/index.md +0 -0
  47. {crewplus-0.3.4 → crewplus-0.3.6}/tests/README.md +0 -0
  48. {crewplus-0.3.4 → crewplus-0.3.6}/tests/conftest.py +0 -0
  49. {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/gpt-5.4_run1.md +0 -0
  50. {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/gpt-5.4_run2.md +0 -0
  51. {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/gpt-5.6-luna_run1.md +0 -0
  52. {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/gpt-5.6-luna_run2.md +0 -0
  53. {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/gpt-5.6-terra_run1.md +0 -0
  54. {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/gpt-5.6-terra_run2.md +0 -0
  55. {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/page24_150dpi.png +0 -0
  56. {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/report.md +0 -0
  57. {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/results.json +0 -0
  58. {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session1-luna-terra/gpt-5.6-luna_run1.md +0 -0
  59. {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session1-luna-terra/gpt-5.6-luna_run2.md +0 -0
  60. {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session1-luna-terra/gpt-5.6-terra_run1.md +0 -0
  61. {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session1-luna-terra/gpt-5.6-terra_run2.md +0 -0
  62. {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session1-luna-terra/report.md +0 -0
  63. {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session1-luna-terra/results.json +0 -0
  64. {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session2-55-sol/gpt-5.5_run1.md +0 -0
  65. {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session2-55-sol/gpt-5.5_run2.md +0 -0
  66. {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session2-55-sol/gpt-5.6-sol_run1.md +0 -0
  67. {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session2-55-sol/gpt-5.6-sol_run2.md +0 -0
  68. {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session2-55-sol/page24_150dpi.png +0 -0
  69. {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session2-55-sol/report.md +0 -0
  70. {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session2-55-sol/results.json +0 -0
  71. {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session2-55-sol/summary-report-55-sol.md +0 -0
  72. {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/summary-report.md +0 -0
  73. {crewplus-0.3.4 → crewplus-0.3.6}/tests/test_bash_tools.py +0 -0
  74. {crewplus-0.3.4 → crewplus-0.3.6}/tests/test_claude_api.py +0 -0
  75. {crewplus-0.3.4 → crewplus-0.3.6}/tests/test_claude_custom_endpoint_integration.py +0 -0
  76. {crewplus-0.3.4 → crewplus-0.3.6}/tests/test_claude_langchain_openai.py +0 -0
  77. {crewplus-0.3.4 → crewplus-0.3.6}/tests/test_claude_taskflow_schema.py +0 -0
  78. {crewplus-0.3.4 → crewplus-0.3.6}/tests/test_custom_claude_endpoint.py +0 -0
  79. {crewplus-0.3.4 → crewplus-0.3.6}/tests/test_gemini3_pro_preview.py +0 -0
  80. {crewplus-0.3.4 → crewplus-0.3.6}/tests/test_gemini_bind_tools.py +0 -0
  81. {crewplus-0.3.4 → crewplus-0.3.6}/tests/test_oai_claude_chat_model.py +0 -0
  82. {crewplus-0.3.4 → crewplus-0.3.6}/tests/test_ops_models_streaming.py +0 -0
  83. {crewplus-0.3.4 → crewplus-0.3.6}/tests/test_pdf_drawing_ingestion_gpt55_sol.py +0 -0
  84. {crewplus-0.3.4 → crewplus-0.3.6}/tests/test_pdf_drawing_ingestion_gpt56.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: crewplus
3
- Version: 0.3.4
3
+ Version: 0.3.6
4
4
  Summary: Base services for CrewPlus AI applications
5
5
  Author-Email: Tim Liu <tim@opsmateai.com>
6
6
  License: MIT
@@ -16,6 +16,8 @@ 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
18
18
  Requires-Dist: pymilvus<=2.6.7,>=2.5.7
19
+ Requires-Dist: httpx<1.0,>=0.27
20
+ Requires-Dist: opentelemetry-api>=1.20
19
21
  Description-Content-Type: text/markdown
20
22
 
21
23
  # CrewPlus
@@ -7,6 +7,13 @@ from .azure_chat_model import TracedAzureChatOpenAI
7
7
  from .feedback_manager import LangfuseFeedbackManager
8
8
  from .schemas.feedback import FeedbackIn, FeedbackUpdate, FeedbackOut
9
9
  from .tracing_manager import TracingManager, TracingContext
10
+ from .rerankers import (
11
+ CohereFoundryReranker,
12
+ RerankError,
13
+ RerankItem,
14
+ RerankResponse,
15
+ create_reranker,
16
+ )
10
17
  from .capabilities import (
11
18
  BaseCapability,
12
19
  CapabilityConfig,
@@ -29,6 +36,11 @@ __all__ = [
29
36
  "FeedbackOut",
30
37
  "TracingManager",
31
38
  "TracingContext",
39
+ "CohereFoundryReranker",
40
+ "RerankError",
41
+ "RerankItem",
42
+ "RerankResponse",
43
+ "create_reranker",
32
44
  "BaseCapability",
33
45
  "CapabilityConfig",
34
46
  "CapabilityRegistry",
@@ -9,6 +9,7 @@ from .gemini_chat_model import GeminiChatModel
9
9
  from .claude_chat_model import ClaudeChatModel
10
10
  from .azure_chat_model import TracedAzureChatOpenAI
11
11
  from .oai_claude_chat_model import OAIClaudeChatModel
12
+ from .rerankers import RERANK_PROVIDERS, create_reranker
12
13
 
13
14
 
14
15
  class ModelLoadBalancer:
@@ -132,7 +133,9 @@ class ModelLoadBalancer:
132
133
 
133
134
  Args:
134
135
  provider: The model provider (e.g., 'azure-openai', 'google-genai').
135
- model_type: The type of model (e.g., 'inference', 'embedding', 'embedding-large').
136
+ model_type: The type of model (e.g., 'inference', 'embedding', 'embedding-large', 'rerank').
137
+ A 'rerank' entry (provider 'azure-foundry-cohere') returns a rerank client,
138
+ not a LangChain model.
136
139
  deployment_name: The unique name for the model deployment.
137
140
  with_metadata: If True, returns a tuple of (model, deployment_name).
138
141
  selection_strategy: The selection strategy ('random', 'round_robin', or 'least_used'). Defaults to 'random'.
@@ -358,6 +361,10 @@ class ModelLoadBalancer:
358
361
  # Custom OpenAI-compatible endpoint for Claude models
359
362
  # Uses OpenAI-compatible API with capability support (web_search, bash, etc.)
360
363
  return self._instantiate_oai_claude_model(model_config, disable_streaming)
364
+ elif provider in RERANK_PROVIDERS:
365
+ # Unlike embeddings, a bad rerank entry raises: the host decides whether
366
+ # running without rerank is acceptable, not the balancer.
367
+ return create_reranker(model_config)
361
368
  else:
362
369
  self.logger.error(f"Unsupported provider: {provider}")
363
370
  raise ValueError(f"Unsupported provider: {provider}")
@@ -0,0 +1,19 @@
1
+ """
2
+ Rerank model clients (cross-encoders) configured from MODELS_CONFIG.
3
+
4
+ Only transport lives here. Retrieval policy — candidate selection, document text,
5
+ fallback to vector order — belongs to the caller (crewplus-search).
6
+ """
7
+ from .base import RerankError, RerankErrorKind, RerankItem, RerankResponse
8
+ from .cohere_foundry import CohereFoundryReranker
9
+ from .factory import RERANK_PROVIDERS, create_reranker
10
+
11
+ __all__ = [
12
+ "RerankError",
13
+ "RerankErrorKind",
14
+ "RerankItem",
15
+ "RerankResponse",
16
+ "CohereFoundryReranker",
17
+ "RERANK_PROVIDERS",
18
+ "create_reranker",
19
+ ]
@@ -0,0 +1,51 @@
1
+ """
2
+ Shared result and error types for rerank model clients.
3
+
4
+ Callers (e.g. crewplus-search) depend on these shapes structurally, not by import,
5
+ so the fields here are a contract: renaming one breaks every caller silently.
6
+ """
7
+ from dataclasses import dataclass
8
+ from typing import List, Literal, Optional
9
+
10
+ RerankErrorKind = Literal[
11
+ "timeout",
12
+ "rate_limited",
13
+ "http_4xx",
14
+ "http_5xx",
15
+ "bad_response",
16
+ "transport",
17
+ "circuit_open",
18
+ ]
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class RerankItem:
23
+ """One reranked document: its position in the input list and its relevance."""
24
+ index: int
25
+ relevance_score: float
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class RerankResponse:
30
+ """Reranked documents, sorted by relevance_score descending."""
31
+ items: List[RerankItem]
32
+ search_units: Optional[int]
33
+ latency_ms: float
34
+ model: str
35
+
36
+
37
+ class RerankError(Exception):
38
+ """
39
+ A rerank call that produced no usable ranking.
40
+
41
+ ``kind`` is what callers branch on (and log as the fallback reason); the
42
+ message is for humans and never contains the query, documents or credentials.
43
+ """
44
+
45
+ def __init__(self, kind: RerankErrorKind, message: str, *, status: Optional[int] = None):
46
+ super().__init__(message)
47
+ self.kind: RerankErrorKind = kind
48
+ self.status = status
49
+
50
+ def __repr__(self) -> str:
51
+ return f"RerankError(kind={self.kind!r}, status={self.status!r}, message={str(self)!r})"
@@ -0,0 +1,337 @@
1
+ """
2
+ Cohere Rerank client for models deployed on Azure AI Foundry.
3
+
4
+ Transport only: one HTTP call per ``arerank``, classified errors, a process-local
5
+ circuit breaker. Deciding what to do when a call fails (e.g. falling back to vector
6
+ order) is the caller's job — this client never retries and never degrades silently.
7
+ """
8
+ import asyncio
9
+ import json
10
+ import logging
11
+ import math
12
+ import threading
13
+ import time
14
+ from typing import Any, Callable, Dict, List, Optional, Sequence
15
+
16
+ import httpx
17
+ from opentelemetry import trace
18
+ from opentelemetry.trace import Status, StatusCode
19
+
20
+ from .base import RerankError, RerankErrorKind, RerankItem, RerankResponse
21
+
22
+ logger = logging.getLogger(__name__)
23
+ _tracer = trace.get_tracer(__name__)
24
+
25
+ RERANK_PATH = "/providers/cohere/v2/rerank"
26
+
27
+ # Errors that say the service is unhealthy or overloaded. A 4xx or a malformed body
28
+ # means a config or contract problem that no amount of waiting fixes, so those do
29
+ # not trip the breaker.
30
+ _BREAKER_COUNTED_KINDS = frozenset({"timeout", "transport", "http_5xx", "rate_limited"})
31
+
32
+
33
+ class _CircuitBreaker:
34
+ """Opens after N consecutive counted failures; lets one probe through after a cool-down."""
35
+
36
+ def __init__(self, threshold: int, open_seconds: float, clock: Callable[[], float]):
37
+ self._threshold = threshold
38
+ self._open_seconds = open_seconds
39
+ self._clock = clock
40
+ self._lock = threading.Lock()
41
+ self._failures = 0
42
+ self._state = "closed"
43
+ self._opened_at = 0.0
44
+ self._probe_in_flight = False
45
+
46
+ @property
47
+ def state(self) -> str:
48
+ return self._state
49
+
50
+ def allow(self) -> bool:
51
+ if self._threshold <= 0:
52
+ return True
53
+ with self._lock:
54
+ if self._state == "closed":
55
+ return True
56
+ if self._state == "open":
57
+ if self._clock() - self._opened_at < self._open_seconds:
58
+ return False
59
+ self._state = "half_open"
60
+ self._probe_in_flight = False
61
+ if self._probe_in_flight:
62
+ return False
63
+ self._probe_in_flight = True
64
+ return True
65
+
66
+ def record(self, error_kind: Optional[str]) -> None:
67
+ if self._threshold <= 0:
68
+ return
69
+ with self._lock:
70
+ self._probe_in_flight = False
71
+ if error_kind not in _BREAKER_COUNTED_KINDS:
72
+ # The service answered (successfully or with a client-side error).
73
+ if self._state != "closed":
74
+ logger.info("[Rerank] circuit closed after a successful probe")
75
+ self._failures = 0
76
+ self._state = "closed"
77
+ return
78
+ self._failures += 1
79
+ if self._state == "half_open" or self._failures >= self._threshold:
80
+ if self._state != "open":
81
+ logger.warning(
82
+ "[Rerank] circuit opened after %d consecutive failures (last=%s)",
83
+ self._failures, error_kind,
84
+ )
85
+ self._state = "open"
86
+ self._opened_at = self._clock()
87
+
88
+
89
+ class CohereFoundryReranker:
90
+ """
91
+ Calls ``POST {api_base}/providers/cohere/v2/rerank`` on an Azure AI Foundry resource.
92
+
93
+ One ``httpx.AsyncClient`` is kept per event loop and reused, because most of a
94
+ rerank call's latency is the network round trip and a fresh TLS handshake per
95
+ call would roughly double it.
96
+
97
+ Args:
98
+ api_base: Foundry resource URL, e.g. ``https://<resource>.services.ai.azure.com``.
99
+ api_key: Resource key, sent as the ``api-key`` header.
100
+ deployment_name: Model deployment name, sent as ``model``.
101
+ timeout_s: Whole-request deadline (connect + send + wait + read together).
102
+ max_tokens_per_doc: Per-document truncation applied by the service.
103
+ breaker_threshold: Consecutive counted failures that open the circuit; 0 disables it.
104
+ breaker_open_seconds: How long the circuit stays open before one probe is allowed.
105
+ transport: Test hook; a custom httpx transport.
106
+ clock: Test hook; monotonic clock used by the breaker.
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ *,
112
+ api_base: str,
113
+ api_key: str,
114
+ deployment_name: str,
115
+ timeout_s: float = 3.0,
116
+ max_tokens_per_doc: int = 4096,
117
+ breaker_threshold: int = 5,
118
+ breaker_open_seconds: float = 30.0,
119
+ transport: Optional[httpx.AsyncBaseTransport] = None,
120
+ clock: Callable[[], float] = time.monotonic,
121
+ ):
122
+ if not api_base or not api_key or not deployment_name:
123
+ raise ValueError("api_base, api_key and deployment_name are required")
124
+ if timeout_s <= 0:
125
+ raise ValueError("timeout_s must be positive")
126
+ if max_tokens_per_doc <= 0:
127
+ raise ValueError("max_tokens_per_doc must be positive")
128
+
129
+ self._url = api_base.rstrip("/") + RERANK_PATH
130
+ self._api_key = api_key
131
+ self._deployment = deployment_name
132
+ self._timeout_s = timeout_s
133
+ self._max_tokens_per_doc = max_tokens_per_doc
134
+ self._transport = transport
135
+ self._breaker = _CircuitBreaker(breaker_threshold, breaker_open_seconds, clock)
136
+ self._clients: Dict[asyncio.AbstractEventLoop, httpx.AsyncClient] = {}
137
+ self._clients_lock = threading.Lock()
138
+
139
+ @property
140
+ def model(self) -> str:
141
+ return self._deployment
142
+
143
+ @property
144
+ def circuit_state(self) -> str:
145
+ return self._breaker.state
146
+
147
+ def __repr__(self) -> str:
148
+ return f"CohereFoundryReranker(deployment={self._deployment!r}, url={self._url!r})"
149
+
150
+ async def arerank(
151
+ self,
152
+ query: str,
153
+ documents: Sequence[str],
154
+ *,
155
+ top_n: Optional[int] = None,
156
+ ) -> RerankResponse:
157
+ """
158
+ Rerank ``documents`` against ``query``.
159
+
160
+ Returns items sorted by relevance, each pointing back into ``documents`` by index.
161
+
162
+ Raises:
163
+ RerankError: on any failure; ``kind`` classifies it.
164
+ """
165
+ if not documents:
166
+ return RerankResponse(items=[], search_units=None, latency_ms=0.0, model=self._deployment)
167
+ if top_n is not None and top_n <= 0:
168
+ raise ValueError("top_n must be positive")
169
+
170
+ body: Dict[str, Any] = {
171
+ "model": self._deployment,
172
+ "query": query,
173
+ "documents": list(documents),
174
+ "max_tokens_per_doc": self._max_tokens_per_doc,
175
+ }
176
+ if top_n is not None:
177
+ body["top_n"] = min(top_n, len(documents))
178
+ # Encoded once so the payload size can be logged: production latency is judged
179
+ # from these lines, and size is the first thing to correlate it with.
180
+ content = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
181
+ stats = {"docs": len(documents), "top_n": top_n, "payload_bytes": len(content)}
182
+
183
+ if not self._breaker.allow():
184
+ self._log_outcome(stats, 0.0, None, "circuit_open")
185
+ raise RerankError("circuit_open", "rerank circuit is open; call skipped")
186
+
187
+ with _tracer.start_as_current_span("rerank.cohere") as span:
188
+ span.set_attribute("rerank.deployment", self._deployment)
189
+ span.set_attribute("rerank.documents", len(documents))
190
+ span.set_attribute("rerank.payload_bytes", len(content))
191
+ if top_n is not None:
192
+ span.set_attribute("rerank.top_n", top_n)
193
+ started = time.perf_counter()
194
+ try:
195
+ response = await self._post(content)
196
+ result = self._parse(response, len(documents), started)
197
+ except RerankError as e:
198
+ latency_ms = (time.perf_counter() - started) * 1000
199
+ self._breaker.record(e.kind)
200
+ span.set_attribute("error.kind", e.kind)
201
+ span.set_attribute("rerank.latency_ms", latency_ms)
202
+ span.set_status(Status(StatusCode.ERROR, e.kind))
203
+ self._log_outcome(stats, latency_ms, None, e.kind, status=e.status)
204
+ raise
205
+ self._breaker.record(None)
206
+ span.set_attribute("rerank.latency_ms", result.latency_ms)
207
+ if result.search_units is not None:
208
+ span.set_attribute("rerank.search_units", result.search_units)
209
+ self._log_outcome(
210
+ stats, result.latency_ms, result.search_units, None,
211
+ top1=result.items[0].index if result.items else None,
212
+ top1_score=result.items[0].relevance_score if result.items else None,
213
+ )
214
+ return result
215
+
216
+ async def aclose(self) -> None:
217
+ """Close every per-loop HTTP client this instance opened."""
218
+ with self._clients_lock:
219
+ clients = list(self._clients.values())
220
+ self._clients.clear()
221
+ for client in clients:
222
+ try:
223
+ await client.aclose()
224
+ except Exception as e: # a client bound to an already-closed loop
225
+ logger.debug("[Rerank] ignoring error while closing client: %s", e)
226
+
227
+ def _client(self) -> httpx.AsyncClient:
228
+ loop = asyncio.get_running_loop()
229
+ with self._clients_lock:
230
+ for stale in [lp for lp in self._clients if lp.is_closed()]:
231
+ self._clients.pop(stale, None)
232
+ client = self._clients.get(loop)
233
+ if client is None:
234
+ client = httpx.AsyncClient(
235
+ timeout=httpx.Timeout(self._timeout_s),
236
+ transport=self._transport,
237
+ )
238
+ self._clients[loop] = client
239
+ return client
240
+
241
+ async def _post(self, content: bytes) -> httpx.Response:
242
+ # httpx timeouts are per phase (connect / write / read), so a slow upload plus a
243
+ # slow read could take several times timeout_s; wait_for makes it one deadline.
244
+ try:
245
+ response = await asyncio.wait_for(self._send(content), timeout=self._timeout_s)
246
+ except asyncio.TimeoutError as e:
247
+ raise RerankError("timeout", f"rerank request exceeded the {self._timeout_s}s deadline") from e
248
+
249
+ status = response.status_code
250
+ if status == 429:
251
+ raise RerankError("rate_limited", "rerank rate limited (429)", status=status)
252
+ if status >= 500:
253
+ raise RerankError("http_5xx", f"rerank server error ({status})", status=status)
254
+ if status >= 300:
255
+ raise RerankError("http_4xx", f"rerank request rejected ({status})", status=status)
256
+ return response
257
+
258
+ async def _send(self, content: bytes) -> httpx.Response:
259
+ try:
260
+ return await self._client().post(
261
+ self._url,
262
+ headers={"api-key": self._api_key, "Content-Type": "application/json"},
263
+ content=content,
264
+ )
265
+ except httpx.TimeoutException as e:
266
+ raise RerankError("timeout", f"rerank request timed out after {self._timeout_s}s") from e
267
+ except httpx.HTTPError as e:
268
+ raise RerankError("transport", f"rerank transport error: {type(e).__name__}") from e
269
+
270
+ def _parse(self, response: httpx.Response, n_docs: int, started: float) -> RerankResponse:
271
+ try:
272
+ payload = response.json()
273
+ except ValueError as e:
274
+ raise RerankError("bad_response", "rerank response is not JSON") from e
275
+
276
+ results = payload.get("results") if isinstance(payload, dict) else None
277
+ if not isinstance(results, list):
278
+ raise RerankError("bad_response", "rerank response has no results list")
279
+
280
+ items: List[RerankItem] = []
281
+ seen = set()
282
+ for entry in results:
283
+ if not isinstance(entry, dict):
284
+ raise RerankError("bad_response", "rerank result entry is not an object")
285
+ index = entry.get("index")
286
+ score = entry.get("relevance_score")
287
+ if isinstance(index, bool) or not isinstance(index, int) or not 0 <= index < n_docs or index in seen:
288
+ raise RerankError("bad_response", f"rerank result has an invalid index: {index!r}")
289
+ if isinstance(score, bool) or not isinstance(score, (int, float)) or math.isnan(score):
290
+ raise RerankError("bad_response", "rerank result has an invalid relevance_score")
291
+ seen.add(index)
292
+ items.append(RerankItem(index=index, relevance_score=float(score)))
293
+ items.sort(key=lambda item: item.relevance_score, reverse=True)
294
+
295
+ search_units = None
296
+ meta = payload.get("meta")
297
+ if isinstance(meta, dict) and isinstance(meta.get("billed_units"), dict):
298
+ units = meta["billed_units"].get("search_units")
299
+ if isinstance(units, (int, float)) and not isinstance(units, bool):
300
+ search_units = int(units)
301
+
302
+ return RerankResponse(
303
+ items=items,
304
+ search_units=search_units,
305
+ latency_ms=(time.perf_counter() - started) * 1000,
306
+ model=self._deployment,
307
+ )
308
+
309
+ def _log_outcome(
310
+ self,
311
+ stats: Dict[str, Any],
312
+ latency_ms: float,
313
+ search_units: Optional[int],
314
+ error_kind: Optional[RerankErrorKind],
315
+ *,
316
+ status: Optional[int] = None,
317
+ top1: Optional[int] = None,
318
+ top1_score: Optional[float] = None,
319
+ ) -> None:
320
+ # One line per call, same key=value layout either way, so a log query can group
321
+ # by deployment / outcome and plot latency against payload size. Never log the
322
+ # query, the documents or the key: they may carry customer data.
323
+ if error_kind is None:
324
+ logger.info(
325
+ "[Rerank] deployment=%s outcome=ok docs=%d top_n=%s payload_bytes=%d latency_ms=%.1f "
326
+ "search_units=%s top1_index=%s top1_score=%s circuit=%s",
327
+ self._deployment, stats["docs"], stats["top_n"], stats["payload_bytes"], latency_ms,
328
+ search_units, top1, f"{top1_score:.4f}" if top1_score is not None else None,
329
+ self._breaker.state,
330
+ )
331
+ else:
332
+ logger.warning(
333
+ "[Rerank] deployment=%s outcome=%s docs=%d top_n=%s payload_bytes=%d latency_ms=%.1f "
334
+ "status=%s timeout_s=%s circuit=%s",
335
+ self._deployment, error_kind, stats["docs"], stats["top_n"], stats["payload_bytes"],
336
+ latency_ms, status, self._timeout_s, self._breaker.state,
337
+ )
@@ -0,0 +1,61 @@
1
+ """
2
+ Build rerank clients from MODELS_CONFIG entries.
3
+
4
+ The same factory serves ModelLoadBalancer (agent-executor) and hosts that derive a
5
+ single entry from MODELS_CONFIG without running a balancer (sandbox), so both get
6
+ identical clients from identical config.
7
+ """
8
+ from typing import Any, Mapping
9
+
10
+ from .cohere_foundry import CohereFoundryReranker
11
+
12
+ RERANK_PROVIDERS = frozenset({"azure-foundry-cohere"})
13
+
14
+ _REQUIRED_FIELDS = ("api_base", "api_key", "deployment_name")
15
+
16
+
17
+ def create_reranker(entry: Mapping[str, Any]) -> CohereFoundryReranker:
18
+ """
19
+ Build a reranker from one MODELS_CONFIG entry.
20
+
21
+ Expected entry::
22
+
23
+ {"provider": "azure-foundry-cohere", "type": "rerank",
24
+ "api_base": "https://<resource>.services.ai.azure.com",
25
+ "deployment_name": "Cohere-rerank-v4.0-pro", "api_key": "...",
26
+ "timeout_ms": 3000, "max_tokens_per_doc": 4096,
27
+ "breaker": {"consecutive_failures": 5, "open_seconds": 30}}
28
+
29
+ Raises:
30
+ ValueError: unknown provider or a missing/invalid field. The message names
31
+ the field only, never its value.
32
+ """
33
+ provider = entry.get("provider")
34
+ if provider not in RERANK_PROVIDERS:
35
+ raise ValueError(f"Unsupported rerank provider: {provider!r}")
36
+
37
+ missing = [field for field in _REQUIRED_FIELDS if not entry.get(field)]
38
+ if missing:
39
+ raise ValueError(f"Rerank model config is missing required field(s): {', '.join(missing)}")
40
+
41
+ breaker = entry.get("breaker") or {}
42
+ if not isinstance(breaker, Mapping):
43
+ raise ValueError("Rerank model config field 'breaker' must be an object")
44
+
45
+ try:
46
+ timeout_s = float(entry.get("timeout_ms", 3000)) / 1000.0
47
+ max_tokens_per_doc = int(entry.get("max_tokens_per_doc", 4096))
48
+ breaker_threshold = int(breaker.get("consecutive_failures", 5))
49
+ breaker_open_seconds = float(breaker.get("open_seconds", 30))
50
+ except (TypeError, ValueError) as e:
51
+ raise ValueError(f"Rerank model config has a non-numeric tuning field: {e}") from None
52
+
53
+ return CohereFoundryReranker(
54
+ api_base=str(entry["api_base"]),
55
+ api_key=str(entry["api_key"]),
56
+ deployment_name=str(entry["deployment_name"]),
57
+ timeout_s=timeout_s,
58
+ max_tokens_per_doc=max_tokens_per_doc,
59
+ breaker_threshold=breaker_threshold,
60
+ breaker_open_seconds=breaker_open_seconds,
61
+ )