fix(python): base64-encode raw bytes passed to base64 file input fields
Why
Fields opted into x-speakeasy-base64-input-mode: file accept a pre-encoded base64 str, a PathLike, or a binary stream. Raw bytes, bytearray and memoryview values were passed through encode_base64_file_input unchanged. Pydantic's lax str coercion then decoded them as UTF-8:
- non-UTF-8 payloads (most binary content) failed validation with
string_unicode - ASCII payloads were accepted and sent on the wire unencoded
memoryviewfailed withstring_type
Binary streams whose read() returns a memoryview raised TypeError.
What changed
encode_base64_file_inputbase64-encodes bytes-like values. Paths and binary streams are read into bytes and go through the same encoding step.memoryviewvalues must have a byte-sized item format (B,b,c, with or without a byte-order prefix). Other formats raiseValueError, which pydantic reports as a validation error, so the wire bytes never depend on the host's int size or byte order.bytearrayand contiguousmemoryviewvalues are encoded without an intermediate copy; only non-contiguous views are copied.- In a union that also has a
bytesmember (e.g.Union[Base64EncodedString, bytes, int], generated from aoneOfof a file-modeformat: bytestring, aformat: binarystring and another type),bytesinput resolves to thebytesmember regardless of member order, as onmain. Values encoded from bytes-like input are returned as astrsubclass that pydantic scores as a strict but non-exact match, so an exactbytesmember wins. Base64FileInputincludesbytes,bytearrayandmemoryview, so type checkers accept them in the input TypedDicts.- Review Python SDK regenerated; only
types/base64fileinput.pyand itsgen.lockchecksum change. - New feature test
request-bodies-base64-file-input-raw-bytes(pythonv2 only; skipped on the other targets, which do not implement the extension).
Encoding contract
The input type decides the encoding: str is sent as already-encoded base64, bytes-like values are treated as raw content. Content alone cannot tell the two apart, since short alphanumeric payloads are also valid base64.
[!WARNING]
⚠️ Migration: already-encoded base64 held as bytes (e.g. the result of base64.b64encode(...)) must now be passed as str (value.decode("ascii")). Passing it as bytes encodes it a second time, silently. That input was never part of the declared Union[str, Base64FileInput] type, and the changeset states the migration.
Known limitations: mixed unions
Rare shapes where a base64 file-input member shares a union with a member that also accepts bytes-like input. Pydantic's smart union has three match levels, so the helper cannot rank the base64 member above every other member and below bytes at the same time.
- ⚠️
bytearraywith abytesmember: forUnion[Base64EncodedString, bytes],bytearray(b"abc")is sent through the base64 member as"YWJj"(mainsent"abc"). Thebytesmember only acceptsbytearrayin lax mode, so the base64 member matches first. Exactbytesinput is unaffected. - ⚠️
byteswith anAnymember: for a union generated fromoneOf: [{}, <file-mode string>],bytesinput follows member order:Union[Any, Base64EncodedString]keeps the rawbytes("abc", as onmain),Union[Base64EncodedString, Any]sends"YWJj".str,PathLikeand stream input resolve the same in both orders.
Why not a new extension value
file already means "pass content, the SDK encodes it", and file content is bytes elsewhere in the Python SDK (File.content: Union[bytes, IO[bytes], io.IOBase]). Raw bytes had no working behaviour under file, so keeping it and adding a separate value would leave existing specs on the broken path. A new value would also not avoid the bytes-as-pre-encoded break: SDK users would hit it as soon as a spec author switched values.
Testing
pytest tests/test_request_bodies_additional.py -k base64in the generatedprimarypythonv2 SDK: 5 passed.- SDK round-trips cover non-UTF-8
bytes, ASCIIbytearray(sent encoded), base64-lookingb"YWJj"(sent as"WVdKag==", not passed through), and a stridedmemoryview. - Helper assertions cover contiguous, strided and ctypes (
'<B')memoryview, a stream returningmemoryview, a non-bytememoryviewraisingValueError, and"YWJj"passing through unchanged. - Union assertions cover
bytes,strandintinput againstUnion[Base64EncodedString, bytes, int]in both member orders,PathLikeand stream input againstUnion[Any, Base64EncodedString], and a non-bytememoryviewfalling through toList[int]inUnion[List[int], Base64EncodedString].
- SDK round-trips cover non-UTF-8
- Union resolution checked on pydantic 2.11.2 (the SDK's minimum) through 2.14.3.
mypy,pyright,pylinton the generatedbase64fileinput.py: clean../scripts/build-review-sdk.sh pythonv2: integrity validation passed.prettier --checkon touchedfeatures.tsfiles;go build ./internal/features/.- Full
make test-pythonv2not run; the change is confined to the base64 input helper.
Public-safety check
- This change contains no credentials, customer documents, private repository URLs, private filesystem paths, or unredacted private logs.
- Title, body, comments, and commit messages name no customers or customer-derived identifiers, private paths or trackers, or workflow provenance, and are understandable without private context (
.claude/skills/public-repo-communication/SKILL.md). - Generated fixtures and review SDK changes are public-safe.
- I reviewed
git diff --check.