rag-wright 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 (184) hide show
  1. rag_wright/__init__.py +13 -0
  2. rag_wright/api/__init__.py +33 -0
  3. rag_wright/api/config.py +59 -0
  4. rag_wright/api/discover.py +70 -0
  5. rag_wright/api/documents.py +39 -0
  6. rag_wright/api/ids.py +31 -0
  7. rag_wright/api/invoke.py +99 -0
  8. rag_wright/api/kg.py +61 -0
  9. rag_wright/api/mcp.py +94 -0
  10. rag_wright/api/usage.py +30 -0
  11. rag_wright/api/workspace.py +85 -0
  12. rag_wright/capabilities/__init__.py +8 -0
  13. rag_wright/capabilities/answer_generator.py +427 -0
  14. rag_wright/capabilities/ard.py +286 -0
  15. rag_wright/capabilities/assertion_extraction.py +79 -0
  16. rag_wright/capabilities/chunk_read.py +58 -0
  17. rag_wright/capabilities/chunk_write.py +163 -0
  18. rag_wright/capabilities/claim_extraction.py +153 -0
  19. rag_wright/capabilities/clause_exception_linking.py +117 -0
  20. rag_wright/capabilities/compliance_judgment.py +322 -0
  21. rag_wright/capabilities/compliance_store.py +87 -0
  22. rag_wright/capabilities/contract_kg_serve.py +156 -0
  23. rag_wright/capabilities/contract_kg_store.py +251 -0
  24. rag_wright/capabilities/dg_extraction.py +585 -0
  25. rag_wright/capabilities/disambiguation.py +163 -0
  26. rag_wright/capabilities/document_parse.py +87 -0
  27. rag_wright/capabilities/document_scope.py +49 -0
  28. rag_wright/capabilities/embedding.py +164 -0
  29. rag_wright/capabilities/embedding_profiles.py +43 -0
  30. rag_wright/capabilities/entity_resolution.py +154 -0
  31. rag_wright/capabilities/fusion.py +64 -0
  32. rag_wright/capabilities/graph_extraction.py +243 -0
  33. rag_wright/capabilities/graph_query.py +73 -0
  34. rag_wright/capabilities/graph_storage.py +111 -0
  35. rag_wright/capabilities/highlight_serve.py +142 -0
  36. rag_wright/capabilities/hybrid_search.py +65 -0
  37. rag_wright/capabilities/invoke.py +31 -0
  38. rag_wright/capabilities/jev_decision.py +38 -0
  39. rag_wright/capabilities/manifests.py +872 -0
  40. rag_wright/capabilities/okf_navigate.py +456 -0
  41. rag_wright/capabilities/parsing.py +286 -0
  42. rag_wright/capabilities/property_boosted_retrieval.py +125 -0
  43. rag_wright/capabilities/query_function_classifier.py +94 -0
  44. rag_wright/capabilities/query_understanding.py +109 -0
  45. rag_wright/capabilities/registry.py +262 -0
  46. rag_wright/capabilities/remote_encoders.py +94 -0
  47. rag_wright/capabilities/requirement_extraction.py +247 -0
  48. rag_wright/capabilities/reranking.py +123 -0
  49. rag_wright/capabilities/retrieval_core.py +126 -0
  50. rag_wright/capabilities/rlm_chunking.py +808 -0
  51. rag_wright/capabilities/rlm_synthesis.py +316 -0
  52. rag_wright/capabilities/scan_quality.py +136 -0
  53. rag_wright/capabilities/span_relevance_judgment.py +191 -0
  54. rag_wright/capabilities/vision_to_text.py +85 -0
  55. rag_wright/capabilities/vlm_ocr.py +85 -0
  56. rag_wright/contracts/__init__.py +6 -0
  57. rag_wright/contracts/chunk.py +79 -0
  58. rag_wright/contracts/compliance.py +303 -0
  59. rag_wright/contracts/contract_meta.py +27 -0
  60. rag_wright/contracts/extraction.py +130 -0
  61. rag_wright/contracts/function.py +167 -0
  62. rag_wright/contracts/function_routing.py +91 -0
  63. rag_wright/contracts/highlight.py +74 -0
  64. rag_wright/contracts/identifiers.py +153 -0
  65. rag_wright/contracts/jurisdiction.py +96 -0
  66. rag_wright/contracts/ontology.py +142 -0
  67. rag_wright/contracts/property.py +201 -0
  68. rag_wright/contracts/provenance.py +78 -0
  69. rag_wright/contracts/query_intent.py +53 -0
  70. rag_wright/contracts/span.py +76 -0
  71. rag_wright/contracts/value_match.py +84 -0
  72. rag_wright/corpus/__init__.py +0 -0
  73. rag_wright/corpus/canonicalize.py +116 -0
  74. rag_wright/corpus/cuad.py +153 -0
  75. rag_wright/corpus/cuad_ingestion.py +72 -0
  76. rag_wright/corpus/document_parser.py +299 -0
  77. rag_wright/corpus/edgar.py +231 -0
  78. rag_wright/corpus/gcs_ingestion.py +120 -0
  79. rag_wright/corpus/http.py +110 -0
  80. rag_wright/corpus/selection.py +152 -0
  81. rag_wright/mcp/__init__.py +11 -0
  82. rag_wright/mcp/compliance_server.py +299 -0
  83. rag_wright/mcp/intra_document_qa_server.py +170 -0
  84. rag_wright/mcp/relational_qa_server.py +171 -0
  85. rag_wright/mcp/session_store.py +64 -0
  86. rag_wright/mcp/typed_property_retrieval_server.py +191 -0
  87. rag_wright/models/__init__.py +8 -0
  88. rag_wright/models/profiles.py +331 -0
  89. rag_wright/models/seam.py +497 -0
  90. rag_wright/models/tag_structured.py +285 -0
  91. rag_wright/models/tracing.py +179 -0
  92. rag_wright/models/usage.py +102 -0
  93. rag_wright/okf/__init__.py +11 -0
  94. rag_wright/okf/compile.py +292 -0
  95. rag_wright/okf/document.py +47 -0
  96. rag_wright/okf/enrich.py +176 -0
  97. rag_wright/okf/links.py +190 -0
  98. rag_wright/okf/lint.py +105 -0
  99. rag_wright/ontology/__init__.py +6 -0
  100. rag_wright/ontology/_generated_template_meta.py +60 -0
  101. rag_wright/ontology/_generated_vocab.py +52 -0
  102. rag_wright/ontology/clause_template.py +964 -0
  103. rag_wright/ontology/codegen.py +84 -0
  104. rag_wright/ontology/compliance_bridge.ttl +186 -0
  105. rag_wright/ontology/contract_bridge.ttl +2685 -0
  106. rag_wright/ontology/contract_taxonomy.py +24 -0
  107. rag_wright/ontology/derive.py +58 -0
  108. rag_wright/ontology/loader.py +435 -0
  109. rag_wright/ontology/packs/ftc_16cfr255.ttl +29 -0
  110. rag_wright/ontology/registry.py +87 -0
  111. rag_wright/ontology/template_introspect.py +100 -0
  112. rag_wright/py.typed +0 -0
  113. rag_wright/reference/__init__.py +2 -0
  114. rag_wright/reference/compliance.py +41 -0
  115. rag_wright/reference/contract_seam.py +123 -0
  116. rag_wright/skills/__init__.py +7 -0
  117. rag_wright/skills/claim_extraction/SKILL.md +47 -0
  118. rag_wright/skills/claim_extraction/__init__.py +1 -0
  119. rag_wright/skills/claim_extraction/template.py +50 -0
  120. rag_wright/skills/compliance_judgment/SKILL.md +59 -0
  121. rag_wright/skills/corpus_ingest/SKILL.md +106 -0
  122. rag_wright/skills/extraction_semantic_judge/SKILL.md +51 -0
  123. rag_wright/skills/extraction_semantic_judge/__init__.py +1 -0
  124. rag_wright/skills/generation/SKILL.md +64 -0
  125. rag_wright/skills/generation/__init__.py +1 -0
  126. rag_wright/skills/generic_compliance_judgment/SKILL.md +58 -0
  127. rag_wright/skills/okf_navigate/SKILL.md +137 -0
  128. rag_wright/skills/requirement_extraction/SKILL.md +47 -0
  129. rag_wright/skills/requirement_extraction/__init__.py +1 -0
  130. rag_wright/skills/requirement_extraction/template.py +50 -0
  131. rag_wright/skills/rlm/SKILL.md +186 -0
  132. rag_wright/skills/rlm/__init__.py +31 -0
  133. rag_wright/skills/rlm/agent.py +292 -0
  134. rag_wright/skills/span_relevance_judgment/SKILL.md +67 -0
  135. rag_wright/skills/vision_to_text/SKILL.md +36 -0
  136. rag_wright/skills/vision_to_text/__init__.py +1 -0
  137. rag_wright/spans/__init__.py +1 -0
  138. rag_wright/spans/boundary.py +78 -0
  139. rag_wright/spans/clause_function_classifier.py +490 -0
  140. rag_wright/spans/clause_kg_extractor.py +337 -0
  141. rag_wright/spans/cuad_labels.py +81 -0
  142. rag_wright/spans/dim_classifier.py +158 -0
  143. rag_wright/spans/dim_fleet.json +411 -0
  144. rag_wright/spans/function_classifier.py +77 -0
  145. rag_wright/spans/function_families.py +62 -0
  146. rag_wright/spans/hybrid_classifier.py +103 -0
  147. rag_wright/spans/legalbert_classifier.py +83 -0
  148. rag_wright/spans/model_capabilities.py +107 -0
  149. rag_wright/spans/new_function_labels.py +111 -0
  150. rag_wright/spans/page_map.py +68 -0
  151. rag_wright/spans/property_extractor.py +365 -0
  152. rag_wright/spans/property_grounding.py +182 -0
  153. rag_wright/spans/reclassify.py +77 -0
  154. rag_wright/spans/scarce_function_labels.py +105 -0
  155. rag_wright/spans/segment.py +341 -0
  156. rag_wright/spans/semantic_judge.py +197 -0
  157. rag_wright/spans/symbolic_validation.py +131 -0
  158. rag_wright/spans/tag_clause_extractor.py +182 -0
  159. rag_wright/store/__init__.py +6 -0
  160. rag_wright/store/arcadedb.py +1135 -0
  161. rag_wright/store/chunk_text.py +66 -0
  162. rag_wright/store/seam.py +213 -0
  163. rag_wright/subgraphs/__init__.py +0 -0
  164. rag_wright/subgraphs/async_ingestion.py +204 -0
  165. rag_wright/subgraphs/compliance_check.py +1042 -0
  166. rag_wright/subgraphs/compliance_ingestion.py +306 -0
  167. rag_wright/subgraphs/contract_ingestion_pipeline.py +999 -0
  168. rag_wright/subgraphs/graph_extraction.py +102 -0
  169. rag_wright/subgraphs/intra_document_qa.py +328 -0
  170. rag_wright/subgraphs/observability.py +140 -0
  171. rag_wright/subgraphs/query_constraint_extraction.py +73 -0
  172. rag_wright/subgraphs/relational_qa.py +165 -0
  173. rag_wright/subgraphs/requirement_extraction.py +137 -0
  174. rag_wright/subgraphs/scaffold.py +65 -0
  175. rag_wright/subgraphs/semantic_chunking.py +183 -0
  176. rag_wright/subgraphs/typed_clause_extraction.py +172 -0
  177. rag_wright/subgraphs/typed_property_retrieval.py +278 -0
  178. rag_wright/util/__init__.py +1 -0
  179. rag_wright/util/concurrent.py +153 -0
  180. rag_wright/util/spacy_model.py +45 -0
  181. rag_wright-0.1.0.dist-info/METADATA +168 -0
  182. rag_wright-0.1.0.dist-info/RECORD +184 -0
  183. rag_wright-0.1.0.dist-info/WHEEL +4 -0
  184. rag_wright-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,497 @@
