Skip to content

feat(agent-cache-py): add Pydantic AI adapter#131

Open
amitkojha05 wants to merge 2 commits into
BetterDB-inc:masterfrom
amitkojha05:feat/pydantic-ai-adapter
Open

feat(agent-cache-py): add Pydantic AI adapter#131
amitkojha05 wants to merge 2 commits into
BetterDB-inc:masterfrom
amitkojha05:feat/pydantic-ai-adapter

Conversation

@amitkojha05
Copy link
Copy Markdown
Contributor

Summary

Adds betterdb_agent_cache.adapters.pydantic_ai — a CachedModel wrapper and prepare_params helper for Pydantic AI. The wrapper implements Pydantic AI's Model protocol, intercepts request() to check the cache before calling the underlying model, and stores on miss.

Pydantic AI doesn't expose raw API params like OpenAI/Anthropic — it calls model.request() internally. So the integration follows the same wrapper pattern as BetterDBSaver in langgraph.py rather than a standalone prepare_params function (though that's also exposed for users who want manual control).

Also adds README.md to packages/agent-cache-py/ referenced via readme = "README.md" in pyproject.toml — the package previously had no PyPI landing page.

Changes

  • New: betterdb_agent_cache/adapters/pydantic_ai.py

    • prepare_params(messages, model_name, model_settings) — normalizes Pydantic AI message history to LlmCacheParams. Handles SystemPromptPart, InstructionPart, UserPromptPart, TextPart, ToolCallPart, ToolReturnPart, RetryPromptPart. ThinkingPart is dropped — non-deterministic, would break cache key stability.
    • CachedModel(model, cache) — wraps any Pydantic AI Model, intercepts request() for cache-before-call semantics, delegates everything else via __getattr__. Uses lazy imports so pydantic-ai-slim is only required when the adapter is used.
  • New: tests/adapters/test_pydantic_ai.py — 7 tests covering prepare_params normalization (all part types, multi-turn round-trip, model settings passthrough), cache hit/miss behavior, __getattr__ delegation, and error propagation. Added make_persisting_valkey_client to conftest.py for store→check round-trip testing (existing make_client() always returns None on GET).

  • New: examples/pydantic_ai/ — runnable example with three scenarios (text, tool-calling, multi-turn) and README.

  • New: README.md — PyPI landing page for betterdb-agent-cache.

  • Modified: pyproject.toml — added pydantic_ai optional extra, pydantic-ai-slim>=0.0.14 to dev and all, pydantic-ai to keywords, readme = "README.md".

No changes to proprietary/. No changes to packages/agent-cache/ (TypeScript).

Checklist

  • Unit / integration tests added
  • Docs added / updated
  • Roborev review passed — run roborev review --branch or /roborev-review-branch in Claude Code (internal)
  • Competitive analysis done / discussed (internal)
  • Blog post about it discussed (internal)

Adds betterdb_agent_cache.adapters.pydantic_ai with:
  - prepare_params(messages, model_name, model_settings): normalizes
    Pydantic AI message history to LlmCacheParams, handling
    SystemPromptPart, InstructionPart, UserPromptPart, TextPart,
    ToolCallPart, ToolReturnPart, RetryPromptPart. ThinkingPart
    dropped (non-deterministic).
  - CachedModel(model, cache): wraps any Pydantic AI Model,
    intercepts request() for cache-before-call semantics, delegates
    everything else via __getattr__.

Includes runnable example (text / tools / multi-turn), full unit-test
coverage matching the existing adapter-test style, and README.md for
the PyPI landing page.

Adds pydantic_ai optional extra. No changes to base dependencies.
No changes to proprietary/ or the TypeScript agent-cache package.
@amitkojha05
Copy link
Copy Markdown
Contributor Author

@KIvanow @jamby77 Please review this PR ,would love to hear your feedback.

UserPromptPart,
)

_ = opts.normalizer if opts else default_normalizer
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Bug: the normalizer is evaluated but immediately discarded — _ is a throwaway name. Every other adapter in this package uses the same pattern correctly:

