Skip to content

UN-3810 [FEAT] Prompt caching: SDK master switch + document-context caching (answer_prompt) - #2199

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

UN-3810 [FEAT] Prompt caching: SDK master switch + document-context caching (answer_prompt)#2199
pk-zipstack wants to merge 8 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

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 783bf450-63cd-43d6-83b3-2f663305b3dc

📥 Commits

Reviewing files that changed from the base of the PR and between 3ed24c1 and dbac1f9.

📒 Files selected for processing (1)
  • workers/executor/executors/answer_prompt.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • workers/executor/executors/answer_prompt.py

Summary by CodeRabbit

  • New Features

    • Added opt-in prompt caching for supported Anthropic Claude models.
    • Caching can be enabled globally or when creating an LLM.
    • Synchronous, streaming, and asynchronous completions support reusable prompt prefixes.
    • Added visibility into whether prompt caching is active.
    • Automatically organizes prompts to maximize reusable context.
  • Bug Fixes

    • Prevented caching for unsupported providers and non-Claude models.
    • Improved usage and cost reporting for cached requests, including cache read and write tokens.
    • Preserved existing prompt behavior when caching is unavailable or disabled.

Walkthrough

Prompt caching is added to adapter validation, SDK completion flows, usage accounting, and executor prompt construction. Anthropic and Bedrock Claude models can cache stable prompt prefixes when caching is enabled.

Changes

Prompt caching

Layer / File(s) Summary
Provider validation and caching configuration
unstract/sdk1/src/unstract/sdk1/adapters/base1.py, unstract/sdk1/src/unstract/sdk1/llm.py, unstract/sdk1/tests/test_prompt_caching.py
Adapter validation, constructor settings, and ENABLE_PROMPT_CACHING control caching. Bedrock caching is limited to Anthropic or Claude model identifiers.
Cached message construction and completion flows
unstract/sdk1/src/unstract/sdk1/llm.py, unstract/sdk1/tests/test_prompt_caching.py
Supported calls add cache markers and accept cache_prefix across synchronous, streaming, and asynchronous completion. Internal control flags are removed before LiteLLM calls.
Cache usage and cost accounting
unstract/sdk1/src/unstract/sdk1/llm.py, unstract/sdk1/tests/test_prompt_caching.py
Usage handling records cache token counts and calculates cache-aware costs with per-token and zero-cost fallbacks.
Executor prompt reordering and forwarding
workers/executor/executors/answer_prompt.py, workers/executor/executors/legacy_executor.py, workers/tests/test_answer_prompt_caching.py
The executor checks LLM capability, separates document context into a cache prefix when active, forwards it to completion, and preserves the original ordering otherwise.

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 markers
  LLM->>LiteLLM: send completion request
  LiteLLM->>Provider: submit supported model request
  Provider-->>LiteLLM: return completion and cache usage
  LiteLLM-->>LLM: return completion result
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.84% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: opt-in prompt caching in the SDK and answer_prompt extraction path.
Description check ✅ Passed The description is detailed and covers purpose, implementation, configuration, risks, testing, scope, and follow-ups, despite missing several template headings.
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 💡 1
📝 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

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.

@greptile-apps

greptile-apps Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds opt-in prompt caching for supported Anthropic and Bedrock-Claude models, including document-context caching in the answer-prompt execution path.

  • Introduces SDK-level caching gates, cached-prefix message construction, and cache-aware token-cost accounting.
  • Preserves full prompt content when caching is disabled or unsupported.
  • Restructures answer prompts into stable context prefixes only when the selected LLM reports active caching.
  • Adds regression tests for provider/model gating, inference-profile identifiers, prompt construction, configuration, and cost handling.

Confidence Score: 5/5

The PR appears safe to merge, with no blocking failures remaining from the previously reviewed Bedrock model-gating paths.

No blocking failure remains.

Important Files Changed

