Skip to content

fix(a2a): accept the whole JSON-RPC id union the spec defines - #37704

Merged
yassin-berriai merged 1 commit into
litellm_internal_stagingfrom
litellm_a2a_accept_integer_jsonrpc_id
Aug 20, 2026
Merged

fix(a2a): accept the whole JSON-RPC id union the spec defines#37704
yassin-berriai merged 1 commit into
litellm_internal_stagingfrom
litellm_a2a_accept_integer_jsonrpc_id

Conversation

@yassin-berriai

@yassin-berriai yassin-berriai commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • JSON-RPC allows string | integer | null; our A2A response model required str
  • An agent echoing an integer id got a 500, not the agent's reply
  • A null id, which the spec mandates for uncorrelatable errors, also 500'd
  • Five distinct failing cases across message/send and tasks/get

How it solves it:

  • Widen the A2A response model's id to the full spec union
  • Keep the caller's id type when backfilling an id the agent omitted
  • Exclude bool, which subclasses int, so true never returns as 1

User Flow

Before: a developer whose A2A client numbers its JSON-RPC requests ("id": 42) cannot call any agent through the proxy at all

  1. They register an agent and send POST https://litellm-domain/a2a/my-agent with {"jsonrpc":"2.0","id":42,"method":"message/send","params":{...}}
  2. They get back HTTP 500 and {"error":{"code":-32603,"message":"Internal error: 1 validation error for LiteLLMSendMessageResponse\nid\n Input should be a valid string ..."}}
  3. The agent itself ran fine and answered, so nothing in its access log explains the failure
  4. They retry with "id": "42" and it returns HTTP 200 with the agent's reply
  5. tasks/get with "id": 42 fails the same way, as does "id": 0
  6. Dropping the id entirely does not help either: the agent's reply comes back as the same HTTP 500

After: the same requests succeed, and the id comes back as the value and type they sent

  1. They send the same POST https://litellm-domain/a2a/my-agent with "id": 42
  2. They get HTTP 200 and the agent's reply, carrying "id": 42 as a number
  3. tasks/get with "id": 42 and with "id": 0 both return HTTP 200 with the id unchanged
  4. Omitting the id returns the agent's reply instead of a 500
  5. String ids keep working exactly as before
  6. When an agent answers an error without echoing the id, the reply carries 42 rather than "42", so the client can still match it to its request

Relevant issues

Linear ticket

Resolves LIT-2818

Pre-Submission checklist

  • I have added meaningful tests
  • The handful of test files covering my change pass locally, e.g. uv run pytest tests/test_litellm/<your_test_file>.py -v
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review

Screenshots / Proof of Fix

Live proxy against a stub upstream agent that echoes the caller's JSON-RPC id back verbatim and logs every inbound request. The two string-id cases are controls: they must return a real 200 with the agent's reply on both sides, or the failing cases mean nothing.

Shared setup, identical for both runs:

# a2a_config.yaml
general_settings:
  master_key: sk-a2aint-1234

agents:
  - agent_name: stub-echo-agent
    agent_card_params:
      protocolVersion: "0.3.0"
      name: stub-echo-agent
      url: "http://127.0.0.1:8137/"
      version: "1.0.0"
      capabilities: {streaming: false}
      defaultInputModes: ["text/plain"]
      defaultOutputModes: ["text/plain"]
      skills: []
python litellm/proxy/proxy_cli.py --config a2a_config.yaml --port 4137

Every case below is the same curl with only the id and method changing:

curl -sS -w '\nHTTP %{http_code}\n' -X POST http://127.0.0.1:4137/a2a/stub-echo-agent \
  -H 'Authorization: Bearer sk-a2aint-1234' -H 'Content-Type: application/json' \
  -d '<the body shown under each case>'

Before (fc3b160)

Code under test, litellm/types/agents.py:334, is id: str

message/send, string id (control)

  1. Body {"jsonrpc":"2.0","id":"ctl-1","method":"message/send","params":{"message":{"role":"user","messageId":"m1","parts":[{"kind":"text","text":"hi"}]}}}
  2. Output
{"id":"ctl-1","jsonrpc":"2.0","result":{"kind":"message","messageId":"stub-msg-1","parts":[{"kind":"text","text":"echoed"}],"role":"agent"}}
HTTP 200

message/send, integer id

  1. Body {"jsonrpc":"2.0","id":42,"method":"message/send","params":{"message":{"role":"user","messageId":"m1","parts":[{"kind":"text","text":"hi"}]}}}
  2. Output
{"jsonrpc":"2.0","id":42,"error":{"code":-32603,"message":"Internal error: 1 validation error for LiteLLMSendMessageResponse\nid\n  Input should be a valid string [type=string_type, input_value=42, input_type=int]\n    For further information visit https://errors.pydantic.dev/2.13/v/string_type"}}
HTTP 500

