python-rerouting-library 0.2.1__py3-none-any.whl

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.
@@ -0,0 +1,25 @@
1
+ from .dispatcher import (
2
+ Dispatcher,
3
+ DispatchResult,
4
+ )
5
+ from .privacy import (
6
+ PrivacyDecision,
7
+ PrivacyDetector,
8
+ )
9
+ from .router import (
10
+ RouteDecision,
11
+ Router,
12
+ )
13
+
14
+
15
+ __all__ = [
16
+ "Router",
17
+ "RouteDecision",
18
+ "Dispatcher",
19
+ "DispatchResult",
20
+ "PrivacyDetector",
21
+ "PrivacyDecision",
22
+ ]
23
+
24
+
25
+ __version__ = "0.2.1"
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+
6
+ if TYPE_CHECKING:
7
+ from .cloud import CloudBackend
8
+ from .local_llama import LocalLlamaBackend
9
+
10
+
11
+ __all__ = [
12
+ "LocalLlamaBackend",
13
+ "CloudBackend",
14
+ ]
15
+
16
+
17
+ def __getattr__(name: str) -> Any:
18
+ if name == "CloudBackend":
19
+ from .cloud import CloudBackend
20
+
21
+ return CloudBackend
22
+
23
+ if name == "LocalLlamaBackend":
24
+ from .local_llama import LocalLlamaBackend
25
+
26
+ return LocalLlamaBackend
27
+
28
+ raise AttributeError(
29
+ f"module {__name__!r} has no attribute {name!r}"
30
+ )
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from openai import OpenAI
4
+
5
+ from python_rerouting_library.exceptions import CloudBackendError
6
+
7
+
8
+ class CloudBackend:
9
+ name = "cloud-api"
10
+
11
+ def __init__(
12
+ self,
13
+ *,
14
+ base_url: str,
15
+ model: str,
16
+ api_key: str,
17
+ max_tokens: int = 1024,
18
+ temperature: float = 0.0,
19
+ ) -> None:
20
+ self.client = OpenAI(
21
+ base_url=base_url,
22
+ api_key=api_key,
23
+ )
24
+
25
+ self.model = model
26
+ self.max_tokens = max_tokens
27
+ self.temperature = temperature
28
+
29
+ def generate(self, query: str) -> str:
30
+ try:
31
+ response = self.client.chat.completions.create(
32
+ model=self.model,
33
+ messages=[
34
+ {
35
+ "role": "user",
36
+ "content": query,
37
+ }
38
+ ],
39
+ temperature=self.temperature,
40
+ max_completion_tokens=self.max_tokens,
41
+ )
42
+
43
+ content = response.choices[0].message.content
44
+
45
+ if not content:
46
+ raise CloudBackendError(
47
+ "Cloud provider returned no text."
48
+ )
49
+
50
+ return content
51
+
52
+ except CloudBackendError:
53
+ raise
54
+
55
+ except Exception as exc:
56
+ raise CloudBackendError(
57
+ "Cloud API request failed."
58
+ ) from exc
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from llama_cpp import Llama
6
+
7
+ from python_rerouting_library.exceptions import LocalBackendError
8
+
9
+
10
+ class LocalLlamaBackend:
11
+ name = "local-llama"
12
+
13
+ def __init__(
14
+ self,
15
+ *,
16
+ model_path: str | Path,
17
+ n_ctx: int = 4096,
18
+ n_threads: int | None = None,
19
+ max_tokens: int = 512,
20
+ temperature: float = 0.0,
21
+ ) -> None:
22
+ self.model_path = Path(model_path)
23
+
24
+ if not self.model_path.is_file():
25
+ raise FileNotFoundError(
26
+ f"Local Llama model was not found: {self.model_path}"
27
+ )
28
+
29
+ self.max_tokens = max_tokens
30
+ self.temperature = temperature
31
+
32
+ try:
33
+ self._llm = Llama(
34
+ model_path=str(self.model_path),
35
+ n_ctx=n_ctx,
36
+ n_threads=n_threads,
37
+ verbose=False,
38
+ )
39
+
40
+ except Exception as exc:
41
+ raise LocalBackendError(
42
+ "Failed to load the local Llama model."
43
+ ) from exc
44
+
45
+ def generate(self, query: str) -> str:
46
+ try:
47
+ result = self._llm.create_chat_completion(
48
+ messages=[
49
+ {
50
+ "role": "user",
51
+ "content": query,
52
+ }
53
+ ],
54
+ max_tokens=self.max_tokens,
55
+ temperature=self.temperature,
56
+ )
57
+
58
+ content = result["choices"][0]["message"]["content"]
59
+
60
+ if not content:
61
+ raise LocalBackendError(
62
+ "Local Llama returned no text."
63
+ )
64
+
65
+ return content
66
+
67
+ except LocalBackendError:
68
+ raise
69
+
70
+ except Exception as exc:
71
+ raise LocalBackendError(
72
+ "Local Llama generation failed."
73
+ ) from exc
74
+ return result["choices"][0]["message"]["content"]
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Settings:
10
+ router_classifier_path: Path
11
+ llama_model_path: Path
12
+
13
+ cloud_api_key: str
14
+ cloud_base_url: str
15
+ cloud_model: str
16
+
17
+ local_max_tokens: int = 128
18
+ cloud_max_tokens: int = 256
19
+
20
+ router_simple_threshold: float = 0.40
21
+ router_complex_threshold: float = 0.60
22
+
23
+ @classmethod
24
+ def from_env(cls) -> "Settings":
25
+ llama_model_path = os.getenv(
26
+ "LLAMA_MODEL_PATH"
27
+ )
28
+
29
+ cloud_api_key = os.getenv(
30
+ "CLOUD_API_KEY"
31
+ )
32
+
33
+ router_classifier_path = os.getenv(
34
+ "ROUTER_CLASSIFIER_PATH"
35
+ )
36
+
37
+ if not llama_model_path:
38
+ raise RuntimeError(
39
+ "LLAMA_MODEL_PATH environment "
40
+ "variable is not set."
41
+ )
42
+
43
+ if not cloud_api_key:
44
+ raise RuntimeError(
45
+ "CLOUD_API_KEY environment "
46
+ "variable is not set."
47
+ )
48
+
49
+ if not router_classifier_path:
50
+ raise RuntimeError(
51
+ "ROUTER_CLASSIFIER_PATH environment "
52
+ "variable is not set. "
53
+ "python-rerouting-library does not "
54
+ "ship with a default complexity "
55
+ "classifier. Train a classifier with "
56
+ "'python -m "
57
+ "python_rerouting_library.training' "
58
+ "or provide your own compatible "
59
+ "classifier artifact, then set "
60
+ "ROUTER_CLASSIFIER_PATH to that file."
61
+ )
62
+
63
+ llama_path = Path(
64
+ llama_model_path
65
+ ).expanduser()
66
+
67
+ if not llama_path.is_file():
68
+ raise FileNotFoundError(
69
+ "Local Llama model was not found: "
70
+ f"{llama_path}"
71
+ )
72
+
73
+ router_path = Path(
74
+ router_classifier_path
75
+ ).expanduser()
76
+
77
+ if not router_path.is_file():
78
+ raise FileNotFoundError(
79
+ "Router classifier was not found: "
80
+ f"{router_path}. "
81
+ "python-rerouting-library does not "
82
+ "bundle a default classifier. "
83
+ "Train one or provide a compatible "
84
+ "classifier artifact and set "
85
+ "ROUTER_CLASSIFIER_PATH to its path."
86
+ )
87
+
88
+ cloud_base_url = os.getenv(
89
+ "CLOUD_BASE_URL",
90
+ "https://api.openai.com/v1",
91
+ )
92
+
93
+ cloud_model = os.getenv(
94
+ "CLOUD_MODEL",
95
+ "gpt-5.4-nano",
96
+ )
97
+
98
+ return cls(
99
+ router_classifier_path=router_path,
100
+ llama_model_path=llama_path,
101
+ cloud_api_key=cloud_api_key,
102
+ cloud_base_url=cloud_base_url,
103
+ cloud_model=cloud_model,
104
+ local_max_tokens=int(
105
+ os.getenv(
106
+ "LOCAL_MAX_TOKENS",
107
+ "128",
108
+ )
109
+ ),
110
+ cloud_max_tokens=int(
111
+ os.getenv(
112
+ "CLOUD_MAX_TOKENS",
113
+ "256",
114
+ )
115
+ ),
116
+ router_simple_threshold=float(
117
+ os.getenv(
118
+ "ROUTER_SIMPLE_THRESHOLD",
119
+ "0.40",
120
+ )
121
+ ),
122
+ router_complex_threshold=float(
123
+ os.getenv(
124
+ "ROUTER_COMPLEX_THRESHOLD",
125
+ "0.60",
126
+ )
127
+ ),
128
+ )
@@ -0,0 +1,200 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from time import perf_counter
5
+ from typing import Protocol
6
+
7
+ from .exceptions import (
8
+ CloudBackendError,
9
+ DispatchError,
10
+ LocalBackendError,
11
+ )
12
+ from .privacy import PrivacyDetector
13
+ from .router import RouteDecision, Router
14
+
15
+
16
+ class Backend(Protocol):
17
+ name: str
18
+
19
+ def generate(
20
+ self,
21
+ query: str,
22
+ ) -> str:
23
+ ...
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class DispatchResult:
28
+ text: str
29
+ route: RouteDecision
30
+ backend_name: str
31
+ fallback_used: bool = False
32
+ privacy_categories: tuple[str, ...] = ()
33
+
34
+
35
+ class Dispatcher:
36
+ def __init__(
37
+ self,
38
+ *,
39
+ router: Router,
40
+ simple_backend: Backend,
41
+ complex_backend: Backend,
42
+ privacy_detector: (
43
+ PrivacyDetector | None
44
+ ) = None,
45
+ ) -> None:
46
+ self.router = router
47
+
48
+ self.simple_backend = (
49
+ simple_backend
50
+ )
51
+
52
+ self.complex_backend = (
53
+ complex_backend
54
+ )
55
+
56
+ self.privacy_detector = (
57
+ privacy_detector
58
+ or PrivacyDetector()
59
+ )
60
+
61
+ def run(
62
+ self,
63
+ query: str,
64
+ ) -> DispatchResult:
65
+ privacy_start = perf_counter()
66
+
67
+ privacy_decision = (
68
+ self.privacy_detector.detect(
69
+ query
70
+ )
71
+ )
72
+
73
+ privacy_latency_ms = (
74
+ perf_counter()
75
+ - privacy_start
76
+ ) * 1000.0
77
+
78
+ # PRIVACY OVERRIDE:
79
+ # local execution only.
80
+ #
81
+ # Cloud fallback is intentionally
82
+ # prohibited for privacy-sensitive
83
+ # queries.
84
+ if privacy_decision.is_sensitive:
85
+ decision = RouteDecision(
86
+ label="privacy_override",
87
+ confidence=None,
88
+ complex_probability=None,
89
+ latency_ms=privacy_latency_ms,
90
+ )
91
+
92
+ try:
93
+ text = (
94
+ self.simple_backend.generate(
95
+ query
96
+ )
97
+ )
98
+
99
+ return DispatchResult(
100
+ text=text,
101
+ route=decision,
102
+ backend_name=(
103
+ self.simple_backend.name
104
+ ),
105
+ fallback_used=False,
106
+ privacy_categories=(
107
+ privacy_decision.categories
108
+ ),
109
+ )
110
+
111
+ except LocalBackendError as error:
112
+ raise DispatchError(
113
+ "Privacy override requires "
114
+ "local processing, but the "
115
+ "local backend failed. "
116
+ "Cloud fallback was not "
117
+ "attempted."
118
+ ) from error
119
+
120
+ # Clean queries continue to the
121
+ # semantic complexity router.
122
+ decision = self.router.route(query)
123
+
124
+ # SIMPLE:
125
+ # local first, cloud fallback.
126
+ if decision.label == "simple":
127
+ try:
128
+ text = (
129
+ self.simple_backend.generate(
130
+ query
131
+ )
132
+ )
133
+
134
+ return DispatchResult(
135
+ text=text,
136
+ route=decision,
137
+ backend_name=(
138
+ self.simple_backend.name
139
+ ),
140
+ fallback_used=False,
141
+ )
142
+
143
+ except LocalBackendError:
144
+ try:
145
+ text = (
146
+ self.complex_backend.generate(
147
+ query
148
+ )
149
+ )
150
+
151
+ return DispatchResult(
152
+ text=text,
153
+ route=decision,
154
+ backend_name=(
155
+ self.complex_backend.name
156
+ ),
157
+ fallback_used=True,
158
+ )
159
+
160
+ except CloudBackendError as error:
161
+ raise DispatchError(
162
+ "Local backend failed "
163
+ "and cloud fallback "
164
+ "also failed."
165
+ ) from error
166
+
167
+ # COMPLEX or UNCERTAIN:
168
+ # cloud only.
169
+ if decision.label in {
170
+ "complex",
171
+ "uncertain",
172
+ }:
173
+ try:
174
+ text = (
175
+ self.complex_backend.generate(
176
+ query
177
+ )
178
+ )
179
+
180
+ return DispatchResult(
181
+ text=text,
182
+ route=decision,
183
+ backend_name=(
184
+ self.complex_backend.name
185
+ ),
186
+ fallback_used=False,
187
+ )
188
+
189
+ except CloudBackendError as error:
190
+ raise DispatchError(
191
+ "Cloud backend failed for "
192
+ "a query requiring cloud "
193
+ "routing. Local fallback "
194
+ "was not attempted."
195
+ ) from error
196
+
197
+ raise DispatchError(
198
+ "Unsupported route label: "
199
+ f"{decision.label!r}"
200
+ )
@@ -0,0 +1,18 @@
1
+ class ReroutingLibraryError(Exception):
2
+ """Base exception for the routing library."""
3
+
4
+
5
+ class BackendError(ReroutingLibraryError):
6
+ """Base exception for backend failures."""
7
+
8
+
9
+ class LocalBackendError(BackendError):
10
+ """Local Llama backend failed."""
11
+
12
+
13
+ class CloudBackendError(BackendError):
14
+ """Cloud backend failed."""
15
+
16
+
17
+ class DispatchError(ReroutingLibraryError):
18
+ """Dispatcher could not complete the request."""
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class PrivacyDecision:
9
+ is_sensitive: bool
10
+ categories: tuple[str, ...]
11
+
12
+
13
+ class PrivacyDetector:
14
+ """
15
+ Detect common sensitive-data patterns before cloud routing.
16
+
17
+ The detector returns only category names. It never returns
18
+ or logs the matched sensitive value.
19
+ """
20
+
21
+ _EMAIL_PATTERNS = (
22
+ re.compile(
23
+ r"\b[A-Za-z0-9._%+-]+@"
24
+ r"[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"
25
+ ),
26
+ )
27
+
28
+ _PHONE_PATTERNS = (
29
+ re.compile(
30
+ r"(?<!\d)"
31
+ r"(?:\+?1[\s.-]?)?"
32
+ r"(?:\(\d{3}\)|\d{3})"
33
+ r"[\s.-]?\d{3}"
34
+ r"[\s.-]?\d{4}"
35
+ r"(?!\d)"
36
+ ),
37
+ )
38
+
39
+ _SSN_PATTERNS = (
40
+ re.compile(
41
+ r"\b\d{3}-\d{2}-\d{4}\b"
42
+ ),
43
+ )
44
+
45
+ _API_KEY_PATTERNS = (
46
+ # OpenAI-style secret keys.
47
+ re.compile(
48
+ r"\bsk-[A-Za-z0-9_-]{20,}\b"
49
+ ),
50
+
51
+ # GitHub classic-style tokens.
52
+ re.compile(
53
+ r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"
54
+ ),
55
+
56
+ # GitHub fine-grained tokens.
57
+ re.compile(
58
+ r"\bgithub_pat_[A-Za-z0-9_]{20,}\b"
59
+ ),
60
+
61
+ # Google API-key style.
62
+ re.compile(
63
+ r"\bAIza[A-Za-z0-9_-]{35}\b"
64
+ ),
65
+
66
+ # AWS access-key identifiers.
67
+ re.compile(
68
+ r"\bAKIA[0-9A-Z]{16}\b"
69
+ ),
70
+ )
71
+
72
+ _CREDIT_CARD_CANDIDATE = re.compile(
73
+ r"(?<!\d)"
74
+ r"(?:\d[ -]*?){12,18}\d"
75
+ r"(?!\d)"
76
+ )
77
+
78
+ def detect(self, text: str) -> PrivacyDecision:
79
+ if not isinstance(text, str):
80
+ raise TypeError(
81
+ "PrivacyDetector input must be a string."
82
+ )
83
+
84
+ categories: list[str] = []
85
+
86
+ if self._matches(
87
+ text,
88
+ self._EMAIL_PATTERNS,
89
+ ):
90
+ categories.append("email")
91
+
92
+ if self._matches(
93
+ text,
94
+ self._PHONE_PATTERNS,
95
+ ):
96
+ categories.append("phone")
97
+
98
+ if self._matches(
99
+ text,
100
+ self._SSN_PATTERNS,
101
+ ):
102
+ categories.append("ssn")
103
+
104
+ if self._matches(
105
+ text,
106
+ self._API_KEY_PATTERNS,
107
+ ):
108
+ categories.append("api_key")
109
+
110
+ if self._contains_credit_card(text):
111
+ categories.append("credit_card")
112
+
113
+ return PrivacyDecision(
114
+ is_sensitive=bool(categories),
115
+ categories=tuple(categories),
116
+ )
117
+
118
+ @staticmethod
119
+ def _matches(
120
+ text: str,
121
+ patterns: tuple[re.Pattern[str], ...],
122
+ ) -> bool:
123
+ return any(
124
+ pattern.search(text)
125
+ for pattern in patterns
126
+ )
127
+
128
+ def _contains_credit_card(
129
+ self,
130
+ text: str,
131
+ ) -> bool:
132
+ for match in self._CREDIT_CARD_CANDIDATE.finditer(
133
+ text
134
+ ):
135
+ digits = re.sub(
136
+ r"\D",
137
+ "",
138
+ match.group(0),
139
+ )
140
+
141
+ if not 13 <= len(digits) <= 19:
142
+ continue
143
+
144
+ if len(set(digits)) == 1:
145
+ continue
146
+
147
+ if self._passes_luhn(digits):
148
+ return True
149
+
150
+ return False
151
+
152
+ @staticmethod
153
+ def _passes_luhn(number: str) -> bool:
154
+ digits = [
155
+ int(character)
156
+ for character in number
157
+ ]
158
+
159
+ checksum = 0
160
+ parity = len(digits) % 2
161
+
162
+ for index, digit in enumerate(digits):
163
+ if index % 2 == parity:
164
+ digit *= 2
165
+
166
+ if digit > 9:
167
+ digit -= 9
168
+
169
+ checksum += digit
170
+
171
+ return checksum % 10 == 0