Skip to content

Commit 263eba1

Browse files
authored
fix(embeddings): truncate oversized litellm-sdk inputs before embedding (#2501) (#2516)
Mental-model content in delta-refresh mode can grow past an embedding model's fixed input-token limit (e.g. Bedrock Titan V2's hard 8192 cap), after which every refresh fails permanently with ContextWindowExceededError and no recovery path. Add an opt-in `HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MAX_INPUT_TOKENS` cap. When set, `LiteLLMSDKEmbeddings.encode()` truncates each input to that many cl100k_base tokens before calling litellm.embedding(), mirroring the existing reranker `max_tokens_per_doc` pattern. Truncation emits a log.warning naming the model and largest original token count so it isn't silent. Off by default (no behavior change / data loss for large-context models); Titan users set it to the model's real limit with a little headroom.
1 parent 4185240 commit 263eba1

5 files changed

Lines changed: 127 additions & 0 deletions

File tree

hindsight-api-slim/hindsight_api/config.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,7 @@ def _resolve_operation_temperature(operation_env: str, default: float) -> float
374374
ENV_EMBEDDINGS_LITELLM_SDK_API_BASE = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_API_BASE"
375375
ENV_EMBEDDINGS_LITELLM_SDK_OUTPUT_DIMENSIONS = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_OUTPUT_DIMENSIONS"
376376
ENV_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT"
377+
ENV_EMBEDDINGS_LITELLM_SDK_MAX_INPUT_TOKENS = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MAX_INPUT_TOKENS"
377378
ENV_RERANKER_LITELLM_SDK_API_KEY = "HINDSIGHT_API_RERANKER_LITELLM_SDK_API_KEY"
378379
ENV_RERANKER_LITELLM_SDK_MODEL = "HINDSIGHT_API_RERANKER_LITELLM_SDK_MODEL"
379380
ENV_RERANKER_LITELLM_SDK_API_BASE = "HINDSIGHT_API_RERANKER_LITELLM_SDK_API_BASE"
@@ -919,6 +920,10 @@ def _parse_strategy_boosts(raw: str | None) -> dict[str, str]:
919920
# LiteLLM SDK defaults
920921
DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL = "cohere/embed-english-v3.0"
921922
DEFAULT_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT = "float"
923+
# Opt-in per-text input truncation (tiktoken cl100k_base tokens). Off by default;
924+
# set to the embedding model's real input limit (e.g. 8192 for Bedrock Titan V2)
925+
# to keep oversized content from permanently failing the embed call. See #2501.
926+
DEFAULT_EMBEDDINGS_LITELLM_SDK_MAX_INPUT_TOKENS: int | None = None
922927
DEFAULT_RERANKER_LITELLM_SDK_MODEL = "cohere/rerank-english-v3.0"
923928

924929
DEFAULT_HOST = "0.0.0.0"
@@ -1720,6 +1725,7 @@ class HindsightConfig:
17201725
embeddings_litellm_sdk_api_base: str | None
17211726
embeddings_litellm_sdk_output_dimensions: int | None
17221727
embeddings_litellm_sdk_encoding_format: str | None
1728+
embeddings_litellm_sdk_max_input_tokens: int | None
17231729
# Gemini/Vertex AI embeddings
17241730
embeddings_gemini_api_key: str | None
17251731
embeddings_gemini_model: str
@@ -2612,6 +2618,9 @@ def from_env(cls) -> "HindsightConfig":
26122618
embeddings_litellm_sdk_encoding_format=os.getenv(
26132619
ENV_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT, DEFAULT_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT
26142620
),
2621+
embeddings_litellm_sdk_max_input_tokens=int(v)
2622+
if (v := os.getenv(ENV_EMBEDDINGS_LITELLM_SDK_MAX_INPUT_TOKENS))
2623+
else DEFAULT_EMBEDDINGS_LITELLM_SDK_MAX_INPUT_TOKENS,
26152624
# Gemini/Vertex AI embeddings (with fallback to LLM keys)
26162625
embeddings_gemini_api_key=os.getenv(ENV_EMBEDDINGS_GEMINI_API_KEY) or os.getenv(ENV_LLM_API_KEY),
26172626
embeddings_gemini_model=os.getenv(ENV_EMBEDDINGS_GEMINI_MODEL, DEFAULT_EMBEDDINGS_GEMINI_MODEL),

