Skip to content

Prompt caching: SDK master switch + document-context caching (answer_prompt)#2199

Open
pk-zipstack wants to merge 2 commits into
mainfrom
feat/sdk-prompt-caching
Open

Prompt caching: SDK master switch + document-context caching (answer_prompt)#2199
pk-zipstack wants to merge 2 commits into
mainfrom
feat/sdk-prompt-caching

Conversation

@pk-zipstack

Copy link
Copy Markdown
Contributor

Summary

Adds opt-in LLM prompt caching to SDK1 and wires it into the main extraction path (answer_prompt). Implements the recommendation from the prompt-caching audit. Off by default — a single master switch (ENABLE_PROMPT_CACHING) gates the whole feature, so there is zero behavior change until it is explicitly enabled.

Pairs with the cloud-plugins PR (unstract-cloud#…, table + challenge extractors). This PR should merge first — the cloud plugins depend on the SDK primitives here.

Why prompt caching, and how it actually helps

Anthropic/Bedrock prompt caching only pays off when a large, byte-identical prefix repeats within ~5 minutes. It is not a switch you flip and save everywhere — the win comes from identifying, per consumer, the span that actually repeats. This PR builds the mechanism and applies it to the one platform path with a big, genuinely-reused prefix: the document context reused across the many prompts run on one document.

What's in this PR

SDK1 (unstract/sdk1)

  • Master switchENABLE_PROMPT_CACHING env var auto-enables caching for every LLM on a supported provider (is_prompt_caching_enabled()), so consumers don't each pass a flag; they only decide what to cache via cache_prefix.
  • cache_prefix on complete() / stream_complete() — splits the user turn into a cached stable prefix block + the volatile suffix; the model sees cache_prefix + prompt unchanged.
  • Per-adapter gatecache_control is emitted only for anthropic / bedrock (mirrors the existing enable_extended_context pattern in base1.py); a no-op on other providers.
  • Cache-token accounting — logs cache_write=/cache_read= and prices cache hits via litellm.completion_cost when present.
  • 🐞 Bug fix: previously, when caching was inactive (unsupported provider or flag off) but a cache_prefix was passed, the prefix was silently dropped from the prompt. Now the full prompt is always preserved. This affected the table extractor for non-Claude adapters. Regression-guarded by tests.

Workers — answer_prompt (the ★ consumer)

  • Flag-gated context-first restructuring: the reused document context becomes the cached prefix; the per-prompt question is the volatile suffix. The reorder is a genuine prompt change (context moves from the end to the front), so it is gated behind the flag and was A/B-eval'd before we'd ship it.

Validation (in a dev environment)

  • Deployed to a staging dev namespace and exercised end-to-end.
  • Accuracy A/B: ran the same prompts on the same document with the flag off vs on — extracted values matched, confirming the context-first reorder does not regress extraction.
  • Caching confirmed: with several prompts on one document (chunk size 0 → identical full-doc context), the context is cache_write-ten on the first prompt and cache_read on the rest.

Why we're stopping here (scope boundary)

The platform has ~7 LLM call sites. We shipped caching for the three with a real, high-reuse prefix and stopped, after analyzing every remaining consumer:

Consumer Why not (yet)
answer_prompt done — document context reused across prompts
table_extractor (cloud) done — instruction prefix reused across pages
challenge (cloud) done — instructions + context reused across evaluations
smart_table_extractor Stable schema/instructions are a suffix → would need a risky reorder for a prefix that is usually below the 1024-token cache minimum. Sub-threshold gain, real risk.
line_item_extractor Base prompt only repeats when a response is truncated (max_tokens); most runs are single-call → no reuse. Conditional + fiddly.
single_pass_extraction One LLM call per document — nothing repeats within a run.
agentic_extraction Real multi-turn reuse, but a complex, higher-risk change — deferred as its own scoped piece with its own A/B.
retriever_llm llama-index owns the message construction — not ours to split.

In short: caching only helps where a large prefix genuinely repeats. Those cases are now covered; the rest would add marginal/conditional benefit at the cost of accuracy-risky rewrites, so we deliberately drew the line here. agentic_extraction is the one worthwhile follow-up if long agentic extractions become common.

Testing

  • unstract/sdk1: pytest tests/test_prompt_caching.py (18 tests — provider gating, cache_prefix split, prefix-preservation fallback, env master switch).
  • workers: pytest tests/test_answer_prompt_caching.py (5 tests — reorder preserves all content, context-first, cache_prefix = context block only, flag defaults off).
  • Manual: set ENABLE_PROMPT_CACHING=true on the executor worker, run ≥2 text prompts (chunk size 0) on one multi-page doc with an Anthropic adapter, and confirm cache_read in the executor logs.

Follow-ups (not blocking)

  • Extend usage/cost dashboards to persist cache_creation_input_tokens / cache_read_input_tokens (currently logged only).
  • Optionally implement caching for agentic_extraction.

🤖 Generated with Claude Code

pk-zipstack and others added 2 commits July 22, 2026 18:25
Gate prompt caching per-adapter (Anthropic / Bedrock-Anthropic) via an
enable_prompt_caching flag on adapter metadata or the LLM constructor.
Add a reusable cache_prefix on complete()/stream_complete() that caches a
stable user-turn prefix (content-block split) so repeated calls reuse it
without changing the prompt text the model sees. Record cache token counts
and price cache hits via litellm.completion_cost when present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r_prompt

SDK1: add an ENABLE_PROMPT_CACHING master switch (env var) that auto-enables
caching for every LLM on a supported provider, so consumers only pass a
cache_prefix. Fix cache_prefix being dropped when caching is inactive
(unsupported provider / flag off) so the full prompt is always preserved.

answer_prompt: flag-gated context-first restructuring — the reused document
context becomes a cached prefix reused across the prompts run on one document,
while the per-prompt question is the volatile suffix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • New Features

    • Added opt-in prompt caching for Anthropic and AWS Bedrock models.
    • Supports caching stable system prompts or reusable context prefixes.
    • Added ENABLE_PROMPT_CACHING environment toggle and per-instance configuration.
    • Preserves prompt content when caching is disabled or unsupported.
    • Usage and cost reporting now includes cached prompt token metrics.
  • Tests

    • Added coverage for configuration, prompt construction, supported providers, and usage behavior.

Walkthrough

Prompt caching is added across adapter validation, the SDK LLM flow, usage accounting, and extraction prompt construction. Anthropic and Bedrock requests can cache stable system or context prefixes when enabled through configuration, constructor state, or environment settings.

Changes

Prompt caching

Layer / File(s) Summary
SDK opt-in and adapter propagation
unstract/sdk1/src/unstract/sdk1/adapters/base1.py, unstract/sdk1/src/unstract/sdk1/llm.py, unstract/sdk1/tests/test_prompt_caching.py
Adapter metadata, constructor settings, and ENABLE_PROMPT_CACHING control caching for Anthropic and Bedrock while remaining outside LiteLLM parameters.
Cached message construction and invocation
unstract/sdk1/src/unstract/sdk1/llm.py, unstract/sdk1/tests/test_prompt_caching.py
Completion methods build provider-specific cache-control blocks, support cache_prefix, preserve uncached content, and cover supported and unsupported providers.
Cached usage and cost accounting
unstract/sdk1/src/unstract/sdk1/llm.py
Usage recording captures cache creation and read tokens and computes costs using cache-aware fallbacks.
Extraction prompt caching integration
workers/executor/executors/answer_prompt.py, workers/executor/executors/legacy_executor.py, workers/tests/test_answer_prompt_caching.py
Extraction prompts place document context in a cache prefix, forward it through completion, and test cached versus uncached ordering and flag behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AnswerPromptService
  participant LLM
  participant LiteLLM
  participant Provider
  AnswerPromptService->>LLM: complete(prompt, cache_prefix)
  LLM->>LLM: build provider cache-control blocks
  LLM->>LiteLLM: send completion request
  LiteLLM->>Provider: submit Anthropic or Bedrock request
  Provider-->>LiteLLM: return response and cache token usage
  LiteLLM-->>LLM: return completion result
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed but does not follow the required template and is missing several required sections, including the explicit breakage and checklist sections. Add the missing template headings, especially What/How/Can this break existing features/DB Migrations/Env Config/Relevant Docs/Related Issues/Dependencies/Testing/Screenshots/Checklist.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: opt-in prompt caching with an SDK master switch and answer_prompt integration.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/sdk-prompt-caching
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch feat/sdk-prompt-caching

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sonarqubecloud

Copy link
Copy Markdown

@greptile-apps

greptile-apps Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds opt-in provider prompt caching and applies context-first caching to prompt extraction.

  • Introduces an ENABLE_PROMPT_CACHING master switch and cache_prefix support for synchronous and streaming LLM calls.
  • Emits cache_control message blocks for Anthropic and Bedrock adapters and incorporates provider cache-token data into usage logging and cost calculation.
  • Reorders answer_prompt document context ahead of the per-prompt question while caching is enabled.
  • Adds SDK and worker tests covering gating, message construction, prefix preservation, and prompt restructuring.

Confidence Score: 4/5

The PR appears safe to merge, with a non-blocking compatibility concern around enabling Anthropic-specific cache markers for every Bedrock model.

The extraction and SDK paths preserve prompt content and remain opt-in, but the provider-only Bedrock gate does not enforce the documented Anthropic-model restriction.

unstract/sdk1/src/unstract/sdk1/llm.py, unstract/sdk1/src/unstract/sdk1/adapters/base1.py

Important Files Changed

Filename Overview
unstract/sdk1/src/unstract/sdk1/llm.py Adds global caching control, message construction, cache-prefix APIs, and cache-aware cost accounting; the Bedrock gate is broader than the documented supported model family.
unstract/sdk1/src/unstract/sdk1/adapters/base1.py Preserves prompt-caching control metadata through Anthropic and Bedrock validation without passing it to LiteLLM as a completion parameter.
workers/executor/executors/answer_prompt.py Adds flag-gated context-first prompt construction and forwards document context as the stable cache prefix.
workers/executor/executors/legacy_executor.py Documents that the SDK LLM instance receives caching behavior through the global environment switch.
unstract/sdk1/tests/test_prompt_caching.py Tests adapter flag propagation, cache message shapes, unsupported-provider fallback, prefix preservation, and environment opt-in.
workers/tests/test_answer_prompt_caching.py Tests context-first restructuring, content preservation, cache-prefix boundaries, and disabled-by-default flag behavior.

Sequence Diagram

sequenceDiagram
    participant Worker as AnswerPromptService
    participant SDK as SDK1 LLM
    participant Adapter as Provider Adapter
    participant Provider as LLM Provider
    Worker->>Worker: Build context-first cache prefix
    Worker->>SDK: "complete(volatilePrompt, cache_prefix=context)"
    SDK->>SDK: Check master switch and provider
    alt Supported caching path
        SDK->>Adapter: System + cached prefix block + volatile block
    else Caching inactive
        SDK->>Adapter: System + concatenated prefix and prompt
    end
    Adapter->>Provider: Completion request
    Provider-->>SDK: Response and cache-token usage
    SDK-->>Worker: Completion and usage record
Loading

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
unstract/sdk1/src/unstract/sdk1/llm.py:335-342
**Bedrock gate covers unsupported models**

The provider-only gate emits Anthropic-specific `cache_control` content blocks for every Bedrock model, while the adapter documents support as limited to Anthropic models on Bedrock. Restricting this path to Anthropic Bedrock model identifiers avoids ineffective caching and unsupported message shapes for other model families.

Reviews (1): Last reviewed commit: "[MISC] Master-switch prompt caching + ca..." | Re-trigger Greptile

Comment on lines +335 to +342
_PROMPT_CACHE_PROVIDERS = frozenset({"anthropic", "bedrock"})

def _prompt_caching_active(self) -> bool:
"""Whether to emit ``cache_control`` blocks for this call."""
return (
self._enable_prompt_caching
and self.adapter.get_provider() in self._PROMPT_CACHE_PROVIDERS
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Bedrock gate covers unsupported models

The provider-only gate emits Anthropic-specific cache_control content blocks for every Bedrock model, while the adapter documents support as limited to Anthropic models on Bedrock. Restricting this path to Anthropic Bedrock model identifiers avoids ineffective caching and unsupported message shapes for other model families.

Knowledge Base Used: Unstract SDK, Core, and Flags

Prompt To Fix With AI
This is a comment left during a code review.
Path: unstract/sdk1/src/unstract/sdk1/llm.py
Line: 335-342

Comment:
**Bedrock gate covers unsupported models**

The provider-only gate emits Anthropic-specific `cache_control` content blocks for every Bedrock model, while the adapter documents support as limited to Anthropic models on Bedrock. Restricting this path to Anthropic Bedrock model identifiers avoids ineffective caching and unsupported message shapes for other model families.

**Knowledge Base Used:** [Unstract SDK, Core, and Flags](https://app.greptile.com/zipstack/-/custom-context/knowledge-base/zipstack/unstract/-/docs/unstract-sdk-core.md)

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
unstract/sdk1/src/unstract/sdk1/adapters/base1.py (1)

1044-1086: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Bedrock enable_prompt_caching isn't gated by model — contradicts its own comment.

The comment states "Only Anthropic models on Bedrock support prompt caching," but enable_prompt_caching is carried through unconditionally from adapter_metadata regardless of which Bedrock model is configured. If a non-Anthropic Bedrock model (Titan, Llama, Mistral, etc.) has this flag set, LLM._prompt_caching_active() will still emit cache_control blocks for it (it only checks provider == "bedrock", not the model id), which those models don't support.

Proposed fix
-        enable_prompt_caching = bool(adapter_metadata.get("enable_prompt_caching", False))
+        raw_model = adapter_metadata.get("model", "")
+        is_anthropic_model = "anthropic." in raw_model
+        enable_prompt_caching = is_anthropic_model and bool(
+            adapter_metadata.get("enable_prompt_caching", False)
+        )

See consolidated comment below for the paired fix location in llm.py.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@unstract/sdk1/src/unstract/sdk1/adapters/base1.py` around lines 1044 - 1086,
Gate enable_prompt_caching in the Bedrock adapter flow around validation and
final assignment so it is true only when the configured Bedrock model is
Anthropic; force it false for Titan, Llama, Mistral, and other non-Anthropic
models before validated["enable_prompt_caching"] is emitted. Preserve the
existing opt-in behavior for supported Anthropic Bedrock models and keep the
prompt-caching control field excluded from Pydantic validation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@unstract/sdk1/src/unstract/sdk1/llm.py`:
- Around line 331-343: The _prompt_caching_active method must also verify that a
Bedrock request uses a supported Anthropic/Claude model before enabling
cache_control emission. Update the provider/model gating in
_prompt_caching_active, preserving the existing Anthropic behavior and disabling
prompt caching for unsupported Bedrock models.
- Around line 828-869: Update _compute_call_cost so the response-based
litellm.completion_cost call passes the cost override via model=model, keeping
cached-call pricing consistent with the fallback cost_per_token path. Add a
regression test covering a prompt-cached call with cost_model set and verifying
the override is used for cost tracking.

In `@workers/executor/executors/answer_prompt.py`:
- Around line 163-187: The prompt construction flow in AnswerPromptService must
only use construct_cached_prompt when caching is globally enabled and supported
by the current LLM; otherwise use construct_prompt, preserve the original prompt
order, and pass no cache prefix. Update
workers/executor/executors/answer_prompt.py lines 163-187 accordingly, and add
the globally enabled unsupported-provider coverage in
workers/tests/test_answer_prompt_caching.py lines 52-91 to verify the original
ordering and absence of a cache prefix.

---

Outside diff comments:
In `@unstract/sdk1/src/unstract/sdk1/adapters/base1.py`:
- Around line 1044-1086: Gate enable_prompt_caching in the Bedrock adapter flow
around validation and final assignment so it is true only when the configured
Bedrock model is Anthropic; force it false for Titan, Llama, Mistral, and other
non-Anthropic models before validated["enable_prompt_caching"] is emitted.
Preserve the existing opt-in behavior for supported Anthropic Bedrock models and
keep the prompt-caching control field excluded from Pydantic validation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a725c6fd-d5d8-4ba0-8a50-24fea57e25bf

📥 Commits

Reviewing files that changed from the base of the PR and between 6040373 and 395426a.

📒 Files selected for processing (6)
  • unstract/sdk1/src/unstract/sdk1/adapters/base1.py
  • unstract/sdk1/src/unstract/sdk1/llm.py
  • unstract/sdk1/tests/test_prompt_caching.py
  • workers/executor/executors/answer_prompt.py
  • workers/executor/executors/legacy_executor.py
  • workers/tests/test_answer_prompt_caching.py

Comment on lines +331 to +343
# Providers for which we emit explicit ``cache_control`` blocks. Anthropic
# and Bedrock-Anthropic support message-level prompt caching this way;
# OpenAI / Azure auto-cache server-side (no marker needed) and other
# providers don't support it, so we never tag their payloads.
_PROMPT_CACHE_PROVIDERS = frozenset({"anthropic", "bedrock"})

def _prompt_caching_active(self) -> bool:
"""Whether to emit ``cache_control`` blocks for this call."""
return (
self._enable_prompt_caching
and self.adapter.get_provider() in self._PROMPT_CACHE_PROVIDERS
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

_prompt_caching_active() gates on provider only, not the underlying model.

For provider == "bedrock", this returns true for any Bedrock model as long as _enable_prompt_caching is set, but Bedrock prompt caching via cache_control only works for Anthropic/Claude models. This pairs with the same gap in base1.py's AWSBedrockLLMParameters.validate() — see consolidated comment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@unstract/sdk1/src/unstract/sdk1/llm.py` around lines 331 - 343, The
_prompt_caching_active method must also verify that a Bedrock request uses a
supported Anthropic/Claude model before enabling cache_control emission. Update
the provider/model gating in _prompt_caching_active, preserving the existing
Anthropic behavior and disabling prompt caching for unsupported Bedrock models.

Comment on lines +828 to +869
def _compute_call_cost(
self,
model: str,
prompt_tokens: int,
completion_tokens: int,
has_cache_tokens: bool,
response: object | None,
) -> float:
"""Compute the dollar cost of a single call.

When caching is active, cache-read tokens are billed at ~0.1x and
cache-write at ~1.25x of the base input rate. ``litellm.cost_per_token``
prices every prompt token at the full input rate, so it over-reports
cost on cache hits. In that case let litellm read the cache token counts
off the response for an accurate figure, falling back to the per-token
path (which is exact when no caching is involved).
"""
if has_cache_tokens and response is not None:
try:
return litellm.completion_cost(completion_response=response)
except Exception:
logger.debug(
"completion_cost() failed for model=%s; "
"falling back to cost_per_token",
model,
exc_info=True,
)
try:
prompt_cost, compl_cost = litellm.cost_per_token(
model=model,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
)
return prompt_cost + compl_cost
except Exception:
logger.warning(
"Failed to compute cost for model=%s; recording as 0.0",
model,
exc_info=True,
)
return 0.0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

litellm completion_cost model parameter override when completion_response has different model

💡 Result:

To handle cases where you need to override the model for cost calculation in LiteLLM (for example, when a completion response returns a model name that differs from the one you want to bill against), you should use the base_model parameter [1]. When using the LiteLLM proxy or configuration, you can set the base_model in your model_list configuration. This tells LiteLLM to use the specified base_model for pricing lookups instead of the model name returned in the completion response [1]. If you are calling the completion_cost function directly in your code, you can explicitly pass the desired model name via the model argument to ensure the cost is calculated against that specific pricing tier [2][3]. For more advanced scenarios involving custom pricing, you can: 1. Override specific cost parameters (e.g., input_cost_per_token, output_cost_per_token) within the model_info block of your configuration [4][1]. 2. Use register_model to manually define or override model pricing in the global LiteLLM cost dictionary [2]. 3. For proxy callbacks, it is often more reliable to use the pre-computed response_cost found in the kwargs of the response object rather than manually invoking completion_cost, as this value is typically calculated by LiteLLM using the intended model context before aliasing occurs [5]. When overriding, note that setting custom pricing fields detaches the deployment from the default cost map for that entry [4]. Ensure that you define all necessary fields, as partial overrides can lead to unexpected billing results [4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate llm.py and relevant symbols =="
fd -a 'llm\.py$' . | sed 's#^\./##'
echo
rg -n "def _compute_call_cost|_cost_model|cost_model|AWSBedrockLLMParameters|model_id|litellm\.completion_cost|litellm\.cost_per_token" -S .

echo
echo "== inspect llm.py relevant sections =="
LLM_FILES="$(fd 'llm.py$' .)"
for f in $LLM_FILES; do
  echo "--- $f ($(wc -l < "$f") lines) ---"
  rg -n "_compute_call_cost|_record_usage|_cost_model|cost_model|AWSBedrockLLMParameters|model_id|completion_cost|cost_per_token" -C 4 "$f"
done

echo
echo "== inspect litellm installed versions/docs signatures if available =="
python3 - <<'PY'
import importlib.util
spec = importlib.util.find_spec("litellm")
print("litellm_module_path:", getattr(spec, "origin", None))
if spec:
    import litellm
    import inspect
    print("litellm_version:", getattr(litellm, "__version__", None))
    for name in ("completion_cost", "cost_per_token"):
        fn = getattr(litellm, name, None)
        print(f"litellm.{name}:", fn)
        if fn is not None:
            try:
                print(inspect.signature(fn))
            except Exception as e:
                print("signature_error:", e)
            try:
                src = inspect.getsource(fn)
                lines = src.splitlines()
                for i, line in enumerate(lines[:40], 1):
                    print(f"{i}:{line}")
            except Exception:
                pass
PY

Repository: Zipstack/unstract

Length of output: 11605


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate llm.py and relevant symbols =="
fd -a 'llm\.py$' . | sed 's#^\./##'
echo
rg -n "def _compute_call_cost|_cost_model|cost_model|AWSBedrockLLMParameters|model_id|litellm\.completion_cost|litellm\.cost_per_token" -S .

echo
echo "== inspect llm.py relevant sections =="
LLM_FILES="$(fd 'llm.py$' .)"
for f in $LLM_FILES; do
  echo "--- $f ($(wc -l < "$f") lines) ---"
  rg -n "_compute_call_cost|_record_usage|_cost_model|cost_model|AWSBedrockLLMParameters|model_id|completion_cost|cost_per_token" -C 4 "$f"
done

echo
echo "== inspect litellm installed versions/docs signatures if available =="
python3 - <<'PY'
import importlib.util
spec = importlib.util.find_spec("litellm")
print("litellm_module_path:", getattr(spec, "origin", None))
if spec:
    import litellm
    import inspect
    print("litellm_version:", getattr(litellm, "__version__", None))
    for name in ("completion_cost", "cost_per_token"):
        fn = getattr(litellm, name, None)
        print(f"litellm.{name}:", fn)
        if fn is not None:
            try:
                print(inspect.signature(fn))
            except Exception as e:
                print("signature_error:", e)
            try:
                src = inspect.getsource(fn)
                lines = src.splitlines()
                for i, line in enumerate(lines[:80], 1):
                    print(f"{i}:{line}")
            except Exception:
                pass
PY

Repository: Zipstack/unstract

Length of output: 11605


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== llm.py cost flow section =="
sed -n '220,260p' unstract/sdk1/src/unstract/sdk1/llm.py
sed -n '420,470p' unstract/sdk1/src/unstract/sdk1/llm.py
sed -n '540,575p' unstract/sdk1/src/unstract/sdk1/llm.py
sed -n '635,655p' unstract/sdk1/src/unstract/sdk1/llm.py
sed -n '700,715p' unstract/sdk1/src/unstract/sdk1/llm.py
sed -n '820,865p' unstract/sdk1/src/unstract/sdk1/llm.py

echo
echo "== base1 cost_model derivation context =="
sed -n '470,510p' unstract/sdk1/src/unstract/sdk1/adapters/base1.py
sed -n '985,1100p' unstract/sdk1/src/unstract/sdk1/adapters/base1.py
sed -n '1525,1545p' unstract/sdk1/src/unstract/sdk1/adapters/base1.py

echo
echo "== prompt caching tests =="
sed -n '1,140p' unstract/sdk1/tests/test_prompt_caching.py

echo
echo "== litellm completion source if available =="
python3 - <<'PY'
import importlib.util
spec = importlib.util.find_spec("litellm")
print("litellm_module_path:", getattr(spec, "origin", None))
if spec:
    import litellm
    import inspect
    print("litellm_version:", getattr(litellm, "__version__", None))
    for name in ("completion_cost", "cost_per_token"):
        fn = getattr(litellm, name, None)
        print(f"litellm.{name}:", fn)
        if fn is not None:
            try:
                print(inspect.signature(fn))
            except Exception as e:
                print("signature_error:", e)
            try:
                src = inspect.getsource(fn)
                for i, line in enumerate(src.splitlines()[:120], 1):
                    print(f"{i}:{line}")
                print("---")
            except Exception as e:
                print("source_error:", e)
PY

Repository: Zipstack/unstract

Length of output: 21711


🌐 Web query:

site:github.com/BerriAI/litellm completion_cost model param response model cost_per_token model

💡 Result:

The cost of an LLM completion request is typically calculated by summing the costs of input (prompt) and output (completion) tokens, often adjusted for factors like prompt caching or specialized modalities (e.g., audio) [1][2][3]. The standard formula for calculating the total cost is: Total Cost = (Input Tokens * Input Cost per Token) + (Output Tokens * Output Cost per Token) [4][3] Technical implementation details often include the following components: 1. Token Counts: The system calculates or extracts the number of prompt tokens and completion tokens generated during the API call [4][5]. 2. Unit Cost Mapping: Costs are determined by referencing a model-specific map that defines the price per unit (e.g., per 1 million tokens or per single token) for input and output [4][6]. 3. Advanced Cost Factors: - Prompt Caching: Costs may be further broken down into distinct rates for cache creation and cache reads [6][3]. - Multi-modal Adjustments: For models processing audio or images, additional costs per audio/image token may be added to the standard input/output calculations [1][2][7]. - Threshold Pricing: Some models apply different rates for tokens once usage exceeds specific thresholds (e.g., beyond 200k tokens) [6][2]. - Alternative Metrics: Some providers or specialized workloads (e.g., certain deployments on AWS Sagemaker or Replicate) may use "cost per second" based on inference runtime rather than token counts [6][5]. In production environments, developers often use middleware or helper utilities to automate these calculations by querying these model-specific price maps based on the provider and model name [4][6][3].

Citations:


🌐 Web query:

"def completion_cost" "model =" "completion_response" litellm

💡 Result:

The completion_cost function in LiteLLM is used to calculate the cost (in USD) of a completion request [1][2]. To use it, you pass your litellm.completion() response object to the completion_response parameter [1][2]. Example usage: from litellm import completion, completion_cost # Make the completion call response = completion( model="gpt-4o", messages=[{"role": "user", "content": "Hello!"}]) # Calculate the cost cost = completion_cost(completion_response=response) print(f"Total cost: ${float(cost):.10f}") Key details: - Alternative inputs: If you do not have a response object, you can provide the model name along with prompt and completion strings to estimate the cost [1][2]. - Pre-computed cost: LiteLLM automatically computes the cost for every completion call and stores it in the response object [1]. You can often access this directly via response._hidden_params.get("response_cost") [1][3] or kwargs.get("response_cost") within custom logging callbacks [4]. - Customization: The function supports optional parameters for custom pricing, such as custom_cost_per_token or custom_llm_provider, to handle specialized models or scenarios [5][6][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

if [ -n "$(command -v gh 2>/dev/null)" ]; then
  echo "== fetch LiteLLM cost calculator source from main =="
  gh api raw https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/cost_calculator.py | sed -n '1,240p'
else
  echo "gh unavailable"
fi

echo
echo "== inspect LLM._record_usage =="
sed -n '750,832p' unstract/sdk1/src/unstract/sdk1/llm.py

echo
echo "== inspect cost_per_token implementation from installed package if available by file walk =="
python3 - <<'PY'
import importlib.util, sys, inspect
spec = importlib.util.find_spec("litellm")
if not spec:
    print("no literm installed")
    sys.exit(0)
print(spec.origin)
from pathlib import Path
base = Path(spec.submodule_search_locations[0]) if spec.submodule_search_locations else Path(spec.origin).parent
print("base", base)
files = list(base.rglob("*.py"))
for f in files[:20]:
    print(f)
PY

Repository: Zipstack/unstract

Length of output: 237


🌐 Web query:

raw.githubusercontent.com BerriAI litellm main litellm cost_calculator.py "model =" "completion_response"

💡 Result:

The file litellm/cost_calculator.py within the BerriAI/litellm repository is a core utility module used to calculate the cost of LLM API calls [1]. The mention of completion_response in your query refers to the parameter used by functions in this module—specifically completion_cost—to extract usage information (such as prompt and completion tokens) and the model name to compute the total cost of a response [1]. Key details regarding this file and its usage include: 1. Functionality: The completion_cost function calculates the total cost (in USD) for a given LLM response object [2][1]. If the completion_response is provided, it automatically extracts token counts and the model name to perform the calculation [1]. If not provided, it can calculate costs based on provided model names and text inputs [1]. 2. Dependency: It relies on model pricing information, which LiteLLM typically fetches from a JSON file (model_prices_and_context_window.json) [2][1]. 3. Usage Pattern: Developers typically use it as follows: from litellm import completion_cost cost = completion_cost(completion_response=response_object) 4. Integration: The logic in cost_calculator.py is also used by other LiteLLM components, such as the BudgetManager, to track and update user or model-specific costs [3]. If you are encountering issues with this file, note that it is frequently updated to support new models and providers, and it is standard practice to ensure your LiteLLM version is up to date to maintain accurate cost tracking [4].

Citations:


🌐 Web query:

github.com/BerriAI/litellm/blob/main/litellm/cost_calculator.py "if completion_response is not None"

💡 Result:

In the LiteLLM library, the check if completion_response is not None within litellm/cost_calculator.py is used to determine how to proceed with calculating the cost of an LLM API call [1][2]. When completion_response is provided (is not None), the cost calculation logic extracts necessary information—specifically the model name and token usage—directly from the response object [1][2]. This allows LiteLLM to dynamically identify the specific model used for the completion, which is particularly useful in scenarios like dynamic routing (e.g., via Azure Model Router), where the actual model utilized may differ from the initial request model [3]. The code supports both Pydantic BaseModel objects and dictionaries as valid completion_response types, using getattr or dictionary access to retrieve the 'model' field and usage statistics [1]. If completion_response is None, the function falls back to calculating costs based on provided prompt and completion text strings, or other parameters like prompt_tokens and completion_tokens if they were passed separately [2][4][5]. In summary, this conditional check enables the library to handle both post-completion cost tracking (where a response object is available) and pre-completion cost estimation (where only input/output text or token counts are available) [2][5].

Citations:


🌐 Web query:

raw.githubusercontent.com/BerriAI/litellm/main/litellm/cost_calculator.py "completion_cost"

💡 Result:

The completion_cost function in the LiteLLM library is a utility designed to calculate the total cost in USD for a given language model API call [1][2][3]. It is located in the litellm/cost_calculator.py module [1][4]. Key features and usage details include: 1. Functionality: It computes the total cost by combining token counts (for input/prompt and output/completion) with model-specific pricing data [2][3][5]. It utilizes a built-in model cost map [2][5]. 2. Primary Inputs: - completion_response: The response object returned from a LiteLLM completion call [1][3]. - Alternatively, users can provide the model name, the prompt (as a string), and the completion (as a string) to calculate costs manually without a full response object [1][2][3]. 3. Advanced Parameters: - Custom Pricing: Supports overriding default costs using parameters such as custom_cost_per_token or custom_cost_per_second, which is particularly useful for providers like Azure or SageMaker [1][4][6]. - Regional Pricing: Includes parameters like region_name for services with location-based pricing, such as Bedrock [1][4]. - Other: Accepts optional parameters for image generation (size, quality, n), service tiers, and specific routing identifiers [1][4]. 4. Return Value: The function returns a float representing the total USD cost for the specified interaction [1][3]. For implementation examples, users typically pass the response object from a litellm.completion call directly into completion_cost [7][3]. Alternatively, manual calculation can be performed by providing the model, prompt, and completion text directly to the function [2][3][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

URL="https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/cost_calculator.py"
tmp="$(mktemp)"

if command -v curl >/dev/null 2>&1; then
  curl -fsSL "$URL" -o "$tmp"
else
  python3 - <<'PY' - "$URL" "$tmp"
import urllib.request, sys
urllib.request.urlretrieve(sys.argv[1], sys.argv[2])
PY
fi

echo "== LiteLLM cost calculator snippet =="
sed -n '1,240p' "$tmp"

echo
echo "== model/model_name handling snippets =="
rg -n "model ?=|model_name|completion_response|usage|response_cost|cost_per_token|base_model" "$tmp" -C 3

rm -f "$tmp"

echo
echo "== inspect LLM._record_usage and cost_model caller context =="
sed -n '740,828p' unstract/sdk1/src/unstract/sdk1/llm.py

Repository: Zipstack/unstract

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

URL="https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/cost_calculator.py"
tmp="$(mktemp)"
curl -fsSL "$URL" -o "$tmp"

echo "== LiteLLM completion_price_model selection after model selection =="
sed -n '1100,1240p' "$tmp"

echo
echo "== LiteLLM usage/cost calculation calls for Bedrock route =="
sed -n '1600,1900p' "$tmp"

echo
echo "== LiteLLM generic_cost_per_token signature/body =="
sed -n '290,370p' "$tmp"

rm -f "$tmp"

Repository: Zipstack/unstract

Length of output: 23019


🌐 Web query:

github.com/BerriAI/litellm BedRockCostConfig cost_per_token anthropic models

💡 Result:

In LiteLLM, cost calculation for Anthropic models hosted on AWS Bedrock is handled primarily through the cost_per_token function located in litellm/llms/bedrock/cost_calculation.py [1]. This utility calculates costs by utilizing the same logic as standard Anthropic cost calculations, while ensuring the provider is identified as bedrock to facilitate appropriate pricing lookups [1]. To manage or override pricing for these models, LiteLLM provides a custom pricing mechanism [2]. You can override the default cost map by adding a model_info key to your model configuration in the LiteLLM proxy or config file [2]. Example configuration for overriding pricing: model_list: - model_name: "my-bedrock-claude" litellm_params: model: "bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0" model_info: input_cost_per_token: 0.000003 output_cost_per_token: 0.000015 For providers like Bedrock that support multiple pricing tiers (e.g., PayGo, priority, or flex), LiteLLM automatically selects the appropriate pricing based on the serviceTier returned in the response [2]. Note that there are known edge cases regarding cost calculation: 1. Cross-region inference: Some users have reported issues where region-specific pricing entries (e.g., for us.anthropic.claude-sonnet-4-6) are not correctly matched due to the model name stripping logic, which may default back to the base model's pricing instead [3]. 2. Provider-specific dispatch: In some configurations, if a custom_llm_provider is explicitly set, the cost calculator may dispatch to provider-specific functions (like anthropic_cost_per_token) before checking for custom override values, which can lead to custom zero-cost overrides being ignored [4].

Citations:


Pass the cost override into the response-based cost path.

cost_model is stripped from completion kwargs but passed back into _record_usage(); the fall-back cost_per_token(model=model, ...) uses it for pricing, while completion_cost(completion_response=response) derives the model from response and ignores the override when caching is enabled. Pass the override (model=model) to completion_cost so cached and non-cached calls use the same pricing model.

Proposed fix
         if has_cache_tokens and response is not None:
             try:
-                return litellm.completion_cost(completion_response=response)
+                return litellm.completion_cost(completion_response=response, model=model)
             except Exception:

Add a regression test for prompt-cached calls with cost_model set, since cost tracking covers cached-token handling only implicitly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@unstract/sdk1/src/unstract/sdk1/llm.py` around lines 828 - 869, Update
_compute_call_cost so the response-based litellm.completion_cost call passes the
cost override via model=model, keeping cached-call pricing consistent with the
fallback cost_per_token path. Add a regression test covering a prompt-cached
call with cost_model set and verifying the override is used for cost tracking.

Comment on lines +163 to +187
prompt_args = {
"preamble": tool_settings.get(PSKeys.PREAMBLE, ""),
"prompt": output[prompt],
"postamble": tool_settings.get(PSKeys.POSTAMBLE, ""),
"grammar_list": tool_settings.get(PSKeys.GRAMMAR, []),
"context": context,
"platform_postamble": platform_postamble,
"word_confidence_postamble": word_confidence_postamble,
"prompt_type": prompt_type,
}
cache_prefix: str | None = None
if is_prompt_caching_enabled():
# Reorder so the reused document context becomes a cached prefix.
# The text the model sees is ``cache_prefix + prompt_str``.
cache_prefix, prompt_str = AnswerPromptService.construct_cached_prompt(
**prompt_args
)
output[PSKeys.COMBINED_PROMPT] = cache_prefix + prompt_str
else:
prompt_str = AnswerPromptService.construct_prompt(**prompt_args)
output[PSKeys.COMBINED_PROMPT] = prompt_str
return AnswerPromptService.run_completion(
llm=llm,
prompt=prompt,
prompt=prompt_str,
cache_prefix=cache_prefix,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep unsupported providers on the original prompt order.

Line 174 reorders prompts whenever the global flag is enabled, but cache controls are only supported for Anthropic and Bedrock. Other providers therefore receive a context-first prompt without caching, changing extraction behavior with no benefit.

  • workers/executor/executors/answer_prompt.py#L163-L187: select construct_cached_prompt() only when caching is effectively supported for this LLM; otherwise retain construct_prompt().
  • workers/tests/test_answer_prompt_caching.py#L52-L91: add a globally-enabled, unsupported-provider case that verifies the original prompt order and no cache prefix are sent.
📍 Affects 2 files
  • workers/executor/executors/answer_prompt.py#L163-L187 (this comment)
  • workers/tests/test_answer_prompt_caching.py#L52-L91
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/executor/executors/answer_prompt.py` around lines 163 - 187, The
prompt construction flow in AnswerPromptService must only use
construct_cached_prompt when caching is globally enabled and supported by the
current LLM; otherwise use construct_prompt, preserve the original prompt order,
and pass no cache prefix. Update workers/executor/executors/answer_prompt.py
lines 163-187 accordingly, and add the globally enabled unsupported-provider
coverage in workers/tests/test_answer_prompt_caching.py lines 52-91 to verify
the original ordering and absence of a cache prefix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant