litellm - 💡(How to fix) Fix [Bug]: OpenAI→A2A bridge breaks for spec-compliant A2A agents (fasta2a / Pydantic AI)

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

Error Message

pydantic_core._pydantic_core.ValidationError: 1 validation error for tagged-union[JSONRPCRequest,JSONRPCRequest,...] message/send.params.message.kind Field required [type=missing, input_value={'role': 'user', 'parts': [...]}]

Root Cause

For agents that complete in <<RTT, this happens to work because some servers complete before they reply. For fasta2a (and any spec-conformant async A2A server), it always returns submitted first → extract_text_from_a2a_response finds no artifacts (or only the user's echoed message) → content = "".

Fix Action

Fix

Add a kind == "data" branch that serializes the structured data:

import json
...

for part in parts:
    kind = part.get("kind")
    if kind == "text":
        text_parts.append(part.get("text", ""))
    elif kind == "data":
        data = part.get("data")
        if data is not None:
            try:
                text_parts.append(json.dumps(data, ensure_ascii=False))
            except (TypeError, ValueError):
                text_parts.append(str(data))
    elif "parts" in part:
        ...

Better-but-bigger alternative: surface data parts as OpenAI structured outputs (parsed JSON in message.parsed) when present. For now, a JSON string in message.content is at least non-empty and unblocks consumers like OpenWebUI.

Code Example

# agent.py
from pydantic import BaseModel
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider

class Pong(BaseModel):
    msg: str

model = OpenAIChatModel("gpt-4o-mini", provider=OpenAIProvider(api_key="..."))
agent = Agent(model, output_type=Pong, instructions="Respond with msg='pong'.")
app = agent.to_a2a()

---

curl -X POST http://litellm:4000/v1/agents \
  -H "Authorization: Bearer $MASTER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_name": "demo",
    "agent_card_params": {
      "name": "Demo",
      "url": "http://my-agent:9100",
      "version": "0.1.0",
      "protocolVersion": "1.0",
      "capabilities": {"streaming": false},
      "defaultInputModes": ["text"],
      "defaultOutputModes": ["text"],
      "skills": []
    },
    "litellm_params": {"url": "http://my-agent:9100"}
  }'

---

curl -X POST http://litellm:4000/v1/chat/completions \
  -H "Authorization: Bearer $MASTER_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"a2a/demo","messages":[{"role":"user","content":"ping"}],"max_tokens":100}'

---

a2a_message = {
    "role": "user",
    "parts": [{"kind": "text", "text": full_context}],
    "messageId": str(uuid.uuid4()),
}

---

pydantic_core._pydantic_core.ValidationError: 1 validation error for
tagged-union[JSONRPCRequest,JSONRPCRequest,...]
message/send.params.message.kind
  Field required [type=missing, input_value={'role': 'user', 'parts': [...]}]

---

a2a_message = {
    "kind": "message",   # <-- ADD
    "role": "user",
    "parts": [{"kind": "text", "text": full_context}],
    "messageId": str(uuid.uuid4()),
}

---

{
  "kind": "task",
  "artifacts": [{
    "name": "result",
    "parts": [{
      "kind": "data",
      "data": {"result": {"msg": "pong"}}
    }]
  }]
}

---

for part in parts:
    if part.get("kind") == "text":
        text_parts.append(part.get("text", ""))
    elif "parts" in part:
        # nested
        ...

---

import json
...

for part in parts:
    kind = part.get("kind")
    if kind == "text":
        text_parts.append(part.get("text", ""))
    elif kind == "data":
        data = part.get("data")
        if data is not None:
            try:
                text_parts.append(json.dumps(data, ensure_ascii=False))
            except (TypeError, ValueError):
                text_parts.append(str(data))
    elif "parts" in part:
        ...

---

text = extract_text_from_a2a_response(response_json)
# ...writes text to choices[0].message.content

---

NotImplementedError: Method message/stream not implemented.

---

stream = optional_params.get("stream", False)
method = "message/stream" if stream else "message/send"

---

litellm-database:main-stable
fasta2a==0.6.1
pydantic-ai-slim[openai,mcp,a2a]==1.101.0

---
RAW_BUFFERClick to expand / collapse

Check for existing issues

  • I have searched the existing issues and checked that my issue is not a duplicate.

What happened?

OpenAI→A2A bridge breaks for spec-compliant A2A agents (fasta2a / Pydantic AI)

Affected version: litellm-database:main-stable (sha as of 2026-05-22, latest release at the time of testing) Component: litellm/llms/a2a/ — OpenAI-compatible proxy for A2A Agent Gateway agents Severity: High — makes the OpenAI-compat path unusable for any A2A agent that

  • emits structured outputs (Pydantic AI output_type=...), and/or
  • validates incoming requests strictly against the A2A spec (fasta2a does).

