Releases: stainlu/stainful
v0.6.0 — TypeScript completeness: uploads, webhooks, nested resources
TypeScript completeness pass. v0.5 shipped the TypeScript emitter
with an explicit deferred list — multipart / binary upload, webhook
unwrap, and nested resource directories. This release closes that
list. The same stainless.yml now emits a TypeScript SDK whose
file layout, request shapes, and webhook handling match what
Stainless generated for openai-node, for every API shape the Python
side already supported.
Added — TypeScript
- Nested resource directories. Subresources now emit as
directories matching openai-node's layout — a 3-deep
chat.completions.messageslands at
resources/chat/completions/messages.ts, with per-directory
index.tsre-exports soimport { Chat } from 'pkg/resources/chat'
resolves whether the sibling is a bare file or a subpackage.
Cross-cutting imports are depth-aware (../count derived from
the resource's position in the tree). Drop-in path parity for
anyone migrating an openai-node-shaped import. - Multipart, multi-content, and binary uploads. Three wire
shapes, same IR the Python side uses:- Multi-content auto-detect (JSON ↔ multipart): a single method
(c.skills.create({ files: [...], ... })) sends multipart when
file-like values are present, JSON when they aren't — decided by
the arguments passed, via a TSextractFilesmirroring the
Python runtime. Oracle: openai-node's
maybeMultipartFormRequestOptions. - Raw binary upload:
application/octet-streammethods take a
singlebody: Uploadableand ship the bytes verbatim. UploadablecoversBlob | File | ArrayBuffer | Uint8Array | [filename, content, contentType?];FormDataparts wrap raw
bytes in aBlobso the wire works under both browser fetch and
Node 20+ undici.
- Multi-content auto-detect (JSON ↔ multipart): a single method
- Webhook unwrap (Standard Webhooks).
type: webhook_unwrap
config methods now emit in TypeScript with the same shape as
Python:unwrap(payload, headers, opts?)returns the typed event
union (type WebhooksUnwrapEvent = OrderCreatedEvent | …), and
verifySignature(...)throwsInvalidWebhookSignatureErroron a
bad signature. HMAC-SHA256 over{webhook_id}.{timestamp}.{body},
base64,v1,<sig>header,whsec_prefix base64-decoded —
identical algorithm to the Python side (standardwebhooks.com).
Runtime uses Web Crypto (crypto.subtle.sign) — global in Node
20+, browsers, and edge runtimes — with no@types/node
dependency. Env-var fallback (<BRAND>_WEBHOOK_SECRET) is read via
globalThis.processso the lookup compiles in browser / edge
contexts whereprocessmay not exist.
Improved
- Package description + tooling now reflect both targets. The
PyPI description no longer says "Python SDK generator" — stainful
emits Python and TypeScript from one config.
Verified
- 139 tests green (was 133 at v0.5; +6 new TS regression tests
covering nested dirs, multi-content/binary, and the webhook
scenarios — happy path, both event variants, bad signature, stale
timestamp, missing header,whsec_base64 decode, void
verifySignature). - Every TS feature is runtime-verified, not just tsc-clean: the
webhook smoke signs payloads withnode:cryptoso the wire shape
exactly matches what the SDK expects, then unwraps.
Honest scope boundaries
Still deferred for TypeScript (tracked, not blocking openai-style
workflows): withRawResponse / asResponse (openai-node's
APIPromise<T> design — a Promise subclass returned by every
method, not Python's wrapper-class pattern; a runtime-wide change
deferred to a later release), and MCP server emit from the TS path.
Python remains at feature-parity with what Stainless generated for
openai-python.
v0.5.0 — TypeScript ships; Python gap list closed
TypeScript ships. One stainless.yml now emits a working SDK in
both Python and TypeScript. The TS slice covers everything that
makes a Stainless-generated openai-node SDK actually usable — typed
errors, retries with backoff + jitter + idempotency, SSE streaming
with narrowed overloads, and CursorPage<T> pagination that walks
all pages via for await — all runtime-verified against mock
fetch + mock SSE + mock paginated responses. Plus the Python side
closes the last documented gap (multi-content request bodies with
JSON ↔ multipart auto-detect) and ships docs / MCP polish.
Added — TypeScript
- TypeScript emitter (
stainful generate-ts). Same IR as the
Python emitter — only the renderer differs. Generates a SDK whose
symbol surface matches openai-node (<Brand>client class
extendingBaseClient,<Name>ResourceextendingAPIResource,
typed error hierarchy with the same names as the Python side,
package.json + tsconfig.json so usersnpm install && tsc).
Property names that aren't valid TS identifiers (e.g.
"git.branch") are quoted in interfaces. Methods camelCase per TS
convention. tsc strict mode passes on OneBusAway + chat fixture +
the full openai-openapi spec (162 paths, 983 schemas). - SSE streaming with narrowed overloads. Methods with a
streaming:config block emit two overloads
(stream?: false→Promise<Response>;stream: true→
Promise<Stream<Event>>) so users get perfect narrowing at the
call site:
const stream = await client.chat.completions.create({
model: 'gpt-4', stream: true, messages: [...]
});
for await (const event of stream) { ... }
RuntimeStream<T>(_core/streaming.ts) parses
data: <json>\n\nblocks, stops at[DONE], tolerates non-JSON
keepalive/comment frames, has.abort()to close mid-stream. CursorPage<T>pagination. Paginated methods return
Promise<CursorPage<Item>>; the page object is an
AsyncIterable<T>that walks subsequent pages on demand:
const page = await client.things.list({ limit: 50 });
for await (const thing of page) { ... } // auto-pages
One generic algorithm covers every forward-only cursor variant
(after=<last_id>,page_token=<next_page>, …) — the wire
param + cursor response field come from the same stainless.yml
pagination[].request/.responseblocks the Python side uses.- TypeScript runtime, vendored as
_core/. Fetch-based
BaseClientwith retries (backoff + jitter +Retry-After+
idempotency-key on retried writes), full typed error hierarchy
(APIError,RateLimitError,NotFoundError, …), abort signal /
per-request timeout, dependency-injectablefetchfor tests, full
ESM/CJS compatible. No npm deps in the generated runtime. - Runtime smoke tests (Node-skipped if not available): happy
GET / 404→NotFoundError/ 429→RateLimitErrorafter retries /
SSEfor awaityields 3 events / paginationfor awaitwalks
2 pages with the cursor wire param. The TS SDK is no longer "type-
checked" — it's runtime-verified.
Added — Python (closes the last migration-guide gap)
- Multi-content request bodies (JSON ↔ multipart auto-detect).
Oracle: openai-python'sskills.create(files=…)— a single method
whose runtime extracts file-like values from the body; if present
→ multipart wire request; else → JSON. Samec.x.create(...)
call shape both ways, decided by the arguments the user actually
passes. The migration guide gap list is now empty (modulo
JSON-OR-form-urlencoded same-op, which has no auto-detect possible
and remains deferred).
Improved
- Docs generator polish. Per-resource
Types:import blocks
(matches openai-python's api.md format) listing the named models
the resource's methods reference. Method.docs now populated from
the OpenAPI op'sdescription(falling back tosummary) — so
generated Python SDK methods get real docstrings, and MCP tool
descriptions are spec-sourced rather than synthesized. - MCP generator polish. Streaming methods (
chat.completions .create) now lock to the non-streaming wire shape — thestream
discriminator is removed from the input schema (LLM clients can't
toggle it) and the tool description notes "(stainful MCP: tool
always uses the non-streaming wire shape; the full response is
returned in one TextContent.)". Avoids ambiguous "what does
stream=True do here?" tool calls.
Verified
- 133 tests green (was 118 at v0.4; 15 new regression tests
including the TS runtime smokes). - mypy 0 on 253 combined source files.
- 10 consecutive green CI pushes since v0.4.
Honest scope boundaries
TypeScript v0.1 ships streaming + pagination + retries + typed
errors. Still deferred (tracked, not blocking openai-style
workflows): multipart / binary upload, webhook unwrap,
nested resource directories (resources/chat/completions/ messages.ts shape), MCP server emit from TS. Python is at
feature-parity with what Stainless was generating for openai-python.
v0.4.0 — SDK + docs + MCP server from one stainless.yml
Three new generators + every documented adoption-blocker closed.
One stainless.yml now produces a Python SDK + Mintlify-shaped
api.md + a Model Context Protocol server — all OSS, all drop-in for
Stainless. Migration guide gap list is empty save multi-content
request bodies (no public Stainless oracle to verify against).
Added — three new artifacts from the same spec
stainful docs— Stainless-shaped Markdown API doc. Per-resource
sections (# Top,## Subresource,### Sub-sub), Methods lists
with<code title="<verb> <path>">…</code>linked into the
generated SDK tree, Shared Types block, paginated returns rendered
asSyncCursorPage[Item]with the item type resolved through
ModelRef → ObjectType, webhook-unwrap entries deliberately
without verb/path. Mintlify-compatible. Oracle:
tests/oracles/openai-python/api.md.stainful mcp— Model Context Protocol server. Each HTTP method
becomes one MCP tool (<resource_chain>_<method>), input schema is
faithful JSON Schema (path params + query params + body object
expanded), response serialized asTextContent(pydantic model →
JSON, bytes → base64). Lazy client construction so the module
imports cleanly before the env var is set. Stdio transport (Claude
Desktop / Cline / mcp-cli compatible). Webhook-unwrap excluded —
not a remote call.- Rich
APIResponse[T]viawith_raw_response.*. Was a
pass-through pre-v0.4; now a real wrapper exposing.http_response,
.status_code,.headers,.request_id,.content,.parse() -> T. ContextVar-based wrapper so concurrent async tasks don't
pollute each other.<Brand>APIResponsealias at the package root.
Added — Stainless drop-in symbol surface
custom_casings:honored. Both dict-style (acme_ai_sdk: AcmeAISDK) and list-of-singletons forms parse into
Config.custom_casings. Whole-name and per-token overrides in
brand()/pascal(). Was the #1 adoption blocker at v0.3.0 —
anyone whose brand wasn't in our 4-entry compound table emitted
the wrong class name (AcmeAiinstead ofAcmeAI).targets.python.package_namehonored. Was silently dropped —
package directory was always derived fromorganization.name
(name: acme-ai-sdk→acme_ai/even when user set
package_name: acme). Real adoption blocker..to_json()/.to_dict()aliases on every generated model
(route to pydantic v2'smodel_dump_json/model_dumpwith
Stainless defaultsby_alias=True, exclude_unset=True).- Webhook
secret=env-var fallback. Generated
client.webhooks.unwrap(...)/verify_signature(...)reads
<BRAND_UPPER>_WEBHOOK_SECRETwhen no explicit secret is passed
(matches openai-python'sOPENAI_WEBHOOK_SECRET). - Typed error-body model auto-promoted to
<pkg>.types.shared.ErrorObject. Scans the spec's 4XX/default
response refs; picks the most common; unwraps single-field
{error: $ref X}wrappers; prefers error-shaped candidates
(message+ at least one ofcode/type/param). Real openai
spec:Error(code, message, param, type)— identical to
openai-python'sopenai.types.shared.ErrorObject. - Alternate page class names. Spec-specific page-class symbols
(SyncTokenPage,SyncNextCursorPage,SyncConversationCursorPage,
SyncPageCursor, …) emitted as aliases in<pkg>/_core/pagination .pysofrom <pkg>.pagination import <Specific>Pageresolves.
All five forward-only cursor variants across openai+anthropic
alias to one config-driven generic class. - Anthropic-shape bi-directional pagination. Distinct algorithm
(before_id↔after_idbased on initial-request direction).
New runtime class_BiDirectionalPage; IR detects the shape via
request:/response:patterns;pagination_cfgcarries the
four wire names.
Fixed
$shared.modelsref normalization. Real configs use either
bare component name (references: Reference) or full $ref
(acme_widget_id_string: "#/components/schemas/..."); we
normalize both.- Resource template pagination-import regex widened from
Sync(Cursor)?Pageto anySync<...>Pageso spec-specific
aliases import correctly.
Verified
- 118 tests green (was 95 at v0.3.0 — 23 new regression tests
for the gap-closure work). - mypy 0 on 253 combined source files (generated openai SDK +
generated OneBusAway dogfood + emitter + runtime). - Drop-in import parity with openai-python: full resource tree,
<pkg>.types.shared.ErrorObject,<pkg>.APIResponse,
<pkg>.types.ErrorObject, generic-with-alias page classes. - 9 quiet pushes since v0.3.0, all CI-green on py3.10–3.12.
Honest scope boundaries
Multi-content request bodies (an op declaring application/json
OR multipart/form-data OR application/x-www-form-urlencoded on
the same operation) still pick the first match — no public
Stainless oracle to verify the exact API surface design.
Documented in the migration guide.
v0.3.0 — real-world test surfaces 9 fixes + nested resource layout
Nine fixes surfaced by generating a working SDK from the real
openai-openapi spec (162 paths, 983 schemas) — each oracle-verified
against the actual Stainless-generated openai-python SDK. Plus the
nested resource directory layout that matches openai-python's
structure (resources/chat/completions/messages.py etc.) — drop-in
import paths.
Fixed
- Silent leaf-name collision (correctness bug). With the previous
flatresources/*.pylayout, two subresources sharing a leaf name
(e.g.chat.completions.messagesANDthreads.messages) wrote to
the same file; last-wins meantc.chat.completions.messages.list()
would silently call/threads/{thread_id}/messages(wrong
endpoint). Now: nested directories matchingopenai-python
(resources/chat/completions/messages.py,
resources/threads/messages.py). Cross-cutting imports inside
resource files switched from..to absolute<pkg>._core.Xso the
same template works at any depth. New regression test
(test_subresource_with_shared_leaf_name_calls_right_endpoint). - Discriminated union with an
Anyvariant (real openai spec
pattern:oneOf: [{type:object, properties:{type,...}}, {}]where
the empty branch lowers toAnyType). Pydantic rejectsobjectas
a discriminated-union variant at import time. Now: drop the AnyType
variants from a discriminated union before applying the
discriminator. - Field-name shadows Python builtin used as a type annotation.
Real example:ConversationResourcehas
object: Literal['conversation']ANDmetadata: object. Mypy's
class-scope name resolution finds the field, not the builtin →
error. Now: when a field is named after a Python builtin AND that
name is used as a type annotation by another field, the
builtin-named field is reordered to come AFTER any such use (within
the same required-vs-optional cohort, so pydantic field ordering
stays valid). Oracle:openai-python'sConversationlays out
id, metadata, object, ...for exactly this reason. allOfmerger was too strict at spec scale. Rejected
real-worldallOfbranches that differed only in (a) cosmetic
fields (description / title / example), (b)$refvs inlined-same-
schema, (c) enum membership where Stainless takes the union
(oracle:openai-pythonemitsLiteral["completed", "incomplete", "failed", "in_progress"]— the union of two branches), or (d) a
T-or-nullvsTlattice (oracle:openai-pythonemits
Optional[int]when one branch is nullable and the other isn't).
Now: cosmetic-strip + one-hop$refresolve + enum-union +
nullable-lattice; remaining structural divergence falls through to
lenient last-wins. Honest trade-off: strict raise was right for
in-repo fixtures, wrong at real-spec scale.cast_to: type | Nonewas too narrow. The
Annotated[Union[V0, V1], Field(discriminator="task")]shape for a
discriminated-union response (real example: openai
audio.transcriptions.create) failed mypy AND silently fell
through to raw dict at runtime. Now: signature widened toAny;
_process_response_dataswitched to a TypeAdapter-first pattern
(trypydantic.TypeAdapter(cast_to), fall back to raw data only on
PydanticSchemaGenerationError) — passes mypy, validates discriminated
unions, andbytes/Noneshort-circuits still work.
Added
- Real-world test:
examples/openai/— the public Stainless-
processedopenai-openapispec (162 paths, 983 schemas, OpenAPI
3.1) + a hand-writtenstainless.ymlcovering ~22 resources (chat,
embeddings, files, audio, fine_tuning, models, moderations, images,
batches, assistants, uploads, vector_stores, conversations,
threads, …). Generates a mypy-clean 172-file SDK that exercises
every capability stainful supports: streaming, JSON, multipart,
binary download, raw binary upload, cursor pagination,
discriminated-union responses, nested subresources, webhook unwrap.
Generated SDK is gitignored; spec committed (content-pinned via the
Stainless CDN SHA in the source URL). - Pydantic-reserved field-name mangling. Field names that clash
withpydantic.BaseModelattributes (schema,copy,dict,
json,model_dump, ...) are now mangled to<name>_and aliased
viaField(alias="<wire>")— same pattern as the existing
Python-keyword mangle. Oracle:openai-pythonemits
schema_: Optional[Dict[str, object]] = FieldInfo(alias="schema", default=None). - Compound brand name override.
openai→OpenAI(not
Openai),openapi→OpenAPI,anthropic→Anthropic,
cloudflare→Cloudflare. Hardcoded table (no risky auto-split
on initialism suffixes — would over-match real words likechai,
tai). Stainless'scustom_casingsblock is the long-term answer
(v1.1 backlog). - Adoption polish. New
docs/migrating-from-stainless.md:
step-by-step migration guide for Stainless customers (after the
Anthropic acquisition wind-down), exhaustive symbol-compat table,
honest gap list with workarounds. README leads with a migration
callout; OpenAI proof point in the Status section. Quickstart
trimmed to the essential install + generate; contributor commands
moved out of the user flow.
Verified
- 84 tests green (was 77 at v0.2.0; 7 new regression tests for
the cross-spec bugs). - mypy 0 on 250 combined source files (172 generated openai SDK +
92 dogfood + 9 emitter). - Drop-in import parity:
from openai.resources.chat.completions import CompletionsResourceresolves against stainful's output. - No regressions on the OneBusAway dogfood: 29/29 of Stainless's
own test files still import unchanged.
v0.2.0 — seven product-quality fixes
Seven product-quality fixes verified vs the real Stainless SDKs (openai-
python / anthropic-sdk-python / onebusaway-python-sdk) — each its own
CI-gated commit. Quality bar: resource-method recall 1.00,
method-signature match 1.00, model-name recall 0.95, generated SDK
mypy-clean, 29/29 of Stainless's own OneBusAway test files import
unchanged, end-to-end behavioral conformance for streaming + cursor
pagination + multipart + binary download + binary upload + webhook
unwrap. Drop-in for stainless.yml; not affiliated with Stainless or
Anthropic.
Added
- Webhook unwrap with typed discriminated event union (Standard Webhooks
scheme). Astainless.ymlmethod withtype: webhook_unwrapnow
generatesclient.webhooks.unwrap(payload, headers, *, secret, tolerance=300)returning the typed event variant (not just a parsed
dict) — pydantic-discriminatedAnnotated[Union[…], PropertyInfo( discriminator=…)]built from the configevent_types:block. Sibling
client.webhooks.verify_signature(...)for verify-without-parse.
Signature scheme = Standard Webhooks (standardwebhooks.com): HMAC-
SHA256 overf"{webhook_id}.{timestamp}.{body}", base64, multi-v1,<sig>
header,whsec_base64-decoded secret, constant-time compare, ±tolerance
replay window. Algorithm verified against the openai-python oracle
(resources/webhooks/webhooks.py); the verification helper is vendored
once into<pkg>/_core/_webhooks.py(single source). New
InvalidWebhookSignatureErroris exported from the package root for
drop-inexceptcompatibility. Previously the emitter ignored
type: webhook_unwrapand would have generated a brokenself._post( f"")(latent: no fixture triggered it). IR change:event_types$refs
are now resolved into IRModelRefs so the emitter can render the typed
union. Newtests/fixtures/webhooks/+ 10-casetests/test_webhook_unwrap.py
(typed variant discrimination,whsec_-decode, bad sig, stale timestamp,
missing header, root-symbol export). Known scope boundary: the
secret=kwarg is required (no client-levelwebhook_secret/env-var
fallback yet). - Raw binary request bodies (
application/octet-streamuploads).
S3-stylePUT object, GitHub release-asset upload, and similar raw
uploads now work end-to-end: the emitter generatesbody: bytes,
the runtime sends it verbatim with
Content-Type: application/octet-stream, sync + async. Fixes a
latent codegen flaw — previously the emitter wrapped the bytes in
_body = {"None": body}and calledto_jsonableon it; no fixture
or dogfood exercised the path, so nothing in the wild was hitting
it. Newtests/fixtures/upload_binary/+tests/test_binary_upload.py
asserts the wire body is the exact bytes and the content-type is
octet-stream. Known scope boundary: endpoints declaring multiple
request content types on the same op (JSON OR multipart OR octet-
stream on the same op) still pick the first match; the
alternative-content case has no public Stainless oracle and is
deferred — separate work. - Binary responses. Non-JSON 200s (audio/, octet-stream, image/, …) used to get no type and be JSON-parsed (mangled). Now download endpoints (e.g. OpenAI
audio.speech) return rawbytes, sync + async. Fixture + conformance test; mypy-clean. - Multipart / file upload (RESEARCH §4 #10).
multipart/form-data
bodies were emitted as one opaque param and sent as JSON (broken for
real uploads, e.g. OpenAI files/audio). Now: fields are expanded, binary
fields typedFileTypes, and the runtime sends real multipart (file in
files, scalars indata), sync + async. New fixture + conformance
test; generated SDK stays mypy-clean. - Auto-pagination (RESEARCH §4 #1). Paginated
listendpoints now
generateSync/AsyncCursorPage[Item]returns;for x in client.x.list():
(andasync for) transparently walks every page over the vendored
runtime. Was entirely missing (the emitter ignoredpaginated:). New
conformance fixture + sync/async behavioral test. Generated SDK stays
mypy-clean.
Fixed
- Cursor pagination wire param is now config-driven. Previously the
runtime hard-coded?cursor=<id>on next-page requests — silently broken
against any real openai-/anthropic-style API expecting?after=<last_id>
(the actual StainlessSyncCursorPageshape). Now the IR derives the
cursor param name and the response cursor field from the stainless config
request:/response:blocks; the emitter passes them through; the
runtime uses them with safe defaults (after, thennext_cursor, then
last-itemid). One generic_CursorPagenow covers the five forward-
only cursor variants in the openai+anthropic oracles (SyncCursorPage,
SyncConversationCursorPage,SyncNextCursorPage,SyncTokenPage,
SyncPageCursor) — same algorithm, different wire names. New fixture
(tests/fixtures/paginated_after/) + conformance test asserting the
wire request actually says?after=2from alast_idresponse;
originalcursorfixture still passes. Known scope boundary:
anthropic's bi-directionalSyncPage(choosesbefore_idvsafter_id
from the initial request) is still forward-only here — separate algorithm,
separate work.
Improved (model fidelity vs the real Stainless SDK)
- Resource→type-name prefix now singularizes the last word
(trip-details→TripDetailRetrieveResponse;client.chat.completions
→CompletionCreateResponse, like OpenAI'sChatCompletion) while the
resource class stays plural. Narrowly scoped —agencies_with_coverage
et al. unchanged. - Result: model-name recall 0.90 → 0.95, method-signature match
0.99 → 1.00, and 29/29 (100%) of Stainless's own test files now
import unchanged against stainful's output (was 28/29).
v0.1.0 — the open-source Stainless
Point your existing stainless.yml at it and get an idiomatic Python SDK. Open source, runs locally, no SaaS.
Stainless's hosted SDK generator is being wound down (Anthropic acquisition). stainful is a continuation path: it reads your existing stainless.yml + OpenAPI spec, unchanged, and emits an idiomatic Python SDK — typed models, typed errors, retries, auto-pagination, streaming, sync+async.
Measured vs the real Stainless-generated OneBusAway SDK (CI-gated, no-regression)
- resource-method recall 1.00, method-signature match 0.99
- model-name recall 0.90, generated code mypy-clean
- 28/29 of Stainless's own test files import unchanged against stainful's output
- regeneration is bit-stable; the repo dogfoods itself
Install
pip install stainful (once published to PyPI)
Honest status
Alpha. Python only. Model-name long tail (~10%) and P3 behavioral-via-Prism remain (see CHANGELOG / QUALITY notes). Not affiliated with Stainless or Anthropic.