ingestlib 0.1.0__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.
Files changed (60) hide show
  1. ingestlib/__init__.py +4 -0
  2. ingestlib/config.py +231 -0
  3. ingestlib/foundations/__init__.py +10 -0
  4. ingestlib/foundations/llm/__init__.py +48 -0
  5. ingestlib/foundations/llm/bedrock/__init__.py +39 -0
  6. ingestlib/foundations/llm/bedrock/embedding.py +127 -0
  7. ingestlib/foundations/llm/bedrock/factory.py +101 -0
  8. ingestlib/foundations/llm/bedrock/nova.py +302 -0
  9. ingestlib/foundations/llm/bedrock/rerank.py +75 -0
  10. ingestlib/foundations/llm/jina/__init__.py +4 -0
  11. ingestlib/foundations/llm/jina/rerank.py +83 -0
  12. ingestlib/foundations/ocr/__init__.py +6 -0
  13. ingestlib/foundations/ocr/models.py +125 -0
  14. ingestlib/foundations/ocr/paddle_vl.py +272 -0
  15. ingestlib/operations/__init__.py +25 -0
  16. ingestlib/operations/classify/__init__.py +16 -0
  17. ingestlib/operations/classify/chunker.py +74 -0
  18. ingestlib/operations/classify/classifier.py +219 -0
  19. ingestlib/operations/classify/models.py +36 -0
  20. ingestlib/operations/parse/__init__.py +33 -0
  21. ingestlib/operations/parse/assembler.py +90 -0
  22. ingestlib/operations/parse/detector.py +28 -0
  23. ingestlib/operations/parse/enricher.py +124 -0
  24. ingestlib/operations/parse/loaders/__init__.py +39 -0
  25. ingestlib/operations/parse/loaders/office.py +117 -0
  26. ingestlib/operations/parse/loaders/pdf.py +185 -0
  27. ingestlib/operations/parse/models.py +162 -0
  28. ingestlib/operations/parse/pipeline.py +147 -0
  29. ingestlib/operations/parse/reviewer.py +136 -0
  30. ingestlib/operations/split/__init__.py +17 -0
  31. ingestlib/operations/split/models.py +90 -0
  32. ingestlib/operations/split/pages.py +182 -0
  33. ingestlib/operations/split/sections.py +119 -0
  34. ingestlib/operations/split/segmenter.py +172 -0
  35. ingestlib/operations/split/splitter.py +169 -0
  36. ingestlib/services/__init__.py +23 -0
  37. ingestlib/services/ingest/__init__.py +9 -0
  38. ingestlib/services/ingest/ingestor.py +152 -0
  39. ingestlib/services/ingest/models.py +30 -0
  40. ingestlib/services/retrieve/__init__.py +10 -0
  41. ingestlib/services/retrieve/models.py +43 -0
  42. ingestlib/services/retrieve/retriever.py +97 -0
  43. ingestlib/storage/__init__.py +49 -0
  44. ingestlib/storage/artifacts.py +337 -0
  45. ingestlib/storage/base.py +102 -0
  46. ingestlib/storage/pinecone/__init__.py +21 -0
  47. ingestlib/storage/pinecone/client.py +167 -0
  48. ingestlib/storage/pinecone/store.py +263 -0
  49. ingestlib/storage/qdrant/__init__.py +21 -0
  50. ingestlib/storage/qdrant/client.py +136 -0
  51. ingestlib/storage/qdrant/store.py +259 -0
  52. ingestlib/storage/s3/__init__.py +4 -0
  53. ingestlib/storage/s3/client.py +97 -0
  54. ingestlib/utils/__init__.py +7 -0
  55. ingestlib/utils/files.py +14 -0
  56. ingestlib/utils/logger.py +127 -0
  57. ingestlib-0.1.0.dist-info/METADATA +248 -0
  58. ingestlib-0.1.0.dist-info/RECORD +60 -0
  59. ingestlib-0.1.0.dist-info/WHEEL +4 -0
  60. ingestlib-0.1.0.dist-info/licenses/LICENSE +21 -0