TL;DR

Calling POST /v1/chat/completions with model="a2a/<name>" (where <name> is a registered A2A agent backed by a fasta2a server, e.g. Pydantic AI's agent.to_a2a()) fails in four independent ways:

  1. message.kind missing → agent rejects with 422 / LiteLLM bubbles up 500.
  2. kind: "data" parts dropped → 200 OK but message.content == "".
  3. No polling of async A2A tasks → response carries state="submitted", never "completed", so artifacts are never fetched.
  4. stream=true maps to message/stream which most A2A servers don't implement → 500 NotImplementedError. Triggered by every OpenAI client that defaults to streaming (OpenWebUI does).

Repro setup

A minimal Pydantic AI agent exposed via agent.to_a2a():

# agent.py
from pydantic import BaseModel
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider

class Pong(BaseModel):
    msg: str

model = OpenAIChatModel("gpt-4o-mini", provider=OpenAIProvider(api_key="..."))
agent = Agent(model, output_type=Pong, instructions="Respond with msg='pong'.")
app = agent.to_a2a()

Run with uvicorn agent:app --host 0.0.0.0 --port 9100.

Register in LiteLLM:

curl -X POST http://litellm:4000/v1/agents \
  -H "Authorization: Bearer $MASTER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_name": "demo",
    "agent_card_params": {
      "name": "Demo",
      "url": "http://my-agent:9100",
      "version": "0.1.0",
      "protocolVersion": "1.0",
      "capabilities": {"streaming": false},
      "defaultInputModes": ["text"],
      "defaultOutputModes": ["text"],
      "skills": []
    },
    "litellm_params": {"url": "http://my-agent:9100"}
  }'

Then call via OpenAI-compat:

curl -X POST http://litellm:4000/v1/chat/completions \
  -H "Authorization: Bearer $MASTER_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"a2a/demo","messages":[{"role":"user","content":"ping"}],"max_tokens":100}'

Bug 1 — message.kind missing in outgoing requests

File: litellm/llms/a2a/chat/transformation.py Lines: ~225-230 (transform_request) and ~360-365 (_openai_message_to_a2a_message)

The A2A spec defines Message as a tagged union with kind: "message" as discriminator (see A2A spec — Message and the JSONRPCRequest definition). LiteLLM constructs the outgoing message without the discriminator:

a2a_message = {
    "role": "user",
    "parts": [{"kind": "text", "text": full_context}],
    "messageId": str(uuid.uuid4()),
}

fasta2a's request validator uses TypeAdapter[JSONRPCRequest].validate_json(body) and rejects this with:

pydantic_core._pydantic_core.ValidationError: 1 validation error for
tagged-union[JSONRPCRequest,JSONRPCRequest,...]
message/send.params.message.kind
  Field required [type=missing, input_value={'role': 'user', 'parts': [...]}]

fasta2a returns 500, LiteLLM bubbles up A2aException - Internal Server Error.

Fix

Add "kind": "message" to both dict literals:

a2a_message = {
    "kind": "message",   # <-- ADD
    "role": "user",
    "parts": [{"kind": "text", "text": full_context}],
    "messageId": str(uuid.uuid4()),
}

(and identically in _openai_message_to_a2a_message).

Bug 2 — kind: "data" parts silently dropped

File: litellm/llms/a2a/common_utils.py Function: extract_text_from_a2a_message Lines: ~67-93

A2A agents that return structured outputs (Pydantic AI with output_type=..., LangGraph nodes with a typed return, …) emit task artifacts of the form:

{
  "kind": "task",
  "artifacts": [{
    "name": "result",
    "parts": [{
      "kind": "data",
      "data": {"result": {"msg": "pong"}}
    }]
  }]
}

extract_text_from_a2a_message only handles kind == "text":

for part in parts:
    if part.get("kind") == "text":
        text_parts.append(part.get("text", ""))
    elif "parts" in part:
        # nested
        ...

The data part is dropped, so transform_response writes message.content = "". From the OpenAI client's perspective the agent returned nothing.

Fix

Add a kind == "data" branch that serializes the structured data:

import json
...

for part in parts:
    kind = part.get("kind")
    if kind == "text":
        text_parts.append(part.get("text", ""))
    elif kind == "data":
        data = part.get("data")
        if data is not None:
            try:
                text_parts.append(json.dumps(data, ensure_ascii=False))
            except (TypeError, ValueError):
                text_parts.append(str(data))
    elif "parts" in part:
        ...

Better-but-bigger alternative: surface data parts as OpenAI structured outputs (parsed JSON in message.parsed) when present. For now, a JSON string in message.content is at least non-empty and unblocks consumers like OpenWebUI.

Bug 3 — No polling of async A2A tasks