tasks/get, string id (control)

  1. Body {"jsonrpc":"2.0","id":"ctl-2","method":"tasks/get","params":{"id":"stub-task-1"}}
  2. Output
{"id":"ctl-2","jsonrpc":"2.0","result":{"kind":"task","id":"stub-task-1","contextId":"stub-ctx-1","status":{"state":"completed"}}}
HTTP 200

tasks/get, integer id

  1. Body {"jsonrpc":"2.0","id":42,"method":"tasks/get","params":{"id":"stub-task-1"}}
  2. Output
{"jsonrpc":"2.0","id":42,"error":{"code":-32603,"message":"Internal error: 1 validation error for LiteLLMSendMessageResponse\nid\n  Input should be a valid string [type=string_type, input_value=42, input_type=int]\n    For further information visit https://errors.pydantic.dev/2.13/v/string_type"}}
HTTP 500

tasks/get, falsy integer id

  1. Body {"jsonrpc":"2.0","id":0,"method":"tasks/get","params":{"id":"stub-task-1"}}
  2. Output
{"jsonrpc":"2.0","id":0,"error":{"code":-32603,"message":"Internal error: 1 validation error for LiteLLMSendMessageResponse\nid\n  Input should be a valid string [type=string_type, input_value=0, input_type=int]\n    For further information visit https://errors.pydantic.dev/2.13/v/string_type"}}
HTTP 500

tasks/get, id omitted so the agent answers with a null id

  1. Body {"jsonrpc":"2.0","method":"tasks/get","params":{"id":"stub-task-1"}}
  2. Output
{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"Internal error: 1 validation error for LiteLLMSendMessageResponse\nid\n  Input should be a valid string [type=string_type, input_value=None, input_type=NoneType]\n    For further information visit https://errors.pydantic.dev/2.13/v/string_type"}}
HTTP 500

tasks/get, boolean id (not a legal JSON-RPC id)

  1. Body {"jsonrpc":"2.0","id":true,"method":"tasks/get","params":{"id":"stub-task-1"}}
  2. Output
{"jsonrpc":"2.0","id":true,"error":{"code":-32603,"message":"Internal error: 1 validation error for LiteLLMSendMessageResponse\nid\n  Input should be a valid string [type=string_type, input_value=True, input_type=bool]\n    For further information visit https://errors.pydantic.dev/2.13/v/string_type"}}
HTTP 500

upstream access log for the run

  1. The stub logged every inbound request, so the agent was reached and answered on all seven legs; the failures are entirely proxy-side
UPSTREAM GET /.well-known/agent-card.json
UPSTREAM POST / method='message/send' id='ce6ece59-3927-4aeb-b25b-2e8f99c6c35a' id_type=str
UPSTREAM GET /.well-known/agent-card.json
UPSTREAM POST / method='message/send' id='2d49544e-0f82-46bc-b233-6a50a7d17acd' id_type=str
UPSTREAM POST / method='tasks/get' id='ctl-2' id_type=str
UPSTREAM POST / method='tasks/get' id=42 id_type=int
UPSTREAM POST / method='tasks/get' id=0 id_type=int
UPSTREAM POST / method='tasks/get' id=None id_type=NoneType
UPSTREAM POST / method='tasks/get' id=True id_type=bool

After (4affda4)

Code under test, litellm/types/agents.py:347, is id: str | StrictInt | None = None

message/send, string id (control)

  1. Body {"jsonrpc":"2.0","id":"ctl-1","method":"message/send","params":{"message":{"role":"user","messageId":"m1","parts":[{"kind":"text","text":"hi"}]}}}
  2. Output
{"id":"ctl-1","jsonrpc":"2.0","result":{"kind":"message","messageId":"stub-msg-1","parts":[{"kind":"text","text":"echoed"}],"role":"agent"}}
HTTP 200

message/send, integer id

  1. Body {"jsonrpc":"2.0","id":42,"method":"message/send","params":{"message":{"role":"user","messageId":"m1","parts":[{"kind":"text","text":"hi"}]}}}
  2. Output, id returned as a number
{"id":42,"jsonrpc":"2.0","result":{"kind":"message","messageId":"stub-msg-1","parts":[{"kind":"text","text":"echoed"}],"role":"agent"}}
HTTP 200

tasks/get, string id (control)

  1. Body {"jsonrpc":"2.0","id":"ctl-2","method":"tasks/get","params":{"id":"stub-task-1"}}
  2. Output
{"id":"ctl-2","jsonrpc":"2.0","result":{"kind":"task","id":"stub-task-1","contextId":"stub-ctx-1","status":{"state":"completed"}}}
HTTP 200

tasks/get, integer id

  1. Body {"jsonrpc":"2.0","id":42,"method":"tasks/get","params":{"id":"stub-task-1"}}
  2. Output, id returned as a number
{"id":42,"jsonrpc":"2.0","result":{"kind":"task","id":"stub-task-1","contextId":"stub-ctx-1","status":{"state":"completed"}}}
HTTP 200

tasks/get, falsy integer id

  1. Body {"jsonrpc":"2.0","id":0,"method":"tasks/get","params":{"id":"stub-task-1"}}
  2. Output, 0 is preserved rather than read as absent
{"id":0,"jsonrpc":"2.0","result":{"kind":"task","id":"stub-task-1","contextId":"stub-ctx-1","status":{"state":"completed"}}}
HTTP 200

tasks/get, id omitted so the agent answers with a null id

  1. Body {"jsonrpc":"2.0","method":"tasks/get","params":{"id":"stub-task-1"}}
  2. Output, the agent's reply is relayed
{"jsonrpc":"2.0","result":{"kind":"task","id":"stub-task-1","contextId":"stub-ctx-1","status":{"state":"completed"}}}
HTTP 200

tasks/get, boolean id (not a legal JSON-RPC id)

  1. Body {"jsonrpc":"2.0","id":true,"method":"tasks/get","params":{"id":"stub-task-1"}}
  2. Output, stringified rather than relayed as 1, which would collide with a real integer id
{"id":"True","jsonrpc":"2.0","result":{"kind":"task","id":"stub-task-1","contextId":"stub-ctx-1","status":{"state":"completed"}}}
HTTP 200

upstream access log for the run

  1. Same shape as the Before run, confirming the only variable was the response model
UPSTREAM GET /.well-known/agent-card.json
UPSTREAM POST / method='message/send' id='33acb5d6-e696-43d0-822f-cbd4feafe8f1' id_type=str
UPSTREAM GET /.well-known/agent-card.json
UPSTREAM POST / method='message/send' id='565fbe21-b50c-4a8a-b0dc-4009bb0095c1' id_type=str
UPSTREAM POST / method='tasks/get' id='ctl-2' id_type=str
UPSTREAM POST / method='tasks/get' id=42 id_type=int
UPSTREAM POST / method='tasks/get' id=0 id_type=int
UPSTREAM POST / method='tasks/get' id=None id_type=NoneType
UPSTREAM POST / method='tasks/get' id=True id_type=bool

Review notes

Greptile filed the boolean trap twice and was right both times, including against my own reply. bool subclasses int, so widening to accept integers made pydantic coerce a boolean id to 1 or 0, which is worse than the 500 it replaced because 1 collides with a real integer id another in-flight request may be using.

My first correction guarded only _normalize_a2a_jsonrpc_response, and I said in a review reply that the annotation was guarded too. That was wrong: direct construction bypasses normalization, and the follow-up P1 saying so is accurate. The field is now str | StrictInt | None, which accepts exactly string | integer | null and rejects bool, float, list and dict on every path including direct construction. Only the integer half needs strictness, since bool subclasses int and not str.

Getting there without raising a type ceiling took some measuring, which is worth recording. The ceilings in basedpyright-code-budget.json are ratcheted to the exact current count, so any +1 reds lint. StrictStr | StrictInt costs two reportUnknownVariableType (basedpyright cannot resolve either pydantic symbol here) and a field_validator costs one of those plus a reportUntypedFunctionDecorator against a ceiling of 27. str | StrictInt costs exactly one, paid for by giving response_dict in from_a2a_response a real binding instead of an untyped rebind. Net per-rule delta against the merge base is zero on every rule.

Rebased onto fc3b160fb5 to pick up #37671, which added the ruff check --config ruff-tests.toml tests step. That job checks out the PR head rather than the merge ref, so the step ran against a tree predating the config file it names, and no rerun could clear it. Both proof legs above were re-captured after the rebase.

This supersedes #29196, which proposed the same str | int | None widening for LIT-2818 in May. That branch targets litellm_oss_agent_shin_daily_branch, predates _normalize_a2a_jsonrpc_response, and would conflict.

Type

🐛 Bug Fix

Caveats

  • A null id is omitted from the response, not emitted as "id": null
  • That follows the endpoint's existing exclude_none dump, unchanged here
  • Bridge-backed agents still stringify the id; separate, larger change
  • That path narrows request_id: str across six provider packages

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@greptile-apps