Filename Overview
unstract/sdk1/src/unstract/sdk1/adapters/base1.py Carries the prompt-caching control flag through Anthropic and Bedrock validation while restricting Bedrock enablement to identifiers recognized as Claude.
unstract/sdk1/src/unstract/sdk1/llm.py Adds the master switch, provider/model capability checks, cached-prefix message construction across completion APIs, and cache-aware usage pricing.
workers/executor/executors/answer_prompt.py Reorders document context into a cached prefix only when the active LLM confirms caching support, otherwise preserving existing prompt construction.
workers/executor/executors/legacy_executor.py Enables prompt caching when constructing the legacy executor's LLM integration.
unstract/sdk1/tests/test_prompt_caching.py Adds coverage for configuration, provider and Bedrock model gating, inference profiles, message preservation, control-flag stripping, and cached-call cost accounting.
workers/tests/test_answer_prompt_caching.py Adds coverage for cached and uncached answer-prompt construction and completion invocation.

Sequence Diagram

sequenceDiagram
    participant Worker as AnswerPromptService
    participant SDK as SDK1 LLM
    participant Provider as Anthropic / Bedrock Claude
    Worker->>SDK: Check is_prompt_caching_active()
    alt Caching supported and enabled
        Worker->>Worker: Build context prefix + volatile prompt
        Worker->>SDK: "complete(prompt, cache_prefix=context)"
        SDK->>Provider: Cached prefix block + volatile block
    else Caching inactive or unsupported
        Worker->>Worker: Preserve original prompt order
        Worker->>SDK: complete(full prompt)
        SDK->>Provider: Plain system and user messages
    end
    Provider-->>SDK: Completion and cache-token usage
    SDK-->>Worker: Response with recorded usage
Loading

Reviews (5): Last reviewed commit: "UN-3810 [FIX] Document fail-closed inten..." | Re-trigger Greptile

Comment thread unstract/sdk1/src/unstract/sdk1/llm.py Outdated

@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 thread unstract/sdk1/src/unstract/sdk1/llm.py
Comment thread unstract/sdk1/src/unstract/sdk1/llm.py
Comment thread workers/executor/executors/answer_prompt.py
@pk-zipstack pk-zipstack changed the title Prompt caching: SDK master switch + document-context caching (answer_prompt) UN-3810 [FEAT] Prompt caching: SDK master switch + document-context caching (answer_prompt) Jul 24, 2026
pk-zipstack and others added 2 commits August 6, 2026 17:07
…rder for non-caching LLMs, fix cached-call cost

Resolves greptile/coderabbit review findings on the prompt-caching PR:

1. Bedrock model gate (llm.py, base1.py): only emit cache_control for
   Anthropic/Claude models on Bedrock. Titan/Llama/Cohere/Mistral on
   Bedrock no longer get Anthropic-only cache blocks (ineffective +
   unsupported shapes). Enforced in LLM._prompt_caching_active() so it
   also covers the ENABLE_PROMPT_CACHING master-switch path; base1 keeps
   the validated flag honest for the metadata path.

2. answer_prompt reorder (answer_prompt.py): only reorder into a cached
   prefix when the LLM actually caches (new public
   LLM.is_prompt_caching_active()). Unsupported providers keep the
   original context-last prompt order instead of a no-benefit reorder.

3. Cached-call cost (llm.py): pass model= to litellm.completion_cost so
   cached calls price against the cost_model override, matching the
   cost_per_token fallback.

Tests: Bedrock anthropic/non-anthropic gating, public probe, base1
non-anthropic flag, cost override regression, and an unsupported-provider
answer_prompt case asserting original order + no cache_prefix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts:
#	unstract/sdk1/src/unstract/sdk1/llm.py
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@pk-zipstack

Copy link
Copy Markdown
Contributor Author

Review comments addressed + rebased on main

