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.
- {crewplus-0.3.4 → crewplus-0.3.6}/PKG-INFO +3 -1
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/__init__.py +12 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/model_load_balancer.py +8 -1
- crewplus-0.3.6/crewplus/services/rerankers/__init__.py +19 -0
- crewplus-0.3.6/crewplus/services/rerankers/base.py +51 -0
- crewplus-0.3.6/crewplus/services/rerankers/cohere_foundry.py +337 -0
- crewplus-0.3.6/crewplus/services/rerankers/factory.py +61 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/vectorstores/milvus/vdb_service.py +162 -30
- {crewplus-0.3.4 → crewplus-0.3.6}/pyproject.toml +3 -1
- crewplus-0.3.6/tests/test_cohere_foundry_reranker.py +425 -0
- crewplus-0.3.4/crewplus/__pycache__/__init__.cpython-311.pyc.1535298853824 +0 -0
- crewplus-0.3.4/crewplus/services/__pycache__/__init__.cpython-311.pyc.1535299230960 +0 -0
- crewplus-0.3.4/crewplus/services/__pycache__/gemini_chat_model.cpython-311.pyc.1535298773120 +0 -0
- crewplus-0.3.4/crewplus/services/__pycache__/runtime_model_factory.cpython-311.pyc.1959047557728 +0 -0
- crewplus-0.3.4/crewplus/services/runtime_model_factory.py +0 -400
- crewplus-0.3.4/tests/test_runtime_model_factory.py +0 -79
- {crewplus-0.3.4 → crewplus-0.3.6}/LICENSE +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/README.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/__init__.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/callbacks/__init__.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/callbacks/async_langfuse_handler.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/azure_chat_model.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/capabilities/__init__.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/capabilities/base.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/capabilities/bash.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/capabilities/registry.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/capabilities/web_search.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/claude_chat_model.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/feedback.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/feedback_manager.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/gemini_chat_model.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/init_services.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/oai_claude_chat_model.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/schemas/feedback.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/services/tracing_manager.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/utils/__init__.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/utils/schema_action.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/utils/schema_document_updater.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/utils/tracing_util.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/vectorstores/milvus/__init__.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/vectorstores/milvus/milvus_schema_manager.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/crewplus/vectorstores/milvus/schema_milvus.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/docs/GeminiChatModel.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/docs/ModelLoadBalancer.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/docs/VDBService.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/docs/index.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/README.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/conftest.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/gpt-5.4_run1.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/gpt-5.4_run2.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/gpt-5.6-luna_run1.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/gpt-5.6-luna_run2.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/gpt-5.6-terra_run1.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/gpt-5.6-terra_run2.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/page24_150dpi.png +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/report.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/results.json +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session1-luna-terra/gpt-5.6-luna_run1.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session1-luna-terra/gpt-5.6-luna_run2.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session1-luna-terra/gpt-5.6-terra_run1.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session1-luna-terra/gpt-5.6-terra_run2.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session1-luna-terra/report.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session1-luna-terra/results.json +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session2-55-sol/gpt-5.5_run1.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session2-55-sol/gpt-5.5_run2.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session2-55-sol/gpt-5.6-sol_run1.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session2-55-sol/gpt-5.6-sol_run2.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session2-55-sol/page24_150dpi.png +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session2-55-sol/report.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session2-55-sol/results.json +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/session2-55-sol/summary-report-55-sol.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/output/drawing_ingestion/summary-report.md +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/test_bash_tools.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/test_claude_api.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/test_claude_custom_endpoint_integration.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/test_claude_langchain_openai.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/test_claude_taskflow_schema.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/test_custom_claude_endpoint.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/test_gemini3_pro_preview.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/test_gemini_bind_tools.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/test_oai_claude_chat_model.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/test_ops_models_streaming.py +0 -0
- {crewplus-0.3.4 → crewplus-0.3.6}/tests/test_pdf_drawing_ingestion_gpt55_sol.py +0 -0
- {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.
|
|
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
|
+
)
|