File: litellm/llms/a2a/chat/transformation.py Function: transform_response

A2A's message/send is asynchronous by default: the server returns a Task object immediately with status.state == "submitted", and clients are expected to poll tasks/get (or use message/stream) until state == "completed" to retrieve the artifacts.

LiteLLM treats the immediate response as the final answer:

text = extract_text_from_a2a_response(response_json)
# ...writes text to choices[0].message.content

For agents that complete in <<RTT, this happens to work because some servers complete before they reply. For fasta2a (and any spec-conformant async A2A server), it always returns submitted first → extract_text_from_a2a_response finds no artifacts (or only the user's echoed message) → content = "".

Suggested fix

Two options, in increasing order of robustness:

  1. Send configuration.blocking = true in the outgoing message/send params. fasta2a v0.6+ accepts this and waits for completion before responding. This is a 1-line patch. (Confirm that other supported A2A servers — LangGraph, Vertex AI Agent Engine, Bedrock AgentCore, Azure AI Foundry — also honor blocking per spec.)
  2. Poll explicitly in transform_response when the response has result.kind == "task" and status.state in {"submitted","working"}. Fetch the agent URL, retry tasks/get with a configurable backoff (e.g., 200ms→max 2s) until state ∈ {"completed","failed","canceled"} or request_timeout is reached.

Bug 4 — stream=true routes to message/stream which fasta2a doesn't implement

File: litellm/llms/a2a/chat/transformation.py Function: transform_request (lines ~210-215) Symptom: With any client that defaults to streaming (OpenWebUI sends stream=true by default), the bridge returns 500 A2aException - Internal Server Error. Agent log shows:

NotImplementedError: Method message/stream not implemented.

Root cause: transform_request maps the OpenAI stream param to the A2A method:

stream = optional_params.get("stream", False)
method = "message/stream" if stream else "message/send"

But fasta2a v0.6+ and most A2A servers don't implement message/stream. The OpenAI-compat consumer has no way to know this — they just enabled streaming as usual.

Suggested fix (any one of):

  1. Always use message/send (forced sync). The streaming experience becomes a single SSE chunk emitted after the agent completes (combined with the polling fix from Bug 3). Simple, low risk.
  2. Detect agent capabilities before deciding: GET /.well-known/agent-card.json (or use the cached agent card from registry), inspect capabilities.streaming (defined in A2A spec), and only use message/stream if true. Falls back to message/send otherwise.

Option 1 is what our local patch does. Option 2 is the spec-correct path and what we'd recommend long-term.

Side effect to be aware of

The streaming iterator (A2AModelResponseIterator.chunk_parser) currently operates on whatever LiteLLM receives. If the gateway forces message/send (option 1) but the client requested stream=true, LiteLLM will still go through the streaming path and chunk_parse the immediate state="submitted" response — which has no artifacts, producing an empty delta. So Bug 4's fix really needs Bug 3's fix in place too (or the streaming iterator needs to poll, mirroring transform_response).

Related issues

  • #26411 (open): "litellm can't handle long running tasks via a2a". Reporter hits tasks/get errors when polling manually. Related to bug 3 here but comes at it from the opposite angle — they want the gateway to expose tasks/get cleanly; this issue wants the gateway to internally poll so the OpenAI consumer never has to call tasks/get itself.

Reproducibility

All four bugs reproduce with a fresh install:

litellm-database:main-stable
fasta2a==0.6.1
pydantic-ai-slim[openai,mcp,a2a]==1.101.0

Happy to submit a PR with fixes for all four bugs (small, low risk). For bug 3, the implementation in our local fork polls tasks/get in transform_response with 200ms→2s backoff and a 120s overall timeout (configurable via litellm_params.a2a_poll_timeout_seconds). This is backwards-compatible: it only kicks in when the response is a kind:"task" result in a non-terminal state.

Workaround currently used

We bind-mount locally patched copies of transformation.py and common_utils.py over the in-container LiteLLM files. The full patches and a short ASGI middleware on the agent side (which also injects kind="message" for defense-in-depth) are at: [link to your repo, optional].

End-to-end validated: POST /v1/chat/completions with model="a2a/<fasta2a-agent>" now returns 200 OK in ~8s with the agent's typed structured output serialized as JSON in message.content.


Thanks for the great gateway work — A2A as an OpenAI-compatible model namespace is a powerful pattern. These three fixes would close the last gap for real-world fasta2a/Pydantic AI agents.

Steps to Reproduce

Relevant log output

What part of LiteLLM is this about?

No response

What LiteLLM version are you on ?

v1.85.1

Twitter / LinkedIn details

No response

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

litellm - 💡(How to fix) Fix [Bug]: OpenAI→A2A bridge breaks for spec-compliant A2A agents (fasta2a / Pydantic AI)