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.
- rag_wright/__init__.py +13 -0
- rag_wright/api/__init__.py +33 -0
- rag_wright/api/config.py +59 -0
- rag_wright/api/discover.py +70 -0
- rag_wright/api/documents.py +39 -0
- rag_wright/api/ids.py +31 -0
- rag_wright/api/invoke.py +99 -0
- rag_wright/api/kg.py +61 -0
- rag_wright/api/mcp.py +94 -0
- rag_wright/api/usage.py +30 -0
- rag_wright/api/workspace.py +85 -0
- rag_wright/capabilities/__init__.py +8 -0
- rag_wright/capabilities/answer_generator.py +427 -0
- rag_wright/capabilities/ard.py +286 -0
- rag_wright/capabilities/assertion_extraction.py +79 -0
- rag_wright/capabilities/chunk_read.py +58 -0
- rag_wright/capabilities/chunk_write.py +163 -0
- rag_wright/capabilities/claim_extraction.py +153 -0
- rag_wright/capabilities/clause_exception_linking.py +117 -0
- rag_wright/capabilities/compliance_judgment.py +322 -0
- rag_wright/capabilities/compliance_store.py +87 -0
- rag_wright/capabilities/contract_kg_serve.py +156 -0
- rag_wright/capabilities/contract_kg_store.py +251 -0
- rag_wright/capabilities/dg_extraction.py +585 -0
- rag_wright/capabilities/disambiguation.py +163 -0
- rag_wright/capabilities/document_parse.py +87 -0
- rag_wright/capabilities/document_scope.py +49 -0
- rag_wright/capabilities/embedding.py +164 -0
- rag_wright/capabilities/embedding_profiles.py +43 -0
- rag_wright/capabilities/entity_resolution.py +154 -0
- rag_wright/capabilities/fusion.py +64 -0
- rag_wright/capabilities/graph_extraction.py +243 -0
- rag_wright/capabilities/graph_query.py +73 -0
- rag_wright/capabilities/graph_storage.py +111 -0
- rag_wright/capabilities/highlight_serve.py +142 -0
- rag_wright/capabilities/hybrid_search.py +65 -0
- rag_wright/capabilities/invoke.py +31 -0
- rag_wright/capabilities/jev_decision.py +38 -0
- rag_wright/capabilities/manifests.py +872 -0
- rag_wright/capabilities/okf_navigate.py +456 -0
- rag_wright/capabilities/parsing.py +286 -0
- rag_wright/capabilities/property_boosted_retrieval.py +125 -0
- rag_wright/capabilities/query_function_classifier.py +94 -0
- rag_wright/capabilities/query_understanding.py +109 -0
- rag_wright/capabilities/registry.py +262 -0
- rag_wright/capabilities/remote_encoders.py +94 -0
- rag_wright/capabilities/requirement_extraction.py +247 -0
- rag_wright/capabilities/reranking.py +123 -0
- rag_wright/capabilities/retrieval_core.py +126 -0
- rag_wright/capabilities/rlm_chunking.py +808 -0
- rag_wright/capabilities/rlm_synthesis.py +316 -0
- rag_wright/capabilities/scan_quality.py +136 -0
- rag_wright/capabilities/span_relevance_judgment.py +191 -0
- rag_wright/capabilities/vision_to_text.py +85 -0
- rag_wright/capabilities/vlm_ocr.py +85 -0
- rag_wright/contracts/__init__.py +6 -0
- rag_wright/contracts/chunk.py +79 -0
- rag_wright/contracts/compliance.py +303 -0
- rag_wright/contracts/contract_meta.py +27 -0
- rag_wright/contracts/extraction.py +130 -0
- rag_wright/contracts/function.py +167 -0
- rag_wright/contracts/function_routing.py +91 -0
- rag_wright/contracts/highlight.py +74 -0
- rag_wright/contracts/identifiers.py +153 -0
- rag_wright/contracts/jurisdiction.py +96 -0
- rag_wright/contracts/ontology.py +142 -0
- rag_wright/contracts/property.py +201 -0
- rag_wright/contracts/provenance.py +78 -0
- rag_wright/contracts/query_intent.py +53 -0
- rag_wright/contracts/span.py +76 -0
- rag_wright/contracts/value_match.py +84 -0
- rag_wright/corpus/__init__.py +0 -0
- rag_wright/corpus/canonicalize.py +116 -0
- rag_wright/corpus/cuad.py +153 -0
- rag_wright/corpus/cuad_ingestion.py +72 -0
- rag_wright/corpus/document_parser.py +299 -0
- rag_wright/corpus/edgar.py +231 -0
- rag_wright/corpus/gcs_ingestion.py +120 -0
- rag_wright/corpus/http.py +110 -0
- rag_wright/corpus/selection.py +152 -0
- rag_wright/mcp/__init__.py +11 -0
- rag_wright/mcp/compliance_server.py +299 -0
- rag_wright/mcp/intra_document_qa_server.py +170 -0
- rag_wright/mcp/relational_qa_server.py +171 -0
- rag_wright/mcp/session_store.py +64 -0
- rag_wright/mcp/typed_property_retrieval_server.py +191 -0
- rag_wright/models/__init__.py +8 -0
- rag_wright/models/profiles.py +331 -0
- rag_wright/models/seam.py +497 -0
- rag_wright/models/tag_structured.py +285 -0
- rag_wright/models/tracing.py +179 -0
- rag_wright/models/usage.py +102 -0
- rag_wright/okf/__init__.py +11 -0
- rag_wright/okf/compile.py +292 -0
- rag_wright/okf/document.py +47 -0
- rag_wright/okf/enrich.py +176 -0
- rag_wright/okf/links.py +190 -0
- rag_wright/okf/lint.py +105 -0
- rag_wright/ontology/__init__.py +6 -0
- rag_wright/ontology/_generated_template_meta.py +60 -0
- rag_wright/ontology/_generated_vocab.py +52 -0
- rag_wright/ontology/clause_template.py +964 -0
- rag_wright/ontology/codegen.py +84 -0
- rag_wright/ontology/compliance_bridge.ttl +186 -0
- rag_wright/ontology/contract_bridge.ttl +2685 -0
- rag_wright/ontology/contract_taxonomy.py +24 -0
- rag_wright/ontology/derive.py +58 -0
- rag_wright/ontology/loader.py +435 -0
- rag_wright/ontology/packs/ftc_16cfr255.ttl +29 -0
- rag_wright/ontology/registry.py +87 -0
- rag_wright/ontology/template_introspect.py +100 -0
- rag_wright/py.typed +0 -0
- rag_wright/reference/__init__.py +2 -0
- rag_wright/reference/compliance.py +41 -0
- rag_wright/reference/contract_seam.py +123 -0
- rag_wright/skills/__init__.py +7 -0
- rag_wright/skills/claim_extraction/SKILL.md +47 -0
- rag_wright/skills/claim_extraction/__init__.py +1 -0
- rag_wright/skills/claim_extraction/template.py +50 -0
- rag_wright/skills/compliance_judgment/SKILL.md +59 -0
- rag_wright/skills/corpus_ingest/SKILL.md +106 -0
- rag_wright/skills/extraction_semantic_judge/SKILL.md +51 -0
- rag_wright/skills/extraction_semantic_judge/__init__.py +1 -0
- rag_wright/skills/generation/SKILL.md +64 -0
- rag_wright/skills/generation/__init__.py +1 -0
- rag_wright/skills/generic_compliance_judgment/SKILL.md +58 -0
- rag_wright/skills/okf_navigate/SKILL.md +137 -0
- rag_wright/skills/requirement_extraction/SKILL.md +47 -0
- rag_wright/skills/requirement_extraction/__init__.py +1 -0
- rag_wright/skills/requirement_extraction/template.py +50 -0
- rag_wright/skills/rlm/SKILL.md +186 -0
- rag_wright/skills/rlm/__init__.py +31 -0
- rag_wright/skills/rlm/agent.py +292 -0
- rag_wright/skills/span_relevance_judgment/SKILL.md +67 -0
- rag_wright/skills/vision_to_text/SKILL.md +36 -0
- rag_wright/skills/vision_to_text/__init__.py +1 -0
- rag_wright/spans/__init__.py +1 -0
- rag_wright/spans/boundary.py +78 -0
- rag_wright/spans/clause_function_classifier.py +490 -0
- rag_wright/spans/clause_kg_extractor.py +337 -0
- rag_wright/spans/cuad_labels.py +81 -0
- rag_wright/spans/dim_classifier.py +158 -0
- rag_wright/spans/dim_fleet.json +411 -0
- rag_wright/spans/function_classifier.py +77 -0
- rag_wright/spans/function_families.py +62 -0
- rag_wright/spans/hybrid_classifier.py +103 -0
- rag_wright/spans/legalbert_classifier.py +83 -0
- rag_wright/spans/model_capabilities.py +107 -0
- rag_wright/spans/new_function_labels.py +111 -0
- rag_wright/spans/page_map.py +68 -0
- rag_wright/spans/property_extractor.py +365 -0
- rag_wright/spans/property_grounding.py +182 -0
- rag_wright/spans/reclassify.py +77 -0
- rag_wright/spans/scarce_function_labels.py +105 -0
- rag_wright/spans/segment.py +341 -0
- rag_wright/spans/semantic_judge.py +197 -0
- rag_wright/spans/symbolic_validation.py +131 -0
- rag_wright/spans/tag_clause_extractor.py +182 -0
- rag_wright/store/__init__.py +6 -0
- rag_wright/store/arcadedb.py +1135 -0
- rag_wright/store/chunk_text.py +66 -0
- rag_wright/store/seam.py +213 -0
- rag_wright/subgraphs/__init__.py +0 -0
- rag_wright/subgraphs/async_ingestion.py +204 -0
- rag_wright/subgraphs/compliance_check.py +1042 -0
- rag_wright/subgraphs/compliance_ingestion.py +306 -0
- rag_wright/subgraphs/contract_ingestion_pipeline.py +999 -0
- rag_wright/subgraphs/graph_extraction.py +102 -0
- rag_wright/subgraphs/intra_document_qa.py +328 -0
- rag_wright/subgraphs/observability.py +140 -0
- rag_wright/subgraphs/query_constraint_extraction.py +73 -0
- rag_wright/subgraphs/relational_qa.py +165 -0
- rag_wright/subgraphs/requirement_extraction.py +137 -0
- rag_wright/subgraphs/scaffold.py +65 -0
- rag_wright/subgraphs/semantic_chunking.py +183 -0
- rag_wright/subgraphs/typed_clause_extraction.py +172 -0
- rag_wright/subgraphs/typed_property_retrieval.py +278 -0
- rag_wright/util/__init__.py +1 -0
- rag_wright/util/concurrent.py +153 -0
- rag_wright/util/spacy_model.py +45 -0
- rag_wright-0.1.0.dist-info/METADATA +168 -0
- rag_wright-0.1.0.dist-info/RECORD +184 -0
- rag_wright-0.1.0.dist-info/WHEEL +4 -0
- 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
|
+
)
|