greptile-apps Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The response model now accepts the complete JSON-RPC ID union while preventing boolean IDs from being interpreted as integers

  • Uses StrictInt to accept integer IDs without accepting booleans
  • Preserves string, integer, zero, and null IDs through response normalization
  • Adds regression coverage for direct validation, echoed IDs, and backfilled IDs

Confidence Score: 5/5

The PR appears safe to merge

No blocking failure remains; both previously reported boolean-ID paths are fixed at the normalization and direct-validation boundaries

Important Files Changed

Filename Overview
litellm/types/agents.py Widens A2A response IDs to string, strict integer, or null and safely stringifies boolean IDs during normalization
tests/test_litellm/a2a_protocol/test_send_message_response.py Adds focused regression tests covering integer, zero, null, boolean, echoed, backfilled, and direct-construction ID behavior

Reviews (4): Last reviewed commit: "fix(a2a): accept the whole JSON-RPC id u..." | Re-trigger Greptile

Comment thread litellm/types/agents.py Outdated
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@yassin-berriai
yassin-berriai force-pushed the litellm_a2a_accept_integer_jsonrpc_id branch from 64debe2 to 77b78db Compare August 20, 2026 20:49
@yassin-berriai yassin-berriai changed the title fix(a2a): accept the integer JSON-RPC ids the spec allows fix(a2a): accept the whole JSON-RPC id union the spec defines Aug 20, 2026
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai please review the current head 77b78db. It now also fixes null ids (LIT-2818) plus your boolean finding; details in the description.

@yassin-berriai
yassin-berriai force-pushed the litellm_a2a_accept_integer_jsonrpc_id branch from 77b78db to e743498 Compare August 20, 2026 20:58
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai please review the current head e743498. Pure rebase onto staging for the new test-tree lint step; the code diff is unchanged.

Comment thread litellm/types/agents.py Outdated
JSON-RPC 2.0 types `id` as string, integer or null, but
LiteLLMSendMessageResponse annotated it as a bare required `str`. Pydantic v2
dropped v1's int-to-str coercion, so an upstream agent echoing an integer id was
rejected outright, and a null id, which section 5 requires for an error that
cannot be correlated to a request, was rejected too. Both surfaced as -32603 with
a pydantic ValidationError in the message: five distinct 500s on
/a2a/{agent_id}, across message/send and tasks/get.

Everything around the model already handled the full union: the endpoint reads
the id off the body as Any, its helpers are typed `str | int | None`, the error
builder takes `object`, and the streaming path passes the id through untouched.
The response model was the only narrowing left.

Backfilling an id the agent omitted keeps the caller's type too, since JSON-RPC
requires the response id to equal the request id and a caller that sent 7 cannot
correlate a response carrying "7".

`bool` is excluded from the integer half even though it subclasses `int`, so a
boolean id is stringified rather than relayed as 1 or 0, where it would collide
with a real integer id another in-flight request may be using.
@yassin-berriai
yassin-berriai force-pushed the litellm_a2a_accept_integer_jsonrpc_id branch from e743498 to 4affda4 Compare August 20, 2026 21:08
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai please review the current head 4affda4. The boolean P1 is fixed at the annotation via StrictInt, not just in normalization; my prior reply was wrong.

@tin-berri tin-berri 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.

Clean, well-scoped fix (2 files) — widens the A2A response id field to the full JSON-RPC spec union (string | integer | null) instead of requiring str, which was 500ing on integer/null/zero ids despite the agent answering fine. Appreciate the transparency in the review notes about the bool-coercion trap (bool subclasses int, so a naive int widening would silently turn a boolean id into 1/0, colliding with a real in-flight integer id) — caught by Greptile, fixed with StrictInt so bool/float/list/dict are all rejected on every construction path, not just the normalization helper. Proof-of-fix covers all the JSON-RPC id shapes (string, int, 0, omitted/null, bool) against a real upstream stub with access-log confirmation the agent was reached on every case. CI green aside from the long-running benchmarks job. Already has Yucheng's approval too.

@yassin-berriai
yassin-berriai merged commit 996693f into litellm_internal_staging Aug 20, 2026
69 checks passed
@yassin-berriai
yassin-berriai deleted the litellm_a2a_accept_integer_jsonrpc_id branch August 20, 2026 21:41
@codspeed-hq

codspeed-hq Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_a2a_accept_integer_jsonrpc_id (4affda4) with litellm_internal_staging (e07a712)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (fc3b160) during the generation of this report, so e07a712 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

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.

4 participants