This is an automated email from the ASF dual-hosted git repository.

kaxil pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new bcbb825e199 Stop rejecting valid non-ASCII Anthropic batches as too 
large (#74662)
bcbb825e199 is described below

commit bcbb825e19967f86ab850d13fe0020f3bb1013bb
Author: Henry Chen <[email protected]>
AuthorDate: Wed Oct 14 20:15:57 2026 +0800

    Stop rejecting valid non-ASCII Anthropic batches as too large (#74662)
    
    * Cover the Anthropic batch payload-size estimate for non-ASCII prompts
    
    The adapter takes the batch inline, so the bytes that count are the ones
    httpx encodes with ensure_ascii=False as UTF-8. Sizing the payload with
    len(json.dumps(...)) counts \uXXXX escapes instead, inflating CJK input
    2x and non-BMP input 3x, so batches the API would accept are rejected
    locally and the reported byte count in the error is wrong. The openai
    adapter already measures encoded bytes, so the two disagree today.
    
    The expectations are marked strict xfail so the inconsistency is
    recorded without changing behaviour; the fix has to drop the markers.
    
    * Stop rejecting valid non-ASCII Anthropic batches as too large
    
    The adapter hands the requests to the SDK inline, so the bytes that count
    against Anthropic's 256 MB payload limit are the ones httpx puts on the
    wire: ensure_ascii=False, then UTF-8. Sizing the payload with json.dumps
    defaults measured the \uXXXX escape instead, charging a CJK character six
    bytes rather than three and a non-BMP character twelve rather than four.
    A batch the API would have accepted was refused locally, and the byte
    count in the error was up to three times the real one, so the suggested
    remedy was sized against a number that did not exist.
    
    The openai adapter measures the ASCII-escaped form on purpose, because it
    builds the uploaded JSONL file that way itself; only the inline path was
    wrong.
    
    * Measure the Anthropic batch payload the way the SDK serializes it
    
    The size the adapter quotes back is the number a user sizes their
    .expand() split against, so it should be the number that reaches
    Anthropic. Dropping the ASCII escaping got most of the way there, but
    the json.dumps defaults still spend two bytes of whitespace per
    separator, leaving the estimate around 8% above the body the SDK
    actually sends -- a gap independent of the characters involved, so the
    non-ASCII tests could not see it either.
    
    They could not see it for a second reason: the expectation repeated the
    expression under test, which only ever confirms the code agrees with
    itself. Deriving it from the SDK's encoder instead makes the comparison
    independent, and leaves a tripwire if a future SDK serializes a request
    differently.
    
    The serializer is the SDK's own, not httpx's: it dumps the body and
    hands httpx the finished bytes, so the docstrings now point at the
    encoder that decides the size.
    
    * Charge the Anthropic batch limit for the body's framing
    
    The limit this pre-check enforces is charged against the body Anthropic
    receives, and the requests arrive inside one {"requests": [...]} array
    rather than on their own. Summing the requests alone leaves out that
    framing and the comma joining each pair -- around 100 KB at the
    100,000-request maximum -- so a batch in that window passes here and is
    refused by the API, which is the one outcome the pre-check exists to
    prevent. openai.py is already charged its JSONL newline the same way.
    
    Nothing caught this earlier because the default separators over-counted
    by more than the framing omits, so the net estimate stayed above the real
    body until the previous commit made each request exact.
    
    * Cover the running total in the Anthropic payload-size test
    
    One request cannot tell a running total from an overwrite: adding a
    single size to zero reads the same as replacing it, so a loop that
    dropped the accumulation would still pass while the limit it enforces
    only ever saw the last request. The comma between requests is charged
    from the second one on, which with one request means that term never ran
    at all -- deleting it left the test green. Two prompts of unequal length
    exercise both, the way the OpenAI size test already does.
---
 .../airflow/providers/common/ai/batch/anthropic.py | 24 +++++++--
 .../tests/unit/common/ai/batch/test_anthropic.py   | 57 +++++++++++++++++++++-
 2 files changed, 77 insertions(+), 4 deletions(-)

diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/batch/anthropic.py 
b/providers/common/ai/src/airflow/providers/common/ai/batch/anthropic.py
index d928026dddb..5cd53b5735d 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/batch/anthropic.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/batch/anthropic.py
@@ -73,6 +73,21 @@ def _batch_status(batch: Any) -> BatchStatus:
     return _STATUS_MAP.get(batch.processing_status, "in_progress")
 
 
+def _utf8_size(payload: Any) -> int:
+    r"""
+    Measure what one inline ``batches.create`` request costs on the wire, in 
bytes.
+
+    The SDK serializes the batch itself and hands httpx a finished body, so 
its own
+    encoder settles the size (``anthropic/_utils/_json.py``): 
``ensure_ascii=False``,
+    ``separators=(",", ":")``, then UTF-8. The ``json.dumps`` defaults would 
charge a
+    CJK character the six bytes of its ``\uXXXX`` escape rather than the three 
it
+    occupies, plus a byte of whitespace per separator. The ``{"requests": 
[...]}``
+    framing the requests sit in belongs to the batch, not to one request, so 
the
+    caller adds it.
+    """
+    return len(json.dumps(payload, ensure_ascii=False, separators=(",", 
":")).encode())
+
+
 def _build_client(api_key: str | None, base_url: str | None) -> Any:
     try:
         from anthropic import Anthropic
@@ -215,7 +230,7 @@ class AnthropicBatchAdapter(BatchAdapter):
             )
 
         directive = self.build_output_directive(output_spec)
-        directive_bytes = len(json.dumps(directive))
+        directive_bytes = _utf8_size(directive)
         built = self._iter_requests(
             requests,
             model=model,
@@ -225,9 +240,12 @@ class AnthropicBatchAdapter(BatchAdapter):
             max_tokens=max_tokens,
             request_params=request_params,
         )
-        total_bytes = 0
+        # The requests reach Anthropic as one ``{"requests": [...]}`` body, so 
the limit is
+        # charged the framing and the comma joining each pair as well, the way 
``openai.py``
+        # is charged a newline per JSONL line.
+        total_bytes = len('{"requests":[]}')
         for index, item in enumerate(built):
-            total_bytes += len(json.dumps(item))
+            total_bytes += _utf8_size(item) + (1 if index else 0)
             if total_bytes > self.max_payload_bytes:
                 raise LLMBatchLimitExceededError(
                     f"Anthropic batch accepts a payload of at most 
{self.max_payload_bytes} bytes; "
diff --git a/providers/common/ai/tests/unit/common/ai/batch/test_anthropic.py 
b/providers/common/ai/tests/unit/common/ai/batch/test_anthropic.py
index 9470e2ec596..d01f411474a 100644
--- a/providers/common/ai/tests/unit/common/ai/batch/test_anthropic.py
+++ b/providers/common/ai/tests/unit/common/ai/batch/test_anthropic.py
@@ -19,7 +19,7 @@ from __future__ import annotations
 from typing import Any
 
 import pytest
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
 
 from airflow.providers.common.ai.batch.anthropic import AnthropicBatchAdapter
 from airflow.providers.common.ai.batch.base import RawResultItem
@@ -28,6 +28,7 @@ from airflow.providers.common.ai.exceptions import 
LLMBatchLimitExceededError, L
 
 anthropic = pytest.importorskip("anthropic")
 
+from anthropic._utils._json import openapi_dumps  # noqa: E402
 from anthropic.types import Message, TextBlock, ToolUseBlock  # noqa: E402
 from anthropic.types.messages import MessageBatch, 
MessageBatchIndividualResponse  # noqa: E402
 
@@ -266,6 +267,60 @@ class TestSubmitRequestShape:
             )
 
 
+def _inline_payload_bytes(client) -> int:
+    """
+    Return the bytes the SDK's own encoder produces for the whole 
``batches.create`` body.
+
+    Deriving the expectation from the encoder rather than repeating 
``_utf8_size``'s
+    expression keeps it independent of the code under test, and turns these 
tests into
+    a tripwire for an SDK that changes how it serializes a batch.
+    """
+    built = client.messages.batches.created[0]["requests"]
+    return len(openapi_dumps({"requests": built}))
+
+
+class TestPayloadSizeEstimate:
+    @pytest.mark.parametrize(
+        "prompts",
+        [
+            pytest.param(["abc", "abcdef"], id="ascii"),
+            pytest.param(["中文字", "中文字串測試"], id="cjk"),
+            pytest.param(["😀😀😀", "😀😀😀😀😀"], id="non-bmp"),
+        ],
+    )
+    def test_estimate_matches_the_inline_payload_bytes(self, adapter, client, 
str_spec, prompts):
+        # Two requests of unequal length: with one, the running total reads 
the same as an
+        # overwrite, and the comma is never charged at all -- it starts at the 
second request.
+        requests = [{"prompt": prompt} for prompt in prompts]
+        _submit(adapter, requests, str_spec)
+        on_the_wire = _inline_payload_bytes(client)
+
+        adapter.max_payload_bytes = on_the_wire
+        adapter.validate_requests(
+            requests, model="claude-sonnet-5", idempotency_key=KEY, 
output_spec=str_spec
+        )
+
+        adapter.max_payload_bytes = on_the_wire - 1
+        with pytest.raises(LLMBatchLimitExceededError):
+            adapter.validate_requests(
+                requests, model="claude-sonnet-5", idempotency_key=KEY, 
output_spec=str_spec
+            )
+
+    def test_reported_directive_size_is_in_utf8_bytes(self, adapter):
+        class NonAsciiSchema(BaseModel):
+            field: str = Field(description="中" * 100)
+
+        spec = build_output_spec(NonAsciiSchema)
+        directive = adapter.build_output_directive(spec)
+        expected = len(openapi_dumps(directive))
+
+        adapter.max_payload_bytes = 0
+        with pytest.raises(LLMBatchLimitExceededError, match=rf"adds 
~{expected} bytes"):
+            adapter.validate_requests(
+                [{"prompt": "a"}], model="claude-sonnet-5", 
idempotency_key=KEY, output_spec=spec
+            )
+
+
 class TestFindOrphanedBatch:
     def test_always_returns_none(self, adapter):
         assert adapter.find_orphaned_batch("any-key", "any-fp", 
"1970-01-01T00:00:00+00:00") is None

Reply via email to