Files
duocthu/apps/ai-service/adapters/bedrock_converse.py
T
VuQuangBao 03467aeaf2 Attach real input/output/token usage to provider spans for Langfuse cost tracking
Langfuse showed $0.00 cost on every trace even after tracing was wired up:
provider.bedrock_converse.* spans carried only latency, no content or usage.
Converse's own response already includes token usage -- it just wasn't being
read. Captured on the adapter (last_usage, no interface change) and attached
to the span using Langfuse's own OTel attribute convention
(langfuse.observation.*), which its docs say takes precedence over generic
GenAI attributes. Verified live: a fresh trace now shows real prompt/completion
tokens and Langfuse computes real cost once given the model's actual per-token
price.
2026-08-25 16:44:55 +07:00

220 lines
9.3 KiB
Python

"""A Bedrock Converse-API answer generator (DeepSeek / Qwen / Nova / GLM …).
The counterpart to `bedrock_claude.py`, for every non-Anthropic model on
Bedrock. Those models are reached through the unified `converse` operation on
`bedrock-runtime` — the same client `embedding.py` already uses — so this adapter
adds no new SDK: `boto3` is imported lazily, and `rag/` still imports nothing.
One structural difference from the Anthropic path drives the shape of this file:
Converse has **no** server-side response schema (no `output_config.format`), so
the JSON envelope `answer.py` parses cannot be enforced by the API. It is asked
for in the prompt and then isolated here (`_extract_json`) before returning. If
the model still emits something unparseable, `answer.py` fails closed with a
diagnosable abstention instead of presenting a raw dump as a generated answer.
"""
from __future__ import annotations
import json
from typing import Any
from rag.ports import AnswerGenerationUnavailable, RerankUnavailable
BEDROCK_RUNTIME_SERVICE = "bedrock-runtime"
DEEPSEEK_V3_2 = "deepseek.v3.2"
# Sized for a rewrite of the retrieved evidence, not for open-ended generation:
# the section route can hand over a long section, and the answer restates it.
MAX_OUTPUT_TOKENS = 4096
# stopReasons that mean "a successful HTTP response carrying no usable answer".
# Treated as an outage so the caller degrades to the extractive text instead of
# reading content that was filtered away.
_EMPTY_STOP_REASONS = frozenset({"content_filtered", "guardrail_intervened"})
def _provider_error_types() -> tuple[type[BaseException], ...]:
"""botocore's error classes, or none when botocore is absent."""
try:
from botocore.exceptions import BotoCoreError, ClientError
except ImportError:
return ()
return (BotoCoreError, ClientError)
def _extract_json(text: str) -> str:
"""Isolate the JSON object from a Converse text block.
Converse cannot pin the output shape, so a model may wrap the object in a
```json fence or add a sentence around it. This returns the outermost
`{...}` span so `answer.py`'s `json.loads` sees the same clean envelope the
Anthropic adapter's schema-constrained path produces. If no object is found
the original text is returned, and the caller's parse fails closed.
"""
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
return text[start : end + 1]
return text
class BedrockConverseAnswerGenerator:
"""Rewrites retrieved evidence into prose via the Bedrock Converse API.
Model-agnostic: the model id is injected, so switching from DeepSeek to Qwen
or GLM is one config value (and one IAM resource ARN), no code change. What
the model returns is not trusted — `rag.grounding.verify` runs on every
answer this produces, so a fabricated figure yields a discarded generation,
not a wrong answer.
"""
def __init__(
self,
region: str = "us-east-1",
client: Any | None = None,
model_id: str = DEEPSEEK_V3_2,
max_tokens: int = MAX_OUTPUT_TOKENS,
) -> None:
self._region = region
self._client = client
self._model_id = model_id
self._max_tokens = max_tokens
# Converse's own response usage, captured for the observability layer
# (rag/instrumentation.py) to attach to its span. Not part of the
# `generate()` return contract -- every existing caller is unaffected.
self.last_usage: dict[str, int] | None = None
@property
def model_id(self) -> str:
return self._model_id
def _runtime(self) -> Any:
if self._client is None:
import boto3
from botocore.config import Config
self._client = boto3.client(
BEDROCK_RUNTIME_SERVICE,
region_name=self._region,
config=Config(
connect_timeout=5,
# The request budget is checked between calls and cannot
# interrupt boto3 while a call is in flight. A 60s read
# timeout with four SDK attempts allowed one turn to run
# for minutes. Allow one bounded retry: production smoke
# tests showed an isolated Qwen read timeout immediately
# followed by a healthy 3s response for the same request.
read_timeout=20,
retries={"total_max_attempts": 2, "mode": "standard"},
),
)
return self._client
def generate(self, system: str, user: str, schema: dict) -> str:
# The schema cannot be enforced by Converse, so it is stated in the
# message. Temperature 0: a formulary restatement is not a place for
# sampling variety.
directive = (
"Trả về DUY NHẤT một đối tượng JSON đúng schema sau, không kèm văn "
"bản nào khác, không dùng khối markdown ```:\n"
f"{json.dumps(schema, ensure_ascii=False)}"
)
try:
response = self._runtime().converse(
modelId=self._model_id,
system=[{"text": system}],
messages=[{"role": "user", "content": [{"text": f"{user}\n\n{directive}"}]}],
inferenceConfig={"maxTokens": self._max_tokens, "temperature": 0},
)
except _provider_error_types() as error:
raise AnswerGenerationUnavailable(
f"{self._model_id} could not be invoked: {type(error).__name__}"
) from error
self.last_usage = dict(response.get("usage") or {}) or None
if response.get("stopReason") in _EMPTY_STOP_REASONS:
raise AnswerGenerationUnavailable(
f"{self._model_id} produced no usable content "
f"(stopReason={response.get('stopReason')})"
)
blocks = response.get("output", {}).get("message", {}).get("content", [])
text = "".join(block.get("text", "") for block in blocks if isinstance(block, dict))
if not text.strip():
raise AnswerGenerationUnavailable(f"{self._model_id} returned no text content")
return _extract_json(text)
class BedrockCohereReranker:
"""Reorders candidate chunks by relevance with Cohere Rerank on Bedrock.
A cross-encoder rerank is the standard fix for the weak spot of pure vector
similarity: hit@1 0.544 was measured letting the embedding alone pick the
section, because the large pharmacology section sits close to every question.
Rerank scores each (query, chunk) pair jointly, so it recovers precision the
bi-encoder cannot. It is an optional improvement on the similarity fallback,
never on the deterministic section route — losing it reorders nothing, it
does not lose an answer.
"""
COHERE_RERANK_V3_5 = "cohere.rerank-v3-5:0"
def __init__(
self,
region: str = "us-east-1",
client: Any | None = None,
model_id: str = COHERE_RERANK_V3_5,
) -> None:
self._region = region
self._client = client
self._model_id = model_id
def _runtime(self) -> Any:
if self._client is None:
import boto3
from botocore.config import Config
self._client = boto3.client(
BEDROCK_RUNTIME_SERVICE,
region_name=self._region,
config=Config(
connect_timeout=10,
read_timeout=30,
# "adaptive" was tried 2026-08-07 and reverted same day: its
# client-side rate limiter remembers "throttled" across
# requests and paces even unrelated, otherwise-healthy calls
# down after a burst — turned a single answerable turn's
# baseline ~9s into 1-5 MINUTES following this session's own
# heavy adversarial test traffic. "standard" retries each
# call independently, no shared state to get stuck in a bad
# regime. max_attempts alone (3->4) is kept.
retries={"max_attempts": 4, "mode": "standard"},
),
)
return self._client
def rerank(self, query: str, documents: list[str], top_n: int | None = None) -> list[int]:
"""Return document indices, most relevant first. Never drops silently:
on any provider error it raises, and the caller keeps the input order."""
if not documents:
return []
n = top_n or len(documents)
try:
response = self._runtime().invoke_model(
modelId=self._model_id,
body=json.dumps(
{"query": query, "documents": documents, "top_n": n, "api_version": 2}
),
accept="*/*",
contentType="application/json",
)
except _provider_error_types() as error:
raise RerankUnavailable(
f"{self._model_id} could not be invoked: {type(error).__name__}"
) from error
body = json.loads(response["body"].read())
results = body.get("results")
if not results:
raise RerankUnavailable(f"{self._model_id} returned no results")
return [item["index"] for item in results]