▎ - openai.py: normalizer = opts.normalizer if opts else default_normalizer → passed into _normalize_user_content(content, normalizer)
▎ - anthropic.py: same assignment → passed into _normalize_block(blk, normalizer)
▎ - llamaindex.py: norm = opts.normalizer → passed into _normalize_detail(part, norm)

▎ This should be normalizer = opts.normalizer if opts else default_normalizer, and then normalizer needs to be threaded into the UserPromptPart content loop. Currently, any Pydantic AI content item that isn't a plain string falls through to _to_text(), which JSON-serialises it raw (including full base64 image data) instead of producing a stable, compact ref via the normalizer. A user passing PydanticAIPrepareOptions(normalizer=hash_base64) would get no effect.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — normalizer = opts.normalizer if opts else default_normalizer, threaded into _normalize_user_content

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

All three existing adapters extract a private helper that accepts the normalizer as a parameter:

  • openai.py → _normalize_user_content(content, normalizer)
  • anthropic.py → _normalize_block(block, normalizer)
  • llamaindex.py → _normalize_detail(part, normalizer)

pydantic_ai.py inlines everything in prepare_params. This is fine for the current scope (Pydantic AI part types are well-defined), but it's a divergence from the established convention and makes the normalizer wire-up harder to see.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — extracted _normalize_user_content(content, normalizer) as a private async helper, matching the pattern inopenai.py and anthropic.py

elif hasattr(item, "content"):
user_blocks.append({"type": "text", "text": _to_text(getattr(item, "content"))})
else:
user_blocks.append({"type": "text", "text": _to_text(item)})
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

▎ Fixing the _ = → normalizer = bug above is necessary but not sufficient for binary content. This else branch (and the hasattr(item, "content") branch above it) both funnel all non-string UserPromptPart items through _to_text(), which JSON-serialises them raw. Pydantic AI's ImageUrl and BinaryContent types don't have a plain .content attribute, so they land here and embed the full base64 blob or URL string directly into the cache key.

▎ The other adapters handle this explicitly - e.g. openai.py dispatches image_url / input_audio / file items through await normalizer(...) to produce a compact, stable {"type": "binary", "ref": ""} block. The same dispatch needs to happen here for ImageUrl and BinaryContent. If multimodal input isn't in scope for this PR, at minimum add a comment documenting that binary content in UserPromptPart is not yet supported, so callers know to avoid it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added documentation in both the module docstring and the helper's docstring noting that ImageUrl and BinaryContent fall through to _to_text() and that full normalizer dispatch is deferred to a follow-up PR.


model_name = str(getattr(self._model, "model_name", self._model.__class__.__name__))

params = await prepare_params(messages, model_name, model_settings, self._opts)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

▎ model_request_parameters (accepted on line 201) is not passed to prepare_params and therefore not part of the cache key. In Pydantic AI, tool schemas are typically registered on the Agent at construction time and baked into model_request_parameters per call - so two requests with the same messages but different tool sets would produce the same cache key and get a false hit.

▎ This may be intentional if you're assuming one CachedModel instance wraps one fixed Agent. If so, a brief comment here would save the next reader the trouble of wondering whether the omission is a bug: e.g. # model_request_parameters (tool schemas) is excluded from the key - safe when this wrapper is tied to a single Agent instance whose tools don't change between calls.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added a comment explaining the omission is intentional for the single-Agent-per-wrapper usage pattern — matches the wording you suggested.

Copy link
Copy Markdown
Member

@KIvanow KIvanow left a comment

Choose a reason for hiding this comment

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

Thank you for your contribution @amitkojha05 ! Great work overall. I left a few comments on places that can be improved

…er, message normalization, tests, examples, deterministic cache handling, and lint fixes
@amitkojha05
Copy link
Copy Markdown
Contributor Author

@KIvanow all four points addressed in f6579c1. Happy to iterate if anything needs further adjustment.
Thanks

Copy link
Copy Markdown
Member

@KIvanow KIvanow left a comment

Choose a reason for hiding this comment

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

Looks good overall. We'll do a final test with latest changes before merging. Thank you @amitkojha05 !

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