Skip to content

feat!: replace vendored pydantic-ai with a LiteLLM-backed model client #230

Description

@ryan-yuuu

Summary

calfkit's model layer is pydantic-ai, vendored wholesale into
calfkit/_vendor/pydantic_ai/ (182 files, ~51k LOC) — a heavyweight dependency
for what calfkit actually uses it for. Because tools are remote nodes, calfkit
hands pydantic-ai only tool schemas (ExternalToolset of tool_defs,
agent.py:370); pydantic-ai runs one model step and returns
DeferredToolRequests. The multi-turn loop is driven by Kafka re-entry
(agent.py:344-372), not by pydantic-ai.

This issue replaces that layer with LiteLLM as the LLM-call transport, and
defines calfkit-native message types on top of LiteLLM's data types. This
sheds pydantic-ai and the ~51k vendored lines while leaving the distributed
runtime (envelope, tool dispatch, aggregation) untouched.

Motivation

  • Drop a heavyweight dependency. pydantic-ai brings a full agent framework
    (~51k vendored LOC) for what is, in calfkit, a single model call + a message
    model + tool-schema formatting. LiteLLM is a thin transport.
  • One provider surface. LiteLLM reaches 100+ providers behind one
    acompletion call and one config, replacing three native-SDK-bound clients.
  • Native retries / fallbacks / cost (num_retries, fallbacks, Router).
  • Centralizes provider-specific error handling — see Surface a typed "context window exceeded" error instead of a generic ModelHTTPError #193.
  • Matches the stated v1 non-goal (calfkit-v1-design.md §1.5): "We don't
    compete with LiteLLM."

Scope: a contained layer

This replaces the single-model-step + message-model + output/tool-schema
layer. It does not touch the distributed runtime.

Replace (today → after):

  • _agent_loop.run() (one step) → a calfkit call to litellm.acompletion.
  • ModelMessage/ModelResponse/parts in State.message_history → calfkit
    pydantic message types built on LiteLLM's ModelResponse/Message/Usage
    (OpenAI-shaped: content, tool_calls, reasoning_content, usage).
  • DeferredToolRequests/DeferredToolResults → parse tool_calls off the
    response; the deferred bridge is already calfkit-owned
    (ToolCallRef, State.add_tool_call, get_tool_result).
  • RetryPromptPart → a calfkit retry-prompt message (a tool-role message with
    error content fed back on re-entry).
  • ToolDefinition/ExternalToolset → emit OpenAI-format tools=[...] specs.
  • final_output_type (OutputSpec) → calfkit-owned pydantic validation over
    LiteLLM response_format.
  • RequestUsage → accumulate LiteLLM Usage.
  • The three native clients (AnthropicModelClient, OpenAIModelClient,
    OpenAIResponsesModelClient) → a single LiteLLMModelClient (in-process SDK).

Leave untouched (and why it stays orthogonal):

  • Envelope / reply slot / x-calf-kind / fault rail — a different layer
    (inter-node), not message-history internals.
  • Kafka tool dispatch (Call, ToolCallRef, dispatch_topic) — already
    calfkit-owned, outside pydantic-ai.
  • Durable fan-out / aggregation (ktables, PendingToolBatch,
    latest_tool_calls()) — only contact point is "message types must still expose
    tool calls," which the new types do. No design overlap.

Coupling to migrate

Non-vendored pydantic-ai imports to replace:

  • Model/agentcalfkit/nodes/agent.py, calfkit/nodes/tool.py,
    calfkit/providers/pydantic_ai/*, calfkit/providers/__init__.py
  • Message/wire modelscalfkit/models/state.py, calfkit/client/client.py,
    calfkit/models/tool_dispatch.py, calfkit/models/capability.py,
    calfkit/models/tool_context.py (RunContext)
  • Public projectionscalfkit/models/node_result.py,
    calfkit/models/consumer_context.py (both expose ModelMessage)
  • Projection / MCPcalfkit/nodes/_projection.py,
    calfkit/mcp/mcp_toolbox.py
  • Tests — ~28 files; offline test seam (FunctionModel/TestModel in
    tests/providers.py, tests/conftest.py) rebuilt on LiteLLM mock_response.

Breaking changes (contained)

  • message_history shape changes → persisted/in-flight State with the old
    pydantic-ai message shape won't deserialize. Accepted clean break (pre-1.0).
    Note: this is independent of the Envelope.reply/fault fields — the mechanism
    (Envelope → State → message_history) is unchanged.
  • Public API: InvocationResult/NodeResult and ConsumerContext return
    the new message type instead of ModelMessage.
  • Client API: native clients removed; model_client= takes LiteLLMModelClient.

Design considerations / open questions

  1. Message-type mapping. Define calfkit pydantic models over LiteLLM's
    ModelResponse/Message/Usage (confirm exact current LiteLLM types). Decide
    how thinking/reasoning_content and Anthropic prompt-cache controls (today's
    anthropic_thinking, anthropic_cache_*) ride through as LiteLLM passthrough
    params.
  2. Structured output. LiteLLM response_format JSON + calfkit pydantic
    validation, preserving the current final_output_type behavior.
  3. Error mapping → fault rail (the one real seam). Map LiteLLM exceptions
    (rate-limit, context-window, transient) into the already-converged
    fault-rail seam contract (Reliability successor to the fault rail: retry, redelivery, timeouts, DLT #222 umbrella, Ack policy & redelivery: close the per-hop at-most-once crash windows #218Dead-letter topic for unroutable faults #221; ADR-0003/0004/0006). This
    conforms to that contract; it does not change it.
  4. Offline testing. Replace FunctionModel/TestModel with LiteLLM
    mock_response so the default suite stays fast and provider-free.
  5. ModelRetry (tool-author API). Provide a calfkit equivalent for tools that
    ask the model to retry.
  6. Doc reconciliation. calfkit-v1-design.md line 3106 (un-vendor pydantic-ai
    for v1.0, SDK-native abstraction for v1.1) is superseded — update / ADR.

Sequencing

No design collision with the durable fan-out build (worktree
feat/durable-fanout-store) or the fault rail — they own different layers. The
only practical note: both this and the fan-out build edit agent.py/state.py,
so land one and rebase the other to avoid textual merge conflicts.

Non-goals

  • A LiteLLM proxy-server deployment mode — in-process SDK only.
  • A general model-abstraction layer beyond LiteLLM (LiteLLM owns that).
  • Preserving pydantic-ai message types on the wire.

Acceptance criteria

  • Single in-process LiteLLMModelClient; _vendor/pydantic_ai and the three
    native clients removed.
  • calfkit message types defined over LiteLLM data types; message_history
    serializes them; _projection.py updated.
  • One-model-step seam parity: tool-call parsing, structured output, retry
    prompt, deferred dispatch over Kafka, usage accumulation.
  • Public projections (InvocationResult, ConsumerContext) updated +
    documented as breaking.
  • LiteLLM exception → fault-rail seam mapping conforms to ADR-0003/0004/0006.
  • Offline suite passes provider-free via mock_response; real-LLM opt-in
    tests pass on ≥2 providers.
  • make fix && make check clean; CHANGELOG + docs updated; calfkit-v1-design.md
    reconciled (ADR if warranted).

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions