feat(agent-cache-py): add Pydantic AI adapter#131
Conversation
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.
| UserPromptPart, | ||
| ) | ||
|
|
||
| _ = opts.normalizer if opts else default_normalizer |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed — normalizer = opts.normalizer if opts else default_normalizer, threaded into _normalize_user_content
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)}) |
There was a problem hiding this comment.
▎ 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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
▎ 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.
There was a problem hiding this comment.
Added a comment explaining the omission is intentional for the single-Agent-per-wrapper usage pattern — matches the wording you suggested.
KIvanow
left a comment
There was a problem hiding this comment.
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
|
@KIvanow all four points addressed in |
KIvanow
left a comment
There was a problem hiding this comment.
Looks good overall. We'll do a final test with latest changes before merging. Thank you @amitkojha05 !
Summary
Adds
betterdb_agent_cache.adapters.pydantic_ai— aCachedModelwrapper andprepare_paramshelper for Pydantic AI. The wrapper implements Pydantic AI'sModelprotocol, interceptsrequest()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 asBetterDBSaverinlanggraph.pyrather than a standaloneprepare_paramsfunction (though that's also exposed for users who want manual control).Also adds
README.mdtopackages/agent-cache-py/referenced viareadme = "README.md"in pyproject.toml — the package previously had no PyPI landing page.Changes
New:
betterdb_agent_cache/adapters/pydantic_ai.pyprepare_params(messages, model_name, model_settings)— normalizes Pydantic AI message history toLlmCacheParams. HandlesSystemPromptPart,InstructionPart,UserPromptPart,TextPart,ToolCallPart,ToolReturnPart,RetryPromptPart.ThinkingPartis dropped — non-deterministic, would break cache key stability.CachedModel(model, cache)— wraps any Pydantic AIModel, interceptsrequest()for cache-before-call semantics, delegates everything else via__getattr__. Uses lazy imports sopydantic-ai-slimis only required when the adapter is used.New:
tests/adapters/test_pydantic_ai.py— 7 tests coveringprepare_paramsnormalization (all part types, multi-turn round-trip, model settings passthrough), cache hit/miss behavior,__getattr__delegation, and error propagation. Addedmake_persisting_valkey_clienttoconftest.pyfor store→check round-trip testing (existingmake_client()always returnsNoneon GET).New:
examples/pydantic_ai/— runnable example with three scenarios (text, tool-calling, multi-turn) and README.New:
README.md— PyPI landing page forbetterdb-agent-cache.Modified:
pyproject.toml— addedpydantic_aioptional extra,pydantic-ai-slim>=0.0.14todevandall,pydantic-aito keywords,readme = "README.md".No changes to
proprietary/. No changes topackages/agent-cache/(TypeScript).Checklist
roborev review --branchor/roborev-review-branchin Claude Code (internal)