hindsight-api-slim/hindsight_api/engine/embeddings.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,25 @@ class _ZeroEntropyEmbedResponse(BaseModel):
7676
results: list[_ZeroEntropyEmbedResult]
7777

7878

79+
def _truncate_to_tokens(text: str, max_tokens: int) -> tuple[str, int]:
80+
"""Truncate ``text`` to at most ``max_tokens`` cl100k_base tokens.
81+
82+
tiktoken is an approximation of any given provider's tokenizer, so set
83+
``max_tokens`` with a little headroom below the model's real limit.
84+
85+
Returns the (possibly truncated) text and the original token count (so the
86+
caller can report how much was dropped); the count equals ``len(tokens)``
87+
whether or not truncation occurred.
88+
"""
89+
from .token_encoding import get_token_encoding
90+
91+
enc = get_token_encoding()
92+
tokens = enc.encode(text)
93+
if len(tokens) <= max_tokens:
94+
return text, len(tokens)
95+
return enc.decode(tokens[:max_tokens]), len(tokens)
96+
97+
7998
class Embeddings(ABC):
8099
"""
81100
Abstract base class for embedding generation.
@@ -1202,6 +1221,7 @@ def __init__(
12021221
batch_size: int = 100,
12031222
timeout: float = 60.0,
12041223
encoding_format: str | None = "float",
1224+
max_input_tokens: int | None = None,
12051225
):
12061226
"""
12071227
Initialize LiteLLM SDK embeddings client.
@@ -1216,6 +1236,10 @@ def __init__(
12161236
timeout: Request timeout in seconds (default: 60.0)
12171237
encoding_format: Encoding format for embeddings (default: "float").
12181238
Set to None or empty string to omit (needed for Voyage AI, Gemini).
1239+
max_input_tokens: If set, truncate each input text to this many tokens
1240+
(tiktoken cl100k_base) before embedding. Needed for models with a
1241+
fixed input-token limit (e.g. Bedrock Titan V2's hard 8192 cap),
1242+
where an oversized text would otherwise fail permanently (#2501).
12191243
"""
12201244
self.api_key = api_key
12211245
self.model = model
@@ -1224,6 +1248,7 @@ def __init__(
12241248
self.batch_size = batch_size
12251249
self.timeout = timeout
12261250
self.encoding_format = encoding_format or None
1251+
self.max_input_tokens = max_input_tokens
12271252
self._litellm = None # Will be set during initialization
12281253
self._dimension: int | None = None
12291254

@@ -1300,6 +1325,33 @@ def encode(self, texts: list[str]) -> list[list[float]]:
13001325
if not texts:
13011326
return []
13021327

1328+
# Truncate oversized inputs before hitting the provider. Models with a
1329+
# fixed input-token limit (e.g. Bedrock Titan V2, 8192) reject an
1330+
# oversized text with a permanent error rather than truncating it
1331+
# server-side, which strands the caller (e.g. a delta mental model whose
1332+
# content grew past the cap) with no recovery path. See #2501.
1333+
if self.max_input_tokens is not None:
1334+
truncated_texts = []
1335+
original_token_counts = []
1336+
for t in texts:
1337+
new_text, original_tokens = _truncate_to_tokens(t, self.max_input_tokens)
1338+
truncated_texts.append(new_text)
1339+
if original_tokens > self.max_input_tokens:
1340+
original_token_counts.append(original_tokens)
1341+
texts = truncated_texts
1342+
if original_token_counts:
1343+
logger.warning(
1344+
"Embeddings: truncated %d of %d input(s) to %d tokens for model %s "
1345+
"(largest was ~%d tokens); embedded content is incomplete. "
1346+
"This usually means a mental model's content has grown past the model's "
1347+
"input limit — see issue #2501.",
1348+
len(original_token_counts),
1349+
len(texts),
1350+
self.max_input_tokens,
1351+
self.model,
1352+
max(original_token_counts),
1353+
)
1354+
13031355
all_embeddings = []
13041356

13051357
# Process in batches
@@ -1691,6 +1743,7 @@ def create_embeddings_from_env() -> Embeddings:
16911743
api_base=config.embeddings_litellm_sdk_api_base,
16921744
output_dimensions=config.embeddings_litellm_sdk_output_dimensions,
16931745
encoding_format=config.embeddings_litellm_sdk_encoding_format,
1746+
max_input_tokens=config.embeddings_litellm_sdk_max_input_tokens,
16941747
)
16951748
elif provider == "google":
16961749
vertexai_project_id = config.embeddings_vertexai_project_id

hindsight-api-slim/tests/test_litellm_sdk_embeddings.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,65 @@ async def test_encode_empty_list(self, embeddings):
244244
assert isinstance(result, list)
245245
assert len(result) == 0
246246

247+
async def test_encode_truncates_oversized_input_when_max_tokens_set(self, mock_litellm, caplog):
248+
"""Oversized inputs are truncated to max_input_tokens before embedding, with a warning (#2501)."""
249+
import logging
250+
251+
from hindsight_api.engine.token_encoding import get_token_encoding
252+
253+
emb = LiteLLMSDKEmbeddings(
254+
api_key="test_key",
255+
model="bedrock/amazon.titan-embed-text-v2:0",
256+
api_base=None,
257+
batch_size=100,
258+
timeout=60.0,
259+
max_input_tokens=50,
260+
)
261+
emb._litellm = mock_litellm
262+
emb._dimension = 768
263+
mock_litellm.embedding.return_value.data = [{"embedding": [0.1] * 768, "index": 0}]
264+
265+
long_text = "word " * 500 # far more than 50 tokens
266+
with caplog.at_level(logging.WARNING):
267+
emb.encode([long_text])
268+
269+
sent = mock_litellm.embedding.call_args.kwargs["input"][0]
270+
enc = get_token_encoding()
271+
assert len(enc.encode(sent)) <= 50
272+
assert sent != long_text # actually truncated
273+
assert any("truncated" in r.message and r.levelno == logging.WARNING for r in caplog.records)
274+
275+
async def test_encode_no_warning_when_input_within_limit(self, mock_litellm, caplog):
276+
"""No truncation warning when every input already fits under max_input_tokens."""
277+
import logging
278+
279+
emb = LiteLLMSDKEmbeddings(
280+
api_key="test_key",
281+
model="bedrock/amazon.titan-embed-text-v2:0",
282+
api_base=None,
283+
batch_size=100,
284+
timeout=60.0,
285+
max_input_tokens=50,
286+
)
287+
emb._litellm = mock_litellm
288+
emb._dimension = 768
289+
mock_litellm.embedding.return_value.data = [{"embedding": [0.1] * 768, "index": 0}]
290+
291+
with caplog.at_level(logging.WARNING):
292+
emb.encode(["short text well under the limit"])
293+
294+
assert not any("truncated" in r.message for r in caplog.records)
295+
296+
async def test_encode_does_not_truncate_when_max_tokens_unset(self, embeddings, mock_litellm):
297+
"""Without max_input_tokens the text is passed through verbatim (default behavior)."""
298+
assert embeddings.max_input_tokens is None
299+
mock_litellm.embedding.return_value.data = [{"embedding": [0.1] * 768, "index": 0}]
300+
301+
long_text = "word " * 500
302+
embeddings.encode([long_text])
303+
304+
assert mock_litellm.embedding.call_args.kwargs["input"] == [long_text]
305+
247306
async def test_encode_before_initialization(self, mock_litellm):
248307
"""Test that encode raises error if not initialized."""
249308
emb = LiteLLMSDKEmbeddings(

hindsight-docs/docs/developer/configuration.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,7 @@ server-level only (not overridable per tenant/bank) and a change requires a rest
586586
| `HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_API_BASE` | Custom base URL for LiteLLM SDK embeddings (optional) | - |
587587
| `HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_OUTPUT_DIMENSIONS` | Optional output embedding dimensions (provider-dependent, e.g., `768` for Gemini embedding models) | - |
588588
| `HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT` | Encoding format for embedding responses. Set to empty string to omit the parameter (needed for Voyage AI, Gemini). | `float` |
589+
| `HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MAX_INPUT_TOKENS` | If set, truncate each text to this many tokens (tiktoken `cl100k_base`, approximate) before embedding. Set it to the model's real input limit (e.g. `8192` for Bedrock Titan V2, with a little headroom) so oversized content is truncated instead of failing the embed call permanently. Off by default. | - |
589590
| `HINDSIGHT_API_EMBEDDINGS_GEMINI_API_KEY` | Gemini API key for embeddings (falls back to `HINDSIGHT_API_LLM_API_KEY`) | - |
590591
| `HINDSIGHT_API_EMBEDDINGS_GEMINI_MODEL` | Gemini embedding model. The `gemini-embedding-2` family (e.g. `gemini-embedding-2-preview`) is supported on both the Gemini API and Vertex AI — because these multimodal models aggregate a multi-input request into one embedding, Hindsight automatically embeds one input per call to keep per-fact vectors. | `gemini-embedding-001` |
591592
| `HINDSIGHT_API_EMBEDDINGS_GEMINI_OUTPUT_DIMENSIONALITY` | Output embedding dimensions (Gemini supports configurable dimensionality) | `768` |
@@ -802,6 +803,8 @@ export HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_API_KEY=your-provider-api-key
802803
export HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MODEL=cohere/embed-english-v3.0
803804
# Optional: request a specific output dimension when the provider supports it
804805
# export HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_OUTPUT_DIMENSIONS=768
806+
# Optional: truncate oversized inputs to the model's input-token limit (e.g. Bedrock Titan V2)
807+
# export HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MAX_INPUT_TOKENS=8192
805808

806809
# Supported LiteLLM SDK embedding providers:
807810
# - cohere/embed-english-v3.0 (1024 dimensions)

skills/hindsight-docs/references/developer/configuration.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,7 @@ server-level only (not overridable per tenant/bank) and a change requires a rest
586586
| `HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_API_BASE` | Custom base URL for LiteLLM SDK embeddings (optional) | - |
587587
| `HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_OUTPUT_DIMENSIONS` | Optional output embedding dimensions (provider-dependent, e.g., `768` for Gemini embedding models) | - |
588588
| `HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT` | Encoding format for embedding responses. Set to empty string to omit the parameter (needed for Voyage AI, Gemini). | `float` |
589+
| `HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MAX_INPUT_TOKENS` | If set, truncate each text to this many tokens (tiktoken `cl100k_base`, approximate) before embedding. Set it to the model's real input limit (e.g. `8192` for Bedrock Titan V2, with a little headroom) so oversized content is truncated instead of failing the embed call permanently. Off by default. | - |
589590
| `HINDSIGHT_API_EMBEDDINGS_GEMINI_API_KEY` | Gemini API key for embeddings (falls back to `HINDSIGHT_API_LLM_API_KEY`) | - |
590591
| `HINDSIGHT_API_EMBEDDINGS_GEMINI_MODEL` | Gemini embedding model. The `gemini-embedding-2` family (e.g. `gemini-embedding-2-preview`) is supported on both the Gemini API and Vertex AI — because these multimodal models aggregate a multi-input request into one embedding, Hindsight automatically embeds one input per call to keep per-fact vectors. | `gemini-embedding-001` |
591592
| `HINDSIGHT_API_EMBEDDINGS_GEMINI_OUTPUT_DIMENSIONALITY` | Output embedding dimensions (Gemini supports configurable dimensionality) | `768` |
@@ -802,6 +803,8 @@ export HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_API_KEY=your-provider-api-key
802803
export HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MODEL=cohere/embed-english-v3.0
803804
# Optional: request a specific output dimension when the provider supports it
804805
# export HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_OUTPUT_DIMENSIONS=768
806+
# Optional: truncate oversized inputs to the model's input-token limit (e.g. Bedrock Titan V2)
807+
# export HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MAX_INPUT_TOKENS=8192
805808

806809
# Supported LiteLLM SDK embedding providers:
807810
# - cohere/embed-english-v3.0 (1024 dimensions)

0 commit comments

Comments
 (0)