Pushed 8c0cffa5 (fixes) and merged latest main (resolved the llm.py/base1.py conflicts — prompt-caching lines kept alongside main's context_window handling and the mock-response hook).

Resolutions:

  1. Bedrock model gatingLLM._prompt_caching_active() now emits cache_control only for Anthropic/Claude models on Bedrock (not Titan/Llama/Cohere/Mistral). Enforced in the LLM layer so it also covers the ENABLE_PROMPT_CACHING master-switch path; base1 keeps the validated flag honest for the metadata path.
  2. answer_prompt reorder — only reorders into a cached prefix when the LLM actually caches, via new public LLM.is_prompt_caching_active(). Unsupported providers keep the original context-last order + no cache_prefix.
  3. Cached-call cost_compute_call_cost passes model= to litellm.completion_cost so cached calls price against the cost_model override.

Tests: 28 SDK (test_prompt_caching.py) + 9 workers (test_answer_prompt_caching.py) passing — added Bedrock family gating, the public probe, the base1 non-Anthropic gate, the cost-override regression, and an unsupported-provider answer_prompt case.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

Comment thread unstract/sdk1/src/unstract/sdk1/llm.py Outdated

@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: 2

🧹 Nitpick comments (1)
workers/tests/test_answer_prompt_caching.py (1)

28-37: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Scope the sys.modules stubbing so it does not leak across the test session.

_load_answer_prompt inserts synthetic executor and executor.executors modules into sys.modules at import time and never removes them. These entries persist for the whole pytest session, and other worker tests import the real executor package, so later tests can receive stale namespace stubs depending on test order. Move the stubbing into a shared fixture that restores sys.modules, or centralize the same stubbing strategy in workers/conftest.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 `@workers/tests/test_answer_prompt_caching.py` around lines 28 - 37, Update
_load_answer_prompt and _mod initialization so synthetic executor modules are
created within a shared pytest fixture rather than at module import time. Ensure
the fixture restores the prior sys.modules entries after each test, preserving
real executor imports for other worker tests; centralize this setup in
workers/conftest.py if appropriate.
🤖 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 413-427: Update the cache-prefix condition in the prompt-building
method to require a truthy cache_prefix rather than only checking for None.
Treat empty strings as absent so they use the normal prompt path and never
create an empty Anthropic text block.

In `@unstract/sdk1/tests/test_prompt_caching.py`:
- Around line 245-267: Update the docstring in
test_cost_override_passed_to_completion_cost_on_cached_call so its summary line
is followed by a blank line before the description, satisfying Ruff D205.
Replace object() when invoking LLM._compute_call_cost with a minimal stub
instance that represents the required self context and can safely support future
self attribute access.

---

Nitpick comments:
In `@workers/tests/test_answer_prompt_caching.py`:
- Around line 28-37: Update _load_answer_prompt and _mod initialization so
synthetic executor modules are created within a shared pytest fixture rather
than at module import time. Ensure the fixture restores the prior sys.modules
entries after each test, preserving real executor imports for other worker
tests; centralize this setup in workers/conftest.py if appropriate.
🪄 Autofix

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: 7a267d38-4461-4155-a027-57d1ff64aad2

📥 Commits

Reviewing files that changed from the base of the PR and between f271d5c and 4ef96cd.

📒 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
🚧 Files skipped from review as they are similar to previous changes (2)
  • workers/executor/executors/legacy_executor.py
  • workers/executor/executors/answer_prompt.py

Comment thread unstract/sdk1/src/unstract/sdk1/llm.py Outdated
Comment thread unstract/sdk1/tests/test_prompt_caching.py Outdated
…ache_prefix, test nits

Second-pass review fixes (greptile + coderabbit) on the prompt-caching PR:

1. Opaque Bedrock inference profiles (llm.py, base1.py): the Anthropic/
   Claude model gate now checks both `model` and `model_id`. When a caller
   routes through a Bedrock Application Inference Profile, the ARN in
   `model` is opaque and the Claude id only appears in `model_id`; the
   previous model-only check silently bypassed caching for those calls.

2. Empty cache_prefix (llm.py): `_build_messages` now treats an empty
   `cache_prefix` as absent (truthy check, not `is not None`). Anthropic
   rejects empty text content blocks, and an empty prefix has no caching
   benefit; the fallback concat path drops it cleanly too.

3. Test quality (test_prompt_caching.py): fixed the D205 docstring, use a
   SimpleNamespace stub instead of bare object() for `self`, and annotate
   monkeypatch params. Added tests for the opaque-AIP model_id path (and
   the no-Claude-id negative) plus the empty-cache_prefix case.

ruff + ruff-format clean; 31 SDK + 9 workers tests passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@pk-zipstack

Copy link
Copy Markdown
Contributor Author

@greptile-apps review

The sole 4/5 blocker — "Opaque profiles bypass caching" (llm.py:380-381) — is fixed in ce582f521, which is newer than the last-reviewed commit (4ef96cd4b). The Bedrock capability gate now checks both model and model_id, so a Claude call routed through an opaque Bedrock Application Inference Profile (opaque ARN in model, Claude id in model_id) is correctly detected and caches:

  • LLM._prompt_caching_active() — iterates ("model", "model_id") for the anthropic/claude markers.
  • AWSBedrockLLMParameters.validate() — same both-field check via _MODEL_ID_FIELDS, keeping the validated flag honest.
  • Tests added: test_bedrock_opaque_inference_profile_uses_model_id (AIP ARN in model + Claude id in model_id → caches) and test_bedrock_opaque_profile_without_claude_id_does_not_cache (no id anywhere → correctly off).

Also addressed in the same commit: empty-cache_prefix guard (no empty Anthropic text block) and the test-quality nits (D205, SimpleNamespace stub). All review threads resolved; ruff + ruff-format clean; 31 SDK + 9 workers tests passing.

@harini-venkataraman

Copy link
Copy Markdown
Contributor

PR Review Summary: #2199 — UN-3810 [FEAT] Prompt caching: SDK master switch + document-context caching

Overview

This PR implements opt-in LLM prompt caching for the Unstract SDK, gated by an ENABLE_PROMPT_CACHING env var (default off). It adds cache_control block emission for
Anthropic/Bedrock-Anthropic providers, document-context caching in answer_prompt, and cache-aware cost accounting. The PR includes 37 tests across 2 test files.

Overall Assessment: Well-designed feature with strong test coverage and thoughtful gating. The three-layer gating (master switch, adapter flag, provider/model check) is sound,
and the fallback paths are robust (prefix is never dropped). Several issues need attention before merge.


Critical Issues (2 found)

  1. acomplete() does not accept or forward cache_prefix

File: unstract/sdk1/src/unstract/sdk1/llm.py

complete() and stream_complete() both accept and forward cache_prefix to _build_messages(). However, acomplete() was not updated — it calls self._build_messages(prompt) without a
cache_prefix parameter, and its signature doesn't accept one. Any future caller using async completion with caching will silently get un-cached behavior, creating an API
asymmetry that violates the principle of consistent sync/async surfaces.

Fix: Add cache_prefix: str | None = None to acomplete() signature and forward it.

  1. Empty cache_prefix="" creates invalid Anthropic text block

File: unstract/sdk1/src/unstract/sdk1/llm.py, _build_messages()

When cache_prefix="", the is not None check passes, producing {"type": "text", "text": "", "cache_control": {"type": "ephemeral"}}. Empty content blocks are generally rejected or
meaningless for Anthropic API caching.

Fix: Change cache_prefix is not None to cache_prefix (truthy check) in the _build_messages() guard. Similarly in the fallback: cache_prefix + prompt if cache_prefix else prompt.


Important Issues (5 found)

  1. Duplicate is_prompt_caching_enabled() definitions — DRY violation

Files: llm.py and answer_prompt.py both define identical copies

If the env var name or parsing logic changes, both copies must be updated in lockstep. Import from the SDK module instead.

  1. sys.modules manipulation in tests leaks into the process

File: workers/tests/test_answer_prompt_caching.py

_load_answer_prompt() injects synthetic packages into sys.modules at module load time and never cleans them up. This can contaminate other tests in the same pytest process.

Fix: Use a session-scoped fixture with cleanup or unittest.mock.patch.dict(sys.modules, ...).

  1. _compute_call_cost logs at debug level on cost fallback

File: llm.py, _compute_call_cost()

When completion_cost() fails for cached calls, the fallback to cost_per_token (which over-reports cost ~10x on cache hits) is logged at debug level — invisible in production.
Operators monitoring costs will see inflated charges with no explanation.

Fix: Promote to logger.warning with a note that cache pricing may be inaccurate.

  1. Duplicated postamble logic between construct_prompt and construct_cached_prompt

File: answer_prompt.py

Both methods independently compute JSON postamble and platform_postamble formatting. If one is updated without the other, prompt content will silently diverge between cached and
non-cached modes, making A/B comparison unreliable.

Fix: Extract the postamble preparation into a shared private method _prepare_postambles().

  1. Bedrock application-inference-profile ARNs bypass the model gate

File: llm.py, _prompt_caching_active()

Opaque ARN-based model IDs (e.g., arn:aws:bedrock:...:application-inference-profile/...) don't contain "anthropic" or "claude" substrings, so Claude models accessed via inference
profiles silently lose caching. This is safe (no wrong cache blocks) but may surprise operators.

Fix: Document this known limitation. Add a debug-level log when Bedrock caching is skipped so operators can diagnose.


Suggestions (4 found)

  1. Test gap: _compute_call_cost fallback path untested

Only the happy path (where litellm.completion_cost succeeds) is tested. Add tests for: (a) completion_cost raises → fallback to cost_per_token, (b) both fail → returns 0.0, (c)
has_cache_tokens=True but response=None → skips completion_cost.

  1. Test gap: enable_prompt_caching pop from completion_kwargs untested

The explicit completion_kwargs.pop("enable_prompt_caching", None) in complete(), stream_complete(), and acomplete() is untested. If removed, the flag would leak into litellm's
Pydantic validation.

  1. is_prompt_caching_enabled — consider warning on unrecognized values

Only "true" enables caching — "1", "yes", "on" don't work. No log message when someone sets a truthy-but-not-"true" value. Add a startup warning.

  1. _llm_caches_prompts warning should include LLM type/provider

The warning "Failed to determine prompt-caching support..." is generic with no LLM context to help operators identify which LLM is misbehaving.


Strengths

  • Three-layer gating (env var → adapter flag → provider/model check) is a clean design
  • Semantic invariance: model sees identical text regardless of caching state
  • Robust fallback: cache_prefix is never dropped, even when caching is off
  • Strong test architecture: _StubLLM pattern avoids mocking litellm while exercising real caching logic
  • Explicit regression guards: Tests like test_build_messages_cache_prefix_preserved_when_caching_off have clear docstrings explaining what they prevent
  • Comprehensive provider gating tests: 4 unsupported providers + 7 Bedrock models (3 Anthropic + 4 non-Anthropic)
  • Clean opt-in design: backward-compatible, no behavior change when disabled

Recommended Action

  1. Fix the 2 critical issues (acomplete asymmetry, empty cache_prefix)
  2. Address the sys.modules test cleanup and cost fallback logging
  3. Add missing test coverage for fallback paths
  4. Consider the DRY and postamble duplication fixes

…-log level, test coverage

Resolves harini-venkataraman's PR review (2 critical, 5 important, 4 suggestions):

Critical:
- acomplete() now accepts and forwards cache_prefix, matching complete()/
  stream_complete() (was silently un-cached on the async path).
- Empty cache_prefix already treated as absent (truthy guard) — kept + tested.

Important:
- Removed the duplicate is_prompt_caching_enabled() from answer_prompt.py; the
  SDK owns the env master switch and production gates on
  LLM.is_prompt_caching_active() (DRY).
- Test sys.modules stubs are now removed after import so they can't contaminate
  other tests in the same process.
- _compute_call_cost cost-fallback now logs at WARNING (the fallback can
  over-report cached-call cost ~10x; debug was invisible in prod).
- Extracted shared _prepare_postambles() so construct_prompt and
  construct_cached_prompt can't silently diverge.
- Bedrock gate logs a debug breadcrumb when caching is skipped for an
  unrecognized (opaque) model.

Suggestions:
- Tests for _compute_call_cost fallback paths (completion_cost raises, both
  fail -> 0.0, response=None skip).
- Tests that enable_prompt_caching/cost_model/context_window never reach
  litellm.completion / .acompletion.
- is_prompt_caching_enabled warns once on truthy-lookalikes ("1"/"yes"/"on").
- _llm_caches_prompts warning now includes the LLM type and provider.

ruff + ruff-format clean; 38 SDK + 11 workers tests passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@pk-zipstack

Copy link
Copy Markdown
Contributor Author

@harini-venkataraman thanks for the thorough review — all 11 points addressed in 3ed24c193.

Critical

  1. acomplete() now accepts and forwards cache_prefix (was silently un-cached on the async path). + async strip test.
  2. ✅ Empty cache_prefix — the truthy guard (is not Nonecache_prefix) landed in the prior commit; kept, with test_build_messages_empty_cache_prefix_treated_as_absent.

Important
3. ✅ DRY: removed the duplicate is_prompt_caching_enabled() from answer_prompt.py. It was dead in production (gating now flows through LLM.is_prompt_caching_active()); the SDK owns the env master switch.
4. ✅ sys.modules stubs are now removed after import (try/finally), so they can't contaminate other tests in the process.
5. ✅ _compute_call_cost cost-fallback now logs at WARNING with an explicit "cost may be OVER-reported" note.
6. ✅ Extracted shared _prepare_postambles() used by both construct_prompt and construct_cached_prompt; added a parity test so they can't silently diverge.
7. ✅ Bedrock: the gate already checks both model and model_id (so Claude via inference profiles caches); added the debug breadcrumb you suggested for the fully-opaque case.

Suggestions
8. ✅ Added _compute_call_cost fallback tests: completion_cost raises → cost_per_token; both fail → 0.0; response=None → skips completion_cost.
9. ✅ Added tests that enable_prompt_caching/cost_model/context_window never reach litellm.completion or .acompletion.
10. ✅ is_prompt_caching_enabled now warns once on truthy-lookalikes ("1"/"yes"/"on") and stays off.
11. ✅ _llm_caches_prompts warning now includes the LLM type and provider.

ruff + ruff-format clean; 38 SDK + 11 workers tests passing.

@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: 1

🤖 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 `@workers/executor/executors/answer_prompt.py`:
- Around line 122-135: Add a targeted Ruff `noqa` annotation to the intentional
broad exception in the prompt-caching capability probe, documenting that
arbitrary probe failures must fail closed and preserve the original prompt
order. Keep the existing exception handling and logging behavior unchanged,
including the nested provider lookup handling.
🪄 Autofix

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: 10d97412-d23f-453e-ac57-d534007b1355

📥 Commits

Reviewing files that changed from the base of the PR and between ce582f5 and 3ed24c1.

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

Comment thread workers/executor/executors/answer_prompt.py Outdated
…xcepts (BLE001)

Add noqa: BLE001 with rationale to the two intentional broad excepts in
_llm_caches_prompts: the capability probe must fail closed (keep the original
prompt order) and the provider lookup is best-effort log context. Per CodeRabbit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Aug 6, 2026

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Unstract test results

Per-group results

Status Group Tier Passed Failed Errors Skipped Duration (s)
e2e-api-deployment e2e 3 0 0 0 20.5
e2e-coowners e2e 1 0 0 0 1.5
e2e-etl e2e 1 0 0 0 8.8
e2e-login e2e 2 0 0 0 1.2
e2e-prompt-studio e2e 1 0 0 0 4.6
e2e-smoke e2e 2 0 0 0 1.4
e2e-workflow e2e 1 0 0 0 16.4
integration-backend integration 205 0 0 26 43.3
integration-connectors integration 1 0 0 7 8.1
integration-workers integration 140 0 0 1 50.4
unit-backend unit 460 0 0 1 38.5
unit-connectors unit 63 0 0 0 9.9
unit-core unit 33 0 0 0 1.4
unit-platform-service unit 15 0 0 0 2.7
unit-rig unit 109 0 0 0 5.3
unit-sdk1 unit 518 0 0 0 23.2
unit-workers unit 1346 0 0 1 104.3
TOTAL 2901 0 0 36 341.5

Critical paths

⚠️ Critical paths not yet covered

  • workflow-execution-fan-out — Multi-file workflow execution fans out to file-processing workers and rejoins. (declared coverage: no groups declared)
✅ Covered critical paths
  • auth-login — covered by e2e-login
  • adapter-register-llm — covered by integration-backend
  • workflow-author — covered by integration-backend
  • co-owner-manage — covered by integration-backend, e2e-coowners
  • workflow-create-execute — covered by e2e-workflow
  • api-deployment-provision — covered by integration-backend
  • api-deployment-auth — covered by integration-backend
  • api-deployment-run — covered by e2e-api-deployment
  • prompt-studio-author — covered by integration-backend
  • prompt-studio-fetch-response — covered by e2e-prompt-studio
  • connector-register-test — covered by integration-backend
  • pipeline-etl-execute — covered by e2e-etl
  • usage-aggregate-read — covered by integration-backend
  • usage-token-tracking — covered by e2e-api-deployment
  • callback-result-delivery — covered by e2e-api-deployment

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.

2 participants