Skip to content

fix(stt): send convert form fields unencoded (#819) - #823

Closed
kraenhansen wants to merge 2 commits into
mainfrom
cursor/fix-stt-keyterms-multipart-fa07
Closed

kraenhansen wants to merge 2 commits into
mainfrom
cursor/fix-stt-keyterms-multipart-fa07

Conversation

@kraenhansen

@kraenhansen kraenhansen commented Jul 27, 2026 •

Copy link
Copy Markdown
Member

Fixes #819.

Problem

The SDK regeneration (#817) regeneration changed four multipart fields of POST /v1/speech-to-text from being passed through as-is to being wrapped in json.dumps(jsonable_encoder(...)):

"webhook_metadata": json.dumps(jsonable_encoder(webhook_metadata)),
"entity_detection": json.dumps(jsonable_encoder(entity_detection)),
"entity_redaction": json.dumps(jsonable_encoder(entity_redaction)),
"keyterms": json.dumps(jsonable_encoder(keyterms)),

That has three consequences on the wire, all of which are visible when dumping the multipart body of client.speech_to_text.convert(file=..., model_id="scribe_v2", keyterms=["hello", "world"]) on main:

Content-Disposition: form-data; name="webhook_metadata"

null
Content-Disposition: form-data; name="entity_detection"

null
Content-Disposition: form-data; name="entity_redaction"

null
Content-Disposition: form-data; name="keyterms"

["hello", "world"]
  1. A list is sent as one field containing the whole JSON array instead of one field per item, so the API validates ["hello", "world"] as a single keyterm and returns invalid_keyword_length — the reported bug.
  2. Omitted fields are sent as the literal string null, because jsonable_encoder maps the OMIT sentinel to None and json.dumps(None) is "null", which is neither OMIT nor None and therefore survives the request body filtering.
  3. A plain string such as entity_detection="pii" is sent quoted, as "pii".

Fix

The generated files can't be edited, so the decoding happens in the hand-maintained speech_to_text_custom.py. Both speech-to-text clients now point their raw client at a delegating client wrapper whose HTTP client decodes the affected fields just before the request is sent:

  • a JSON array becomes a Python list, which httpx expands into repeated form fields
  • a JSON string becomes the plain string
  • null causes the field to be dropped
  • a JSON object (webhook_metadata) stays JSON encoded, which is what the API expects

Everything else on the client wrapper and HTTP client is delegated untouched, and only the speech-to-text raw client is affected — the shared client wrapper instance is not mutated, so no other resource sees the interception.

With the fix, the same call sends:

Content-Disposition: form-data; name="keyterms"

hello
Content-Disposition: form-data; name="keyterms"

world

Tests

tests/test_stt_multipart.py captures the multipart body through an httpx.MockTransport and asserts the encoding of each affected field for the sync, async and with_raw_response paths, plus a case pinning that unrelated fields are unchanged. Nine of the ten tests fail on main and all pass with the fix.

Relationship to the upstream generator fix

fern-api/fern#17226 fixes this in the generator, but it does not make this wrapper redundant on its own:

  • It only covers body properties typed as a list/set of primitives, so it fixes keyterms (Optional[List[str]]) but not entity_detection and entity_redaction (Union[str, List[str]]) or webhook_metadata (Union[str, Dict[str, Any]]), which are unions rather than lists and keep the json.dumps path, so a plain string stays quoted.
  • The separate "null" fix (the is not OMIT else OMIT guard) shipped in generator 5.0.4, and .fern/metadata.json pins fernapi/fern-python-sdk to 4.64.1 — the last 4.x release, published two days before 5.0.4. Picking up either fix requires bumping the generator to 5.x.

Decoding only touches string values, so each field stops being rewritten as soon as the generator stops encoding it: after the generator upgrade keyterms arrives as a list and omitted fields arrive as OMIT, neither of which is a string. The wrapper can be deleted once all four fields are generated correctly.

Not covered

dubbing.project.create(keyterms=...), music.compose(tags=...), voices.ivc.create(labels=...), studio.projects.create(genres=...) and audio_native.create(...) got the same json.dumps treatment in the same regeneration. Those resources have no hand-maintained wrapper to hook into, so they are left for the generator upgrade.

Open in Web Open in Cursor 

cursoragent and others added 2 commits July 27, 2026 23:07
The generated raw client started passing keyterms, entity_detection,
entity_redaction and webhook_metadata through json.dumps, so a list of
keyterms reached the API as a single field holding the whole JSON array
and was rejected by the 50 character per-keyterm limit. Omitted fields
were sent as the literal string "null" and plain strings arrived quoted.

Decode those fields in the hand-maintained speech-to-text wrapper before
the request is sent, so lists become repeated form fields, strings are
sent verbatim, omitted fields are dropped, and objects stay JSON encoded.

Co-authored-by: Kræn Hansen <mail@kraenhansen.dk>
…s encoding

Co-authored-by: Kræn Hansen <mail@kraenhansen.dk>
@kraenhansen

Copy link
Copy Markdown
Member Author

Closing and waiting for an upstream fix by the Fern team 🤞

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.

keyterms parameter causes invalid_keyword_length error in v2.59.0 due to json.dumps() serialization regression

2 participants