1
+ """The single construction point for the model client (T11).
2
+
3
+ Every model is built here, and `with_structured_output` is reached only here. A capability names a
4
+ model id (via `profiles.model_for(role)`) and asks the seam for a plain client or a
5
+ structured-output runnable; it never constructs `ChatOpenAI` or passes a provider/model flag itself.
6
+ The structured-only `extra_body` from the model's profile is applied to the forced structured call
7
+ alone, so free-text and reasoning calls on the same model are unaffected (CLAUDE.md standing rule).
8
+
9
+ Grounded against `langchain_openai.chat_models.base` (ADR-0001): `ChatOpenAI` takes `model`,
10
+ `temperature`, `api_key`, `base_url`; `with_structured_output(schema, method=..., **kwargs)` forwards
11
+ kwargs into the tool binding, so `extra_body` passed here binds to the structured runnable only.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import logging
18
+ import os
19
+ import random
20
+ import time
21
+ from collections.abc import Awaitable, Callable
22
+ from dataclasses import dataclass
23
+ from typing import Any, Optional
24
+
25
+ from langchain_core.runnables import Runnable, RunnableLambda
26
+ from langchain_openai import ChatOpenAI
27
+ from openai import APIConnectionError, APITimeoutError, InternalServerError, RateLimitError
28
+ from pydantic import PrivateAttr
29
+
30
+ from rag_wright.models.profiles import profile_for
31
+
32
+ log = logging.getLogger(__name__)
33
+
34
+
35
+ # Framework-native connection resilience (grounded: ChatOpenAI.max_retries/timeout + Runnable.with_retry).
36
+ # Retry is configured HERE at the single model construction point (ADR-0006), never hand-rolled at call sites.
37
+ #
38
+ # FREE-TEXT / plain build_model: the OpenAI SDK's own retry loop (max_retries) is the single layer, covering the
39
+ # native transients (429 / 5xx APIStatusError / connection / timeout), with the SDK's exponential backoff and
40
+ # Retry-After (429) handling intact. The okf_navigate agent's own model/sub-agent calls are covered separately
41
+ # by ModelRetryMiddleware.
42
+ #
43
+ # Bounded worst case (engine issue 0003 / ADR-0056): a persistent upstream stall makes each attempt hit the
44
+ # per-request timeout, then the SDK retries -- so the worst case is timeout x (max_retries + 1). At the old
45
+ # 120 x 7 that was ~14 min of idle-socket waiting (indistinguishable from a hang) on ANY free-text call
46
+ # (generation, reasoning, the ADR-0045 tag-parse structured path, vision-to-text, RLM chunk/synthesis). Bounded
47
+ # to 90 x 3 = 270s (~4.5 min): max_retries back to the SDK's own default of 2, timeout tightened but kept
48
+ # generous enough for legitimately longer free-text prose. (Unlike the structured path, the SDK loop stays, so
49
+ # its Retry-After handling is preserved -- there is no trade-off here.)
50
+ _MAX_RETRIES = 2 # SDK connection-resilience budget for 5xx/429/network on plain (free-text) calls (SDK default)
51
+ _TIMEOUT_S = 90.0 # per-request timeout for a plain call (a hang fails at 90s; worst case = 90 x (2+1) = 270s)
52
+ #
53
+ # STRUCTURED build_structured: ONE bounded retry layer, not two stacked (engine issue 0003 / ADR-0056). Before,
54
+ # build_structured wrapped `.with_retry` (3 attempts) AROUND a client that ALSO retried at the SDK (max_retries
55
+ # 6), so one logical structured call had a worst case of timeout x 6 x 3 = ~36 min -- long enough to look like a
56
+ # hang and to hold a synchronous single-doc ingest past any acceptable bound (NFR-1). Now the SDK loop is
57
+ # disabled for structured calls (max_retries=0) and the LangChain `.with_retry` is the SOLE layer, so the worst
58
+ # case is timeout x attempts = 60 x 3 = 180s, and the classifier's degrade path (empty sub-batch on exception)
59
+ # is reachable in ~3 min instead of ~36. TRADE-OFF: disabling the SDK loop loses its Retry-After (429) header
60
+ # handling; the tenacity exponential-jitter backoff on `.with_retry` substitutes for it (a documented, accepted
61
+ # substitution -- exponential-jitter backoff spaces out 429/5xx retries in its place).
62
+ # per-request timeout for a structured call. Default 60s (a hang fails here, not at 120s). Env-overridable because
63
+ # reasoning-ON structured calls under batch load can legitimately run ~50-70s (the server returns 200 OK, but the
64
+ # client would give up at 60s and retry -> a retry pileup); a bulk job can raise this (e.g. 150) to let them finish.
65
+ _STRUCTURED_TIMEOUT_S = float(os.getenv("RAG_STRUCTURED_TIMEOUT_S", "60"))
66
+ _STRUCTURED_RETRY_ATTEMPTS = 3 # the SOLE retry layer for structured calls; worst case ~= 60 x 3 = 180s
67
+ _STRUCTURED_RETRY_ON: tuple[type[BaseException], ...] = (
68
+ # OpenRouter surfaces a 504 "operation was aborted" as a plain ValueError the status-code retry cannot
69
+ # classify; the SDK transients are included here too because the SDK no longer retries them for structured
70
+ # calls (this `.with_retry` is now the only layer that will).
71
+ ValueError, APITimeoutError, APIConnectionError, RateLimitError, InternalServerError,
72
+ )
73
+
74
+
75
+ # TRUE wall-clock deadline for the ASYNC path (engine issue 0003 / ADR-0057). An httpx timeout is per-socket-op,
76
+ # so a slow-drip / SSE-keep-alive response resets the read clock indefinitely -- a single call ran 591s against a
77
+ # 60s timeout, and a 399s call succeeded with no exception at all. Only elapsed wall clock, enforced OUTSIDE the
78
+ # socket, can bound it. `asyncio.timeout` delivers a real CancelledError into the awaited call, so httpx closes
79
+ # the socket -- a true cancel, not the soft/leaked-thread cancel a synchronous watchdog gives.
80
+ _MODEL_DEADLINE_S = 180.0 # total wall-clock ceiling per LOGICAL model call (across bounded retries + backoff)
81
+
82
+
83
+ class ModelCallTimeout(Exception):
84
+ """A logical model call exceeded the total wall-clock deadline (`_MODEL_DEADLINE_S`) and was truly cancelled
85
+ (socket torn down). TERMINAL: a stalling peer is not a transient worth re-hitting, so this is deliberately
86
+ NOT in `_STRUCTURED_RETRY_ON` and must be kept out of any pregel `retry_on` -- the caller degrades or
87
+ dead-letters on it. It is the async fix a per-socket-op timeout cannot be (engine issue 0003 / ADR-0057)."""
88
+
89
+
90
+ def _backoff_s(attempt: int) -> float:
91
+ """Exponential backoff with jitter (the Retry-After substitute), capped. `attempt` is 1-based."""
92
+ return min(0.5 * (2 ** (attempt - 1)), 8.0) + random.uniform(0.0, 0.5)
93
+
94
+
95
+ def _openrouter_config() -> dict[str, Any]:
96
+ """OpenRouter connection config from env (default/dev + fallback serving path; secrets only in `.env`)."""
97
+ return {
98
+ "api_key": os.environ["OPENROUTER_API_KEY"],
99
+ "base_url": os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"),
100
+ }
101
+
102
+
103
+ def serving_backend() -> str:
104
+ """The selected serving backend name (`openrouter` | `vllm`) from `RAG_SERVING` (default `openrouter`).
105
+ The single source of the serving switch -- the seam AND the docling-graph extraction path (a separate
106
+ model surface, `dg_extraction.default_extraction_model`, MS1-3) both read it, so one env flips everything.
107
+ """
108
+ return os.getenv("RAG_SERVING", "openrouter").lower()
109
+
110
+
111
+ def _serving_config() -> dict[str, Any]:
112
+ """OpenAI-compatible connection config for the SELECTED serving backend, chosen by env WITHOUT hardcoding
113
+ a provider (MS1-1, ADR-0039). `openrouter` (default; dev + fallback) or `vllm` (the self-hosted Granite
114
+ product substrate). vLLM needs `VLLM_BASE_URL` (an OpenAI-compatible base, e.g.
115
+ `https://<app>.modal.run/v1`); `VLLM_API_KEY` is vLLM's `--api-key` bearer. The rest of the seam
116
+ (per-model profile `structured_method`, `extra_body`, retries/timeout) is backend-agnostic and unchanged.
117
+ """
118
+ serving = serving_backend()
119
+ if serving == "openrouter":
120
+ return _openrouter_config()
121
+ if serving == "vllm":
122
+ return {
123
+ "api_key": os.getenv("VLLM_API_KEY", "rw-vllm-dev-key"),
124
+ "base_url": os.environ["VLLM_BASE_URL"].rstrip("/"),
125
+ }
126
+ raise ValueError(f"RAG_SERVING must be 'openrouter' or 'vllm', got {serving!r}")
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class Connection:
131
+ """ADR-0100: how to reach a model -- resolved from its profile. `backend` is the routing target; `provider`
132
+ is the litellm provider name (for the extraction path); `served_model_id` is the id the backend expects."""
133
+
134
+ backend: str # openrouter | vllm | ollama
135
+ provider: str # litellm provider: openrouter | hosted_vllm | ollama
136
+ base_url: str
137
+ api_key: Optional[str]
138
+ served_model_id: str
139
+
140
+
141
+ def resolve_connection(model_id: str) -> Connection:
142
+ """ADR-0100: resolve a model STRING to its access (backend + base_url + key + the id the backend expects),
143
+ from its profile. A profile that PINS a `backend` routes there (so different strings can target OpenRouter
144
+ vs a self-hosted vLLM/Modal server -- mix at will); an un-pinned profile falls back to the global
145
+ `RAG_SERVING` default (back-compat). `base_url_env`/`api_key_env` on the profile override the per-backend
146
+ default env vars, so two distinct vLLM/Modal deployments are just two strings."""
147
+ profile = profile_for(model_id)
148
+ backend = profile.backend or serving_backend() # un-pinned -> RAG_SERVING
149
+ served = profile.served_model_id or model_id
150
+ if backend == "openrouter":
151
+ return Connection(
152
+ backend, "openrouter",
153
+ os.getenv(profile.base_url_env or "OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"),
154
+ os.environ.get(profile.api_key_env or "OPENROUTER_API_KEY"), served)
155
+ if backend == "vllm":
156
+ return Connection(
157
+ backend, "hosted_vllm",
158
+ os.environ[profile.base_url_env or "VLLM_BASE_URL"].rstrip("/"),
159
+ os.getenv(profile.api_key_env or "VLLM_API_KEY", "rw-vllm-dev-key"), served)
160
+ if backend == "ollama":
161
+ return Connection(
162
+ backend, "ollama",
163
+ os.getenv(profile.base_url_env or "OLLAMA_BASE_URL", "http://localhost:11434"), None, served)
164
+ raise ValueError(f"unknown backend {backend!r} for model {model_id!r} (openrouter | vllm | ollama)")
165
+
166
+
167
+ def _provider_pin() -> dict[str, Any]:
168
+ """OpenRouter provider routing from env (measurement/benchmark only). `OPENROUTER_PROVIDER` is a
169
+ comma-separated provider list; `OPENROUTER_ALLOW_FALLBACKS` (true/false) toggles routing beyond that list.
170
+
171
+ - single provider, no fallbacks (default): `OPENROUTER_PROVIDER=Cerebras` ->
172
+ `{"provider": {"only": ["Cerebras"], "allow_fallbacks": False}}` (a hard pin, to measure one provider).
173
+ - ordered preference + fallbacks: `OPENROUTER_PROVIDER=deepinfra/turbo,Cerebras,friendli` with
174
+ `OPENROUTER_ALLOW_FALLBACKS=true` -> `{"provider": {"order": [...], "allow_fallbacks": True}}` (try those
175
+ in order, then route around rate-limit/errors to any other provider).
176
+
177
+ Empty when unset, so normal routing is unaffected."""
178
+ raw = os.getenv("OPENROUTER_PROVIDER")
179
+ if not raw:
180
+ return {}
181
+ providers = [p.strip() for p in raw.split(",") if p.strip()]
182
+ if not providers:
183
+ return {}
184
+ allow = os.getenv("OPENROUTER_ALLOW_FALLBACKS", "").strip().lower() in ("1", "true", "yes")
185
+ if len(providers) == 1 and not allow:
186
+ return {"provider": {"only": providers, "allow_fallbacks": False}} # hard single-provider pin
187
+ return {"provider": {"order": providers, "allow_fallbacks": allow}} # ordered preference, fallbacks per env
188
+
189
+
190
+ class _CostCapturingChatOpenAI(ChatOpenAI):
191
+ """ISSUE-0021: OpenRouter returns the ACTUAL per-call `cost` on the final streaming chunk's `usage`, but
192
+ LangChain's streaming normalization (`_create_usage_metadata`) whitelists token counts and DROPS `cost` --
193
+ unlike `ainvoke`, which preserves the raw `token_usage` in `response_metadata`. Tap the raw chunk in the
194
+ (overridable) per-chunk converter to capture the real cost -- a pass-through, exactly like the litellm path --
195
+ WITHOUT touching any of the streaming / idle-drip / deadline / retry machinery. `_cost_holder` is per-instance,
196
+ so each `build_model(...)` call gets a fresh capture."""
197
+
198
+ _cost_holder: dict[str, Any] = PrivateAttr(default_factory=dict)
199
+
200
+ def _convert_chunk_to_generation_chunk(self, chunk: dict, default_chunk_class: type,
201
+ base_generation_info: dict | None) -> Any:
202
+ usage = chunk.get("usage") or {}
203
+ if usage.get("cost") is not None:
204
+ self._cost_holder["cost"] = usage.get("cost")
205
+ return super()._convert_chunk_to_generation_chunk(chunk, default_chunk_class, base_generation_info)
206
+
207
+
208
+ def build_model(model_id: str, *, temperature: float = 0.0, _client_cls: type[ChatOpenAI] | None = None,
209
+ **overrides: Any) -> ChatOpenAI:
210
+ """Construct the base client for `model_id`, carrying the profile's base `extra_body` (request-level
211
+ provider routing, e.g. OpenRouter throughput sort -- a config-driven provider flag, ADR-0027).
212
+
213
+ Model-level retry/timeout (framework connection resilience) are set here; a caller may override either.
214
+ `_client_cls` lets a caller substitute a thin ChatOpenAI subclass (e.g. astream_text's cost-capturing client,
215
+ issue 0021); it defaults to the plain client so every other caller is unchanged.
216
+ """
217
+ params: dict[str, Any] = {"max_retries": _MAX_RETRIES, "timeout": _TIMEOUT_S}
218
+ profile = profile_for(model_id)
219
+ caller_extra = overrides.pop("extra_body", None) # a per-call extra_body (e.g. astream_text's text_extra_body)
220
+ # MERGE order: profile base routing < env provider pin < caller extra_body -- so a caller adds/overrides a key
221
+ # (e.g. reasoning) WITHOUT dropping the profile's provider routing (a plain params.update would clobber it all).
222
+ extra_body = {**(profile.extra_body or {}), **_provider_pin(), **(caller_extra or {})}
223
+ if extra_body:
224
+ params["extra_body"] = extra_body
225
+ params.update(overrides) # caller overrides win
226
+ cls = _client_cls or ChatOpenAI # resolve at call time so a monkeypatched `seam.ChatOpenAI` (tests) is honored
227
+ conn = resolve_connection(model_id) # ADR-0100: backend + base_url + key + served id, from the profile
228
+ return cls(
229
+ model=conn.served_model_id, # the id the backend expects (== model_id for an un-pinned string)
230
+ temperature=temperature,
231
+ base_url=conn.base_url,
232
+ api_key=conn.api_key,
233
+ **params,
234
+ )
235
+
236
+
237
+ def build_structured(
238
+ model_id: str, schema: Any, *, include_raw: bool = False, temperature: float = 0.0,
239
+ max_tokens: int | None = None, label: str | None = None,
240
+ ) -> Runnable:
241
+ """A structured-output runnable for `model_id`, driven by its profile.
242
+
243
+ The profile supplies the method and the optional structured-only `extra_body`; the `extra_body`
244
+ is bound to this forced structured call only. This is the sole path to `with_structured_output`. The
245
+ runnable is wrapped in a SINGLE bounded retry layer (`_with_bounded_retry`); the SDK's own retry loop is
246
+ disabled here (max_retries=0) so the two do not stack into a ~36 min worst case (engine issue 0003 / ADR-0056).
247
+
248
+ `temperature` defaults to 0 (deterministic-intent); a caller doing best-of-N self-consistency raises it
249
+ to sample GENUINELY diverse structured completions (the base client's temperature, not a provider flag).
250
+ `max_tokens` caps the completion length -- a safety net against a model that runs away to the context
251
+ limit under a schema constraint (observed on self-hosted Gemma-4 with a mis-set chat template).
252
+ """
253
+ from rag_wright.models import tracing
254
+ from rag_wright.models import usage as usage_acct
255
+
256
+ profile = profile_for(model_id)
257
+ # ISSUE-0025 / issue 0042: the forced-structured path discards the raw response, so tokens + OpenRouter's
258
+ # ACTUAL cost (which `ainvoke` surfaces on the raw, issue 0021) would be lost. The runnable is built ONCE and
259
+ # invoked (concurrently) later, possibly inside a `usage_scope()` entered after build -- so we cannot decide
260
+ # per-call at build time; we ALWAYS force `include_raw=True` internally and read usage off the raw at finish,
261
+ # recording it into any active usage scope (issue 0042) and emitting a Langfuse generation when traced
262
+ # (ISSUE-0025). The caller's exact output shape and the raise-on-parse-failure contract are RESTORED below, so
263
+ # this is purely a client-side capture (no extra tokens, no extra round trip) with an unchanged external shape.
264
+ traced = tracing.tracing_on()
265
+ effective_include_raw = True
266
+ kwargs: dict[str, Any] = {"method": profile.structured_method, "include_raw": effective_include_raw}
267
+ # also put the env provider pin on the forced structured call (belt-and-suspenders: the base client carries
268
+ # it too, but with_structured_output's extra_body should not drop it).
269
+ structured_extra = {**(profile.structured_extra_body or {}), **_provider_pin()}
270
+ if structured_extra:
271
+ kwargs["extra_body"] = structured_extra
272
+ # ONE retry layer for structured calls (engine issue 0003 / ADR-0056): disable the SDK's own retry loop
273
+ # (max_retries=0) and use a tighter per-request timeout, so `_with_bounded_retry` is the sole, bounded layer
274
+ # (worst case = timeout x attempts, not multiplied by the SDK budget).
275
+ overrides: dict[str, Any] = {"max_retries": 0, "timeout": _STRUCTURED_TIMEOUT_S}
276
+ if max_tokens is not None:
277
+ overrides["max_tokens"] = max_tokens
278
+ inner: Runnable = build_model(model_id, temperature=temperature, **overrides).with_structured_output(
279
+ schema, **kwargs)
280
+ if not include_raw:
281
+ # we forced include_raw for instrumentation, but the caller wanted the parsed value with the native
282
+ # raise-on-parse-failure contract -- restore it so the bounded retry sees the SAME error it would have.
283
+ inner = inner | RunnableLambda(_raise_on_parse_error)
284
+ # Dual-path during the async migration (ADR-0057): `.invoke` keeps the sync bounded retry (ADR-0056) for
285
+ # not-yet-migrated callers; `.ainvoke` is the async bounded retry + TRUE wall-clock deadline. The sync path
286
+ # is removed once all callers are async (Phase D). `RunnableLambda(func, afunc=...)` routes each accordingly.
287
+ sync_runnable = _with_bounded_retry(inner, model_id)
288
+
289
+ def _finish(result: Any, latency_ms: float, gen: Any) -> Any:
290
+ raw = result.get("raw") if isinstance(result, dict) else None
291
+ inp, out, cost = _usage_from_raw(raw)
292
+ # issue 0042: record into any active usage scope (no-op if none) -- always, regardless of tracing.
293
+ usage_acct.record_usage(model_id, input_tokens=inp, output_tokens=out, cost=cost, latency_ms=latency_ms)
294
+ if gen is not None: # ISSUE-0025 / 0048: end the generation opened before the call (real span duration)
295
+ parsed = result.get("parsed") if isinstance(result, dict) else None
296
+ gid = _provider_gen_id(raw) # 0048: OpenRouter generation id for queue-vs-gen attribution
297
+ tracing.finish_generation(
298
+ gen, output=parsed, usage=({"input": inp, "output": out} if (inp or out) else None),
299
+ cost=cost, latency_ms=latency_ms,
300
+ metadata=({"openrouter_generation_id": gid} if gid else None))
301
+ # hand the caller back the exact shape it asked for (we forced include_raw internally)
302
+ return result if include_raw else (result.get("parsed") if isinstance(result, dict) else result)
303
+
304
+ def _sync(x: Any) -> Any:
305
+ # 0048: open the generation BEFORE the call so Langfuse's own latency is the real duration.
306
+ gen = tracing.start_generation(model=model_id, input=x, label=label, stage="build_structured") \
307
+ if traced else None
308
+ t0 = time.monotonic()
309
+ return _finish(sync_runnable.invoke(x), (time.monotonic() - t0) * 1000.0, gen)
310
+
311
+ async def _adeadline(x: Any) -> Any:
312
+ gen = tracing.start_generation(model=model_id, input=x, label=label, stage="build_structured") \
313
+ if traced else None
314
+ t0 = time.monotonic()
315
+ result = await _ainvoke_bounded(inner, x, model_id, label)
316
+ return _finish(result, (time.monotonic() - t0) * 1000.0, gen)
317
+
318
+ return RunnableLambda(_sync, afunc=_adeadline)
319
+
320
+
321
+ def _raise_on_parse_error(result: Any) -> Any:
322
+ """When `include_raw=True` was forced for instrumentation but the caller wanted the parsed value, restore the
323
+ native `include_raw=False` contract: re-raise the exact parse error so the bounded retry retries identically."""
324
+ if isinstance(result, dict) and result.get("parsing_error") is not None:
325
+ raise result["parsing_error"]
326
+ return result
327
+
328
+
329
+ def _usage_from_raw(raw: Any) -> tuple[int, int, Any]:
330
+ """(input_tokens, output_tokens, cost) off a raw structured response (`include_raw=True`); `(0, 0, None)`
331
+ when absent. `cost` is OpenRouter's ACTUAL per-call cost (issue 0021); `None` = the backend surfaced none
332
+ (priced from a table by Langfuse; counted as `calls_without_cost` in the usage scope, never as $0)."""
333
+ if raw is None:
334
+ return 0, 0, None
335
+ um = getattr(raw, "usage_metadata", None) or {}
336
+ token_usage = (getattr(raw, "response_metadata", {}) or {}).get("token_usage") or {}
337
+ return int(um.get("input_tokens", 0) or 0), int(um.get("output_tokens", 0) or 0), token_usage.get("cost")
338
+
339
+
340
+ def _provider_gen_id(raw: Any) -> Optional[str]:
341
+ """The provider's generation id off a raw response (LangChain sets it on `.id`; else `response_metadata.id`).
342
+ On OpenRouter this resolves at `/api/v1/generation?id=` -- recording it lets a slow call be ATTRIBUTED (queue
343
+ vs generation time) rather than guessed (issue 0048). None on backends that don't surface one (e.g. vLLM)."""
344
+ if raw is None:
345
+ return None
346
+ return getattr(raw, "id", None) or (getattr(raw, "response_metadata", {}) or {}).get("id")
347
+
348
+
349
+ def _call_desc(model_id: str, label: str | None) -> str:
350
+ """The model-call description used in the deadline/retry warnings + the timeout message. ADR-0058 side-fix
351
+ (issue 0004): include the STAGE/call-site (`label`) when the caller supplies it, so a timeout names WHICH
352
+ stage was cancelled (e.g. `granite-4.2-8b for semantic_chunking.discover`), not just the model."""
353
+ return f"{model_id} for {label}" if label else model_id
354
+
355
+
356
+ async def _bounded_deadline(
357
+ make_awaitable: Callable[[], Awaitable[Any]], model_id: str, label: str | None = None
358
+ ) -> Any:
359
+ """Run an async model operation under the single bounded retry layer AND a true total wall-clock deadline
360
+ (ADR-0057). `make_awaitable` is a factory returning a FRESH awaitable per attempt (a coroutine is single-use).
361
+ Shared by the structured `.ainvoke` path and the free-text `astream` path. `label` names the call-site/stage
362
+ in the warnings (ADR-0058, issue 0004 side-fix).
363
+
364
+ Bounded transient retries (the `_STRUCTURED_RETRY_ON` set) with per-attempt logging and exponential-jitter
365
+ backoff, ALL under one `asyncio.timeout(_MODEL_DEADLINE_S)`. A slow-drip or connection-alive stall that a
366
+ per-socket-op timeout never catches is CANCELLED at the deadline -- `asyncio.timeout` delivers CancelledError
367
+ into the awaited call, httpx closes the socket -- and surfaces as a terminal `ModelCallTimeout`. Retry sleeps
368
+ count against the same budget, so the total is bounded regardless of how it is spent."""
369
+ desc = _call_desc(model_id, label)
370
+
371
+ async def _run() -> Any:
372
+ for attempt in range(1, _STRUCTURED_RETRY_ATTEMPTS + 1):
373
+ start = time.monotonic()
374
+ try:
375
+ return await make_awaitable()
376
+ except _STRUCTURED_RETRY_ON as exc:
377
+ log.warning("model call to %s failed after %.1fs (%s); retry %d/%d",
378
+ desc, time.monotonic() - start, type(exc).__name__,
379
+ attempt, _STRUCTURED_RETRY_ATTEMPTS)
380
+ if attempt >= _STRUCTURED_RETRY_ATTEMPTS:
381
+ raise
382
+ await asyncio.sleep(_backoff_s(attempt))
383
+
384
+ try:
385
+ async with asyncio.timeout(_MODEL_DEADLINE_S):
386
+ return await _run()
387
+ except TimeoutError as exc:
388
+ log.warning("model call to %s exceeded the %.0fs total wall-clock deadline; cancelled",
389
+ desc, _MODEL_DEADLINE_S)
390
+ raise ModelCallTimeout(f"model call to {desc} exceeded {_MODEL_DEADLINE_S}s deadline") from exc
391
+
392
+
393
+ async def _ainvoke_bounded(runnable: Runnable, x: Any, model_id: str, label: str | None = None) -> Any:
394
+ """The structured async path: `.ainvoke` under the shared bounded retry + total deadline (ADR-0057)."""
395
+ return await _bounded_deadline(lambda: runnable.ainvoke(x), model_id, label)
396
+
397
+
398
+ # Idle-between-chunks guard for streamed free-text (ADR-0057, ASYNC-A3): if no chunk arrives within this window
399
+ # the stream raises -- a PRECISE drip-stall catch (the exact failure mode OpenRouter's SSE keep-alive comments
400
+ # hide) on top of the total deadline. A native `ChatOpenAI` field.
401
+ _STREAM_CHUNK_TIMEOUT_S = 60.0
402
+
403
+
404
+ async def astream_text(model_id: str, prompt: Any, *, temperature: float = 0.0,
405
+ max_tokens: int | None = None, label: str | None = None) -> str:
406
+ """Free-text generation via streaming (ADR-0057, ASYNC-A3). Streams with `stream_chunk_timeout` for precise
407
+ idle-drip detection, the total `asyncio.timeout` deadline for the whole call, and the bounded transient
408
+ retries -- accumulating the streamed chunks into the full text (the same value
409
+ `build_model(...).invoke(prompt).content` produced). `prompt` is a string or a message list."""
410
+ import time as _time
411
+
412
+ from rag_wright.models import tracing
413
+ from rag_wright.models import usage as usage_acct
414
+
415
+ overrides: dict[str, Any] = {
416
+ "max_retries": 0, "timeout": _STRUCTURED_TIMEOUT_S, "stream_chunk_timeout": _STREAM_CHUNK_TIMEOUT_S}
417
+ if max_tokens is not None:
418
+ overrides["max_tokens"] = max_tokens
419
+ # The FREE-TEXT reasoning control (profile.text_extra_body): applied to this streaming path only, never to a
420
+ # forced structured call. For a reasoning model this must be EXPLICIT -- unset, qwen3.8 streaming intermittently
421
+ # returns empty content (issue 0020). build_model merges it OVER the profile's base extra_body (provider routing).
422
+ text_eb = profile_for(model_id).text_extra_body
423
+ if text_eb:
424
+ overrides["extra_body"] = text_eb
425
+ traced = tracing.tracing_on()
426
+ # issue 0042: capture usage when tracing is on OR a usage scope is active (this is a per-call function, so the
427
+ # scope entered around the invoke is visible here). `stream_usage` makes LC/OpenRouter emit usage_metadata on
428
+ # the final chunk (issue 0017); the cost-capturing client recovers OpenRouter's real cost the chunk drops.
429
+ capture = traced or usage_acct.usage_capturing()
430
+ if capture:
431
+ overrides["stream_usage"] = True
432
+ # ISSUE-0021: use the cost-capturing client so OpenRouter's ACTUAL per-call cost (which LangChain's streaming
433
+ # normalization drops) is recovered from the raw final chunk -- no more $0.00/UNPRICED for an unpriced model.
434
+ client = build_model(model_id, temperature=temperature, _client_cls=_CostCapturingChatOpenAI, **overrides)
435
+ usage: dict[str, Any] = {}
436
+ ttft: list[Any] = [] # 0048: wall-clock of the FIRST content token (time to first token), for the queue/decode split
437
+ gid: list[str] = [] # 0048: the provider generation id (OpenRouter) off the stream, for call attribution
438
+
439
+ async def _consume() -> str:
440
+ import datetime as _dt
441
+ parts: list[str] = []
442
+ async for chunk in client.astream(prompt):
443
+ c = str(chunk.content)
444
+ if c and not ttft:
445
+ ttft.append(_dt.datetime.now(_dt.timezone.utc))
446
+ if not gid and getattr(chunk, "id", None):
447
+ gid.append(chunk.id)
448
+ parts.append(c)
449
+ um = getattr(chunk, "usage_metadata", None)
450
+ if um:
451
+ usage.update(um)
452
+ return "".join(parts)
453
+
454
+ # 0048: open the generation BEFORE the call so Langfuse's own latency is the real duration (not ~0).
455
+ gen = tracing.start_generation(model=model_id, input=prompt, label=label, stage="astream_text") \
456
+ if traced else None
457
+ t0 = _time.monotonic()
458
+ result = await _bounded_deadline(_consume, model_id, label)
459
+ latency_ms = (_time.monotonic() - t0) * 1000.0
460
+ inp, out = int(usage.get("input_tokens", 0) or 0), int(usage.get("output_tokens", 0) or 0)
461
+ # cost is the provider's ACTUAL total (pass-through, like the litellm path); None if the backend/model did not
462
+ # surface it (e.g. vLLM), so it counts as calls_without_cost / Langfuse prices from its table. getattr-guarded
463
+ # so a substituted client (tests / a non-cost-capturing class) degrades to no cost rather than raising.
464
+ cost = getattr(client, "_cost_holder", {}).get("cost")
465
+ # issue 0042: record into any active usage scope (no-op if none), regardless of tracing.
466
+ usage_acct.record_usage(model_id, input_tokens=inp, output_tokens=out, cost=cost, latency_ms=latency_ms)
467
+ if gen is not None: # 0048: end the generation opened above -> real span duration + time-to-first-token
468
+ u = {"input": inp, "output": out} if usage else None
469
+ tracing.finish_generation(gen, output=result, usage=u, cost=cost, latency_ms=latency_ms,
470
+ completion_start_time=(ttft[0] if ttft else None),
471
+ metadata=({"openrouter_generation_id": gid[0]} if gid else None))
472
+ return result
473
+
474
+
475
+ def _with_bounded_retry(runnable: Runnable, model_id: str) -> Runnable:
476
+ """Wrap a structured runnable in the SINGLE bounded retry layer (engine issue 0003 / ADR-0056): log each
477
+ failed attempt (elapsed + exception type) so a retrying call is visibly working rather than a silent hang,
478
+ then let `.with_retry` apply bounded, exponential-jitter backoff over the transient set. This is the ONLY
479
+ retry layer for structured calls -- the SDK's own loop is disabled (max_retries=0 in `build_structured`) --
480
+ so the worst-case wall clock is `_STRUCTURED_TIMEOUT_S x _STRUCTURED_RETRY_ATTEMPTS`, never multiplied by the
481
+ SDK budget. The exponential-jitter backoff also substitutes for the SDK's lost Retry-After (429) handling."""
482
+
483
+ def _attempt(x: Any) -> Any:
484
+ start = time.monotonic()
485
+ try:
486
+ return runnable.invoke(x)
487
+ except Exception as exc: # noqa: BLE001 - log the transient, then re-raise for the bounded retry above
488
+ log.warning(
489
+ "structured call to %s failed after %.1fs (%s); retrying within the %d-attempt budget",
490
+ model_id, time.monotonic() - start, type(exc).__name__, _STRUCTURED_RETRY_ATTEMPTS)
491
+ raise
492
+
493
+ return RunnableLambda(_attempt).with_retry(
494
+ retry_if_exception_type=_STRUCTURED_RETRY_ON,
495
+ wait_exponential_jitter=True,
496
+ stop_after_attempt=_STRUCTURED_RETRY_ATTEMPTS,
497
+ )