ingestlib/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """ingestlib — self-hosted document intelligence library."""
2
+ from ingestlib.utils.logger import _auto_configure as _auto_configure_logger
3
+
4
+ _auto_configure_logger()
ingestlib/config.py ADDED
@@ -0,0 +1,231 @@
1
+ """Typed configuration for ingestlib — loaded lazily on first use.
2
+
3
+ Importing ingestlib never touches the filesystem; config loads when the
4
+ first operation needs it. Discovery order for config.yaml:
5
+
6
+ 1. INGESTLIB_CONFIG environment variable (explicit path)
7
+ 2. config.yaml in the current working directory, then each parent
8
+
9
+ Secrets never live in config.yaml: a .env sitting next to the discovered
10
+ config file is loaded automatically (JINA_API_KEY, PINECONE_API_KEY,
11
+ QDRANT_URL/_API_KEY), and AWS credentials resolve via the profile field
12
+ against ~/.aws/credentials — the standard boto3 chain.
13
+ """
14
+ import os
15
+ import threading
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ import yaml
20
+ from dotenv import load_dotenv
21
+
22
+
23
+ _CONFIG_ENV_VAR = "INGESTLIB_CONFIG"
24
+ _CONFIG_FILENAME = "config.yaml"
25
+
26
+ _lock = threading.Lock()
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class AWSConfig:
31
+ profile: str
32
+ region: str
33
+ account_id: str
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class BedrockConfig:
38
+ llm_model_id: str
39
+ embedding_model_id: str
40
+ rerank_model_id: str
41
+ rerank_region: str # separate region for rerank (amazon.rerank-v1:0 not in us-east-1)
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class JinaConfig:
46
+ api_key: str # from JINA_API_KEY env var
47
+ base_url: str
48
+ rerank_model_id: str
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class PaddleVLConfig:
53
+ backend: str # mlx-vlm-server (Apple Silicon) | vllm-server (NVIDIA)
54
+ server_url: str # VLM inference server URL
55
+ api_model_name: str
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class S3Config:
60
+ bucket: str # globally unique bucket name for pipeline artifacts
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class PineconeConfig:
65
+ api_key: str # from PINECONE_API_KEY env var
66
+ index_name: str
67
+ sparse_index_name: str # lexical half of hybrid search (created on first upsert)
68
+ sparse_model_id: str # Pinecone-hosted sparse embedding model (no corpus state)
69
+ cloud: str # serverless cloud provider (aws | gcp | azure)
70
+ region: str
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class QdrantConfig:
75
+ api_key: str # from QDRANT_API_KEY env var ("" for a local/unsecured server)
76
+ url: str # from QDRANT_URL env var (e.g. http://localhost:6333 or a cloud URL)
77
+ collection_name: str
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class IngestConfig:
82
+ vector_store: str # which connector the services default to (pinecone | qdrant)
83
+ aws: AWSConfig
84
+ bedrock: BedrockConfig
85
+ jina: JinaConfig
86
+ paddle_vl: PaddleVLConfig
87
+ s3: S3Config
88
+ pinecone: PineconeConfig
89
+ qdrant: QdrantConfig
90
+
91
+
92
+ def _find_config_path() -> Path:
93
+ """Locate config.yaml: INGESTLIB_CONFIG first, then CWD and its parents."""
94
+ explicit = os.environ.get(_CONFIG_ENV_VAR)
95
+ if explicit:
96
+ path = Path(explicit).expanduser()
97
+ if not path.is_file():
98
+ raise FileNotFoundError(
99
+ f"{_CONFIG_ENV_VAR} points to {path}, which does not exist"
100
+ )
101
+ return path
102
+ cwd = Path.cwd()
103
+ for directory in (cwd, *cwd.parents):
104
+ candidate = directory / _CONFIG_FILENAME
105
+ if candidate.is_file():
106
+ return candidate
107
+ raise FileNotFoundError(
108
+ f"{_CONFIG_FILENAME} not found in {cwd} or any parent directory — "
109
+ f"copy config.example.yaml from https://github.com/LangModule/ingestlib "
110
+ f"into your project, or set {_CONFIG_ENV_VAR}=/path/to/config.yaml"
111
+ )
112
+
113
+
114
+ def _load_config() -> IngestConfig:
115
+ config_path = _find_config_path()
116
+ # secrets conventionally sit next to the config file
117
+ load_dotenv(config_path.parent / ".env")
118
+
119
+ with open(config_path, "r") as f:
120
+ data = yaml.safe_load(f)
121
+
122
+ aws_data = data["aws"]
123
+ aws_config = AWSConfig(
124
+ profile=aws_data["profile"],
125
+ region=aws_data["region"],
126
+ account_id=str(aws_data["account_id"]),
127
+ )
128
+
129
+ bedrock_data = data["bedrock"]
130
+ bedrock_config = BedrockConfig(
131
+ llm_model_id=bedrock_data["llm_model_id"],
132
+ embedding_model_id=bedrock_data["embedding_model_id"],
133
+ rerank_model_id=bedrock_data["rerank_model_id"],
134
+ rerank_region=bedrock_data["rerank_region"],
135
+ )
136
+
137
+ jina_data = data["jina"]
138
+ jina_config = JinaConfig(
139
+ api_key=os.environ.get("JINA_API_KEY", ""),
140
+ base_url=jina_data.get("base_url", "https://api.jina.ai/v1"),
141
+ rerank_model_id=jina_data["rerank_model_id"],
142
+ )
143
+
144
+ paddle_vl_data = data["paddle_vl"]
145
+ paddle_vl_config = PaddleVLConfig(
146
+ backend=paddle_vl_data.get("backend", "mlx-vlm-server"),
147
+ server_url=paddle_vl_data.get("server_url", "http://localhost:8111/"),
148
+ api_model_name=paddle_vl_data["api_model_name"],
149
+ )
150
+
151
+ s3_data = data.get("s3", {})
152
+ s3_config = S3Config(
153
+ bucket=s3_data.get("bucket", f"ingestlib-{aws_config.account_id}"),
154
+ )
155
+
156
+ pinecone_data = data.get("pinecone", {})
157
+ index_name = pinecone_data.get("index_name", "ingestlib")
158
+ pinecone_config = PineconeConfig(
159
+ api_key=os.environ.get("PINECONE_API_KEY", ""),
160
+ index_name=index_name,
161
+ sparse_index_name=pinecone_data.get("sparse_index_name", f"{index_name}-sparse"),
162
+ sparse_model_id=pinecone_data.get("sparse_model_id", "pinecone-sparse-english-v0"),
163
+ cloud=pinecone_data.get("cloud", "aws"),
164
+ region=pinecone_data.get("region", "us-east-1"),
165
+ )
166
+
167
+ qdrant_data = data.get("qdrant", {})
168
+ qdrant_config = QdrantConfig(
169
+ api_key=os.environ.get("QDRANT_API_KEY", ""),
170
+ url=os.environ.get("QDRANT_URL", "http://localhost:6333"),
171
+ collection_name=qdrant_data.get("collection_name", "ingestlib"),
172
+ )
173
+
174
+ return IngestConfig(
175
+ vector_store=data.get("vector_store", "pinecone"),
176
+ aws=aws_config,
177
+ bedrock=bedrock_config,
178
+ jina=jina_config,
179
+ paddle_vl=paddle_vl_config,
180
+ s3=s3_config,
181
+ pinecone=pinecone_config,
182
+ qdrant=qdrant_config,
183
+ )
184
+
185
+
186
+ _config: IngestConfig | None = None
187
+
188
+
189
+ def get_config() -> IngestConfig:
190
+ """Full typed configuration, loaded lazily from config.yaml + .env and cached."""
191
+ global _config
192
+ if _config is None:
193
+ with _lock:
194
+ if _config is None:
195
+ _config = _load_config()
196
+ return _config
197
+
198
+
199
+ def get_aws_config() -> AWSConfig:
200
+ """AWS profile/region/account settings."""
201
+ return get_config().aws
202
+
203
+
204
+ def get_bedrock_config() -> BedrockConfig:
205
+ """Bedrock model IDs and rerank region."""
206
+ return get_config().bedrock
207
+
208
+
209
+ def get_jina_config() -> JinaConfig:
210
+ """Jina reranker endpoint and API key."""
211
+ return get_config().jina
212
+
213
+
214
+ def get_paddle_vl_config() -> PaddleVLConfig:
215
+ """PaddleOCR-VL inference server settings."""
216
+ return get_config().paddle_vl
217
+
218
+
219
+ def get_s3_config() -> S3Config:
220
+ """S3 artifact bucket settings."""
221
+ return get_config().s3
222
+
223
+
224
+ def get_pinecone_config() -> PineconeConfig:
225
+ """Pinecone index settings and API key."""
226
+ return get_config().pinecone
227
+
228
+
229
+ def get_qdrant_config() -> QdrantConfig:
230
+ """Qdrant endpoint, API key, and collection settings."""
231
+ return get_config().qdrant
@@ -0,0 +1,10 @@
1
+ """Foundation layers the operations are built on.
2
+
3
+ Sub-packages:
4
+ llm — Bedrock Nova (chat / thinking / structured output), multimodal
5
+ embeddings, and reranking (Jina primary, Amazon retained)
6
+ ocr — PaddleOCR-VL engine (layout + recognition) and the shared region types
7
+
8
+ Operations (parse / classify / split) compose these; nothing here knows the
9
+ operations exist.
10
+ """
@@ -0,0 +1,48 @@
1
+ """Public LLM surface.
2
+
3
+ - LLM/embedding primitives come from Bedrock (Nova family).
4
+ - Rerank is exposed twice with explicit provider suffixes — no ambiguous default:
5
+ aws_rerank / aws_arerank → amazon.rerank-v1:0 (quota-limited, keep for later)
6
+ jina_rerank / jina_arerank → Jina Reranker API (primary)
7
+ """
8
+ from ingestlib.foundations.llm.bedrock import (
9
+ Image,
10
+ achat,
11
+ achat_structured,
12
+ achat_with_thinking,
13
+ aembed_image,
14
+ aembed_text,
15
+ chat,
16
+ chat_structured,
17
+ chat_with_thinking,
18
+ embed_image,
19
+ embed_text,
20
+ get_llm,
21
+ get_llm_with_thinking,
22
+ reset_clients,
23
+ )
24
+ from ingestlib.foundations.llm.bedrock import arerank as aws_arerank
25
+ from ingestlib.foundations.llm.bedrock import rerank as aws_rerank
26
+ from ingestlib.foundations.llm.jina import arerank as jina_arerank
27
+ from ingestlib.foundations.llm.jina import rerank as jina_rerank
28
+
29
+ __all__ = [
30
+ "Image",
31
+ "get_llm",
32
+ "get_llm_with_thinking",
33
+ "chat",
34
+ "chat_with_thinking",
35
+ "achat",
36
+ "chat_structured",
37
+ "achat_structured",
38
+ "achat_with_thinking",
39
+ "embed_text",
40
+ "embed_image",
41
+ "aembed_text",
42
+ "aembed_image",
43
+ "aws_rerank",
44
+ "aws_arerank",
45
+ "jina_rerank",
46
+ "jina_arerank",
47
+ "reset_clients",
48
+ ]
@@ -0,0 +1,39 @@
1
+ """Bedrock backend: Nova LLM/embedding + amazon.rerank-v1:0. Clients built in factory.py."""
2
+ from ingestlib.foundations.llm.bedrock.embedding import (
3
+ aembed_image,
4
+ aembed_text,
5
+ embed_image,
6
+ embed_text,
7
+ )
8
+ from ingestlib.foundations.llm.bedrock.factory import reset_clients
9
+ from ingestlib.foundations.llm.bedrock.nova import (
10
+ Image,
11
+ achat,
12
+ achat_structured,
13
+ achat_with_thinking,
14
+ chat,
15
+ chat_structured,
16
+ chat_with_thinking,
17
+ get_llm,
18
+ get_llm_with_thinking,
19
+ )
20
+ from ingestlib.foundations.llm.bedrock.rerank import arerank, rerank
21
+
22
+ __all__ = [
23
+ "Image",
24
+ "get_llm",
25
+ "get_llm_with_thinking",
26
+ "chat",
27
+ "chat_with_thinking",
28
+ "achat",
29
+ "chat_structured",
30
+ "achat_structured",
31
+ "achat_with_thinking",
32
+ "embed_text",
33
+ "embed_image",
34
+ "aembed_text",
35
+ "aembed_image",
36
+ "rerank",
37
+ "arerank",
38
+ "reset_clients",
39
+ ]
@@ -0,0 +1,127 @@
1
+ """Nova 2 multimodal embeddings — text and image embed primitives (sync and async)."""
2
+ import asyncio
3
+ import base64
4
+ import json
5
+ import time
6
+ from typing import Any, Literal
7
+
8
+ from ingestlib.config import get_bedrock_config
9
+ from ingestlib.foundations.llm.bedrock.factory import get_runtime_client
10
+ from ingestlib.utils.logger import get_logger
11
+
12
+
13
+ logger = get_logger(__name__)
14
+
15
+ EmbeddingPurpose = Literal["GENERIC_INDEX", "GENERIC_RETRIEVAL", "DOCUMENT_RETRIEVAL"]
16
+ EmbeddingDimension = Literal[256, 384, 1024, 3072]
17
+ ImageFormat = Literal["jpeg", "png", "webp", "gif"]
18
+ ImageDetailLevel = Literal["STANDARD_IMAGE", "DOCUMENT_IMAGE"]
19
+
20
+ DEFAULT_DIMENSION: EmbeddingDimension = 1024
21
+ SUPPORTED_DIMENSIONS: tuple[int, ...] = (256, 384, 1024, 3072)
22
+
23
+
24
+ def _validate_dimension(dimension: int) -> None:
25
+ if dimension not in SUPPORTED_DIMENSIONS:
26
+ raise ValueError(
27
+ f"dimension must be one of {SUPPORTED_DIMENSIONS}, got {dimension}"
28
+ )
29
+
30
+
31
+ def _invoke(body: dict[str, Any]) -> dict[str, Any]:
32
+ client = get_runtime_client()
33
+ cfg = get_bedrock_config()
34
+ response = client.invoke_model(
35
+ modelId=cfg.embedding_model_id,
36
+ body=json.dumps(body),
37
+ contentType="application/json",
38
+ accept="application/json",
39
+ )
40
+ return json.loads(response["body"].read())
41
+
42
+
43
+ def _embed(
44
+ *,
45
+ content: dict[str, Any],
46
+ purpose: EmbeddingPurpose,
47
+ dimension: EmbeddingDimension,
48
+ ) -> list[float]:
49
+ _validate_dimension(dimension)
50
+ body = {
51
+ "taskType": "SINGLE_EMBEDDING",
52
+ "singleEmbeddingParams": {
53
+ "embeddingPurpose": purpose,
54
+ "embeddingDimension": dimension,
55
+ **content,
56
+ },
57
+ }
58
+ return _invoke(body)["embeddings"][0]["embedding"]
59
+
60
+
61
+ def embed_text(
62
+ text: str,
63
+ purpose: EmbeddingPurpose = "GENERIC_INDEX",
64
+ dimension: EmbeddingDimension = DEFAULT_DIMENSION,
65
+ ) -> list[float]:
66
+ """Embed text → vector of `dimension` floats (truncates overlong input at the end)."""
67
+ logger.info(
68
+ "embed_text: purpose=%s dim=%d input_len=%d", purpose, dimension, len(text),
69
+ )
70
+ t0 = time.perf_counter()
71
+ result = _embed(
72
+ content={"text": {"truncationMode": "END", "value": text}},
73
+ purpose=purpose,
74
+ dimension=dimension,
75
+ )
76
+ logger.info("embed_text done: %.2fs returned_dim=%d", time.perf_counter() - t0, len(result))
77
+ return result
78
+
79
+
80
+ def embed_image(
81
+ data: bytes,
82
+ format: ImageFormat,
83
+ purpose: EmbeddingPurpose = "GENERIC_INDEX",
84
+ dimension: EmbeddingDimension = DEFAULT_DIMENSION,
85
+ detail_level: ImageDetailLevel = "STANDARD_IMAGE",
86
+ ) -> list[float]:
87
+ """Embed an image → vector of `dimension` floats. DOCUMENT_IMAGE detail for doc pages."""
88
+ logger.info(
89
+ "embed_image: purpose=%s dim=%d format=%s detail_level=%s size=%d bytes",
90
+ purpose, dimension, format, detail_level, len(data),
91
+ )
92
+ t0 = time.perf_counter()
93
+ result = _embed(
94
+ content={
95
+ "image": {
96
+ "format": format,
97
+ "detailLevel": detail_level,
98
+ "source": {"bytes": base64.b64encode(data).decode()},
99
+ }
100
+ },
101
+ purpose=purpose,
102
+ dimension=dimension,
103
+ )
104
+ logger.info("embed_image done: %.2fs returned_dim=%d", time.perf_counter() - t0, len(result))
105
+ return result
106
+
107
+
108
+ async def aembed_text(
109
+ text: str,
110
+ purpose: EmbeddingPurpose = "GENERIC_INDEX",
111
+ dimension: EmbeddingDimension = DEFAULT_DIMENSION,
112
+ ) -> list[float]:
113
+ """Async embed_text() — runs the sync Bedrock client in a worker thread."""
114
+ return await asyncio.to_thread(embed_text, text, purpose, dimension)
115
+
116
+
117
+ async def aembed_image(
118
+ data: bytes,
119
+ format: ImageFormat,
120
+ purpose: EmbeddingPurpose = "GENERIC_INDEX",
121
+ dimension: EmbeddingDimension = DEFAULT_DIMENSION,
122
+ detail_level: ImageDetailLevel = "STANDARD_IMAGE",
123
+ ) -> list[float]:
124
+ """Async embed_image() — runs the sync Bedrock client in a worker thread."""
125
+ return await asyncio.to_thread(
126
+ embed_image, data, format, purpose, dimension, detail_level
127
+ )
@@ -0,0 +1,101 @@
1
+ """Shared boto3 session, Bedrock clients, and generation-keyed model cache."""
2
+ import threading
3
+
4
+ import boto3
5
+ from botocore.config import Config
6
+ from botocore.exceptions import ProfileNotFound
7
+
8
+ from ingestlib.config import get_aws_config, get_bedrock_config
9
+ from ingestlib.utils.logger import get_logger
10
+
11
+
12
+ logger = get_logger(__name__)
13
+ _lock = threading.Lock()
14
+
15
+ _session: boto3.Session | None = None
16
+ _runtime_client = None # bedrock-runtime (LLM inference + embeddings)
17
+ _rerank_agent_client = None # bedrock-agent-runtime in cfg.rerank_region (for rerank)
18
+ _client_generation: int = 0
19
+ _model_cache: dict[str, tuple[object, int]] = {}
20
+
21
+
22
+ def _build_clients() -> None:
23
+ global _session, _runtime_client, _rerank_agent_client, _client_generation
24
+
25
+ aws = get_aws_config()
26
+ bedrock = get_bedrock_config()
27
+ logger.info(
28
+ "building Bedrock clients: profile=%r region=%s rerank_region=%s",
29
+ aws.profile, aws.region, bedrock.rerank_region,
30
+ )
31
+
32
+ profile = (aws.profile or "").strip()
33
+ try:
34
+ _session = (
35
+ boto3.Session(profile_name=profile, region_name=aws.region)
36
+ if profile
37
+ else boto3.Session(region_name=aws.region)
38
+ )
39
+ except ProfileNotFound:
40
+ logger.warning("profile %r not found, falling back to default session", profile)
41
+ _session = boto3.Session(region_name=aws.region)
42
+
43
+ retry_cfg = Config(
44
+ retries={"total_max_attempts": 6, "mode": "standard"},
45
+ connect_timeout=10,
46
+ read_timeout=3600,
47
+ )
48
+ _runtime_client = _session.client(
49
+ "bedrock-runtime", region_name=aws.region, config=retry_cfg
50
+ )
51
+ _rerank_agent_client = _session.client(
52
+ "bedrock-agent-runtime", region_name=bedrock.rerank_region, config=retry_cfg
53
+ )
54
+ _client_generation += 1
55
+ _model_cache.clear()
56
+ logger.debug("clients built (generation=%d)", _client_generation)
57
+
58
+
59
+ def _ensure() -> None:
60
+ if _runtime_client is None:
61
+ _build_clients()
62
+
63
+
64
+ def get_runtime_client():
65
+ """Return the shared boto3 bedrock-runtime client (LLM + embeddings)."""
66
+ with _lock:
67
+ _ensure()
68
+ return _runtime_client
69
+
70
+
71
+ def get_rerank_agent_client():
72
+ """Return the boto3 bedrock-agent-runtime client bound to cfg.rerank_region."""
73
+ with _lock:
74
+ _ensure()
75
+ return _rerank_agent_client
76
+
77
+
78
+ def reset_clients() -> None:
79
+ """Force client recreation on the next call (e.g. after credential rotation)."""
80
+ global _session, _runtime_client, _rerank_agent_client
81
+ with _lock:
82
+ logger.info("resetting Bedrock clients (next call will rebuild)")
83
+ _session = None
84
+ _runtime_client = None
85
+ _rerank_agent_client = None
86
+
87
+
88
+ def get_model(key: str) -> object | None:
89
+ """Return a cached model instance if still valid for the current client generation."""
90
+ with _lock:
91
+ _ensure()
92
+ entry = _model_cache.get(key)
93
+ if entry is not None and entry[1] == _client_generation:
94
+ return entry[0]
95
+ return None
96
+
97
+
98
+ def cache_model(key: str, model: object) -> None:
99
+ """Store a model instance keyed to the current client generation."""
100
+ with _lock:
101
+ _model_cache[key] = (model, _client_generation)