OpenMeter API Spec & SDK Generator
This workspace holds the TypeSpec API definitions and SDK generators. For
repo-wide guidance see the root AGENTS.md; this
file covers only what is specific to api/spec.
Layout
packages/
aip/ # AIP TypeSpec source (api definitions, linter rules)
legacy/ # legacy OpenAPI output
typespec-typescript/ # the SDK generator (TypeSpec emitter, Alloy-based)
typespec-go/ # the Go SDK generator (TypeSpec emitter, Alloy-based)
aip-client-javascript/ # generator OUTPUT: the emitted TypeScript SDK
The runtime templates (the fixed SDK runtime files + conformance tests the
generator reproduces verbatim) live as real, reviewable files under
typespec-typescript/templates/ — not embedded blobs and not a separate
baseline directory. typespec-typescript/src/runtime-templates.ts reads them
via readFileSync at build time and emits them into the generated SDK. To
edit the runtime templates or tests, edit the files under templates/
directly, then run make -C api/spec generate.
typespec-typescriptis a TypeSpec emitter built on@alloy-js+@typespec/emitter-framework. It walks HTTP operations and emits the full SDK.aip-client-javascriptis its output directory (emitter-output-dirinpackages/aip/tspconfig.yamlpoints here). Everything it contains is regenerable — never hand-edit it. A singlegenerateemits the complete SDK (schemas, runtime, per-namespace surface, barrel) plus the conformance tests.typespec-gois a TypeSpec emitter built on@alloy-js/go+@typespec/emitter-framework. It emits the Go SDK intoapi/v3/client, which is also fully regenerable generated output.
How the emitter is structured
emitter.tsx—$onEmit: emitsschemas.ts(Alloy components, the original path), the static runtime files, and the per-namespace surface files, all as sibling<ts.SourceFile>children of one<Output>.runtime-templates.ts— reads the fixed runtime files (core.ts,lib/*,models/errors.ts) and the conformance tests verbatim, viareadFileSync, from the committedtemplates/runtime/andtemplates/tests/directories at build time. Edit those files directly to change the runtime or tests;templates/is excluded from this package's owntsconfig.jsoninclude(only checked downstream, as part of the generatedaip-client-javascriptpackage's typecheck/test suite).- New runtime helpers that don't fit the fixed
templates/set (e.g.lib/wire.ts) are authored as a real.tsfile undersrc/runtime/instead (type-checked and unit-tested by the emitter package's own tooling) and embedded verbatim viareadFileSyncat build time (seesrc/wire-runtime.ts), not as a template-string constant — backticks/${inside the runtime source collide with the template-literal delimiters. sdk-operations.ts— operation discovery: namespace grouping, per-op metadata (path/query/body/response), and naming (func name, facade method name via resource-noun stripping, namespace names).pagination.ts— structural detection of page-number vs cursor list operations (see "Pagination companions" below).sdk-files.ts— string generators for the spec-derived surface files (operations types, funcs, facades, root client, barrels).readme.ts— builds the packageREADME.md(emitted at the package root, not undersrc/) from the same groupedSdkOperation[]the SDK files use, so its documented call paths and routes always match the emitted client.
Grouping (reproduce this)
The OpenMeter service surfaces every operation through an *Endpoints
interface that extends the resource's interface in its source namespace
(e.g. OpenMeter.PlansEndpoints extends ProductCatalog.PlanOperations). The op
walked lives on the *Endpoints interface, so its own namespace is
OpenMeter — the meaningful grouping is on op.interface.sourceInterfaces[0].
Group by the top of the source namespace chain so multi-interface
namespaces stay one client: MetersEndpoints + MetersQueryEndpoints →
meters (keeps meters.query); all Customer*Endpoints → customers.
ProductCatalog is the exception (in SPLIT_BY_INTERFACE): it splits by source
interface → plans, addons, planAddons. Do NOT group by @tag (the tag is
a display string like "Metering Events" → stutter) or by op.namespace (always
OpenMeter).
Nested sub-clients (reproduce this)
The SDK nests sub-clients (customers.charges.list(),
customers.credits.grants.list()) from the source namespace chain below the
group's top namespace. Walking sourceInterfaces[0].namespace up to the global
root yields e.g. ['Customers', 'Charges'] → group customers, nest path
['charges']; ['Customers', 'Credits', 'Grants'] → customers.credits.grants.
facadeFile builds a tree from these paths, emitting one class per node with
lazy sub-client getters that share the parent's Client.
Because grouping follows the source namespace (not the route), an operation
routed under one resource but defined in another's namespace lands under the
latter — by design. list-customer-entitlement-access is routed under
/customers/ but its interface lives in Entitlements, so it is
entitlements.listCustomerAccess(), NOT customers.entitlements.*. This is a
deliberate decision (the op is genuinely an Entitlements operation); do not
"fix" it to nest under customers.
This nesting is driven by the TypeSpec source structure, not API routes — to
nest a resource, wrap its *Operations interface in a sub-namespace
(namespace Charges { interface CustomerChargesOperations { … } } inside a file
that declares namespace Customers;), and update the extends reference in
both openmeter.tsp and konnect.tsp to the nested path. Wrap ONLY the
operation interface — leave models in the parent namespace so their schema names
(and OpenAPI output) are unchanged. The method-name strip set includes the nest
segments, so create-credit-grant under credits.grants → create.
OpenAPI invariance is the hard gate for any .tsp change: regenerate and
confirm the output/definitions/.../*.yaml hashes are unchanged. Namespace
nesting of operation interfaces is OpenAPI-neutral (paths/tags/operationIds are
explicit); moving a model is not. Watch for namespace collisions — nesting
Customers.Billing shadows the global Billing namespace for unqualified refs
in Customers-scoped files; alias around it (Common.BillingRoot) rather than
renaming.
Naming rules (reproduce these)
- func name = full camelCase operationId:
get-meter→getMeter. - facade method = operationId with the group's resource noun(s) and the
cross-cutting
meteringqualifier stripped:get-meter→get;get-customer-billing→getBilling;ingest-metering-events→ingest. Singular/plural folded. The resource name is split into strip-words on separators and case boundaries — both camelCase (PlanAddons→plan,addons) and acronym→word (LLMCost→llm,cost) — so multi-word and acronym-prefixed namespaces strip fully (create-plan-addon→create,create-llm-cost-override→createOverride). When the operationId noun is not the namespace's own resource word it is kept as a disambiguator (llmCost.listPrices/listOverrides,subscriptions.listAddons). - namespace = source namespace (already plural, e.g.
Meters,Events,Customers) or a pluralized split interface resource (Plan→Plans); PascalCase class / camelCase getter. - request type composed from direct-TS parts (no
z.input): query-only →<Base>Query; body-only → the body interface (its…Inputvariant when the body diverges on input); path-only →{ id: string }; path+body →{ id; body }; path+query →<Base>Query & { id }; body+query →{ body } & <Base>Query(body nested so query fields don't leak into the JSON body). Path params are ULIDs, typedstring. See "Request types" below.
TypeSpec style constraints
- When adding query decorators (for example
@query) to a TypeSpec file that does not already use HTTP decorators, import@typespec/httpand addusing TypeSpec.Http;in that file; otherwise compilation fails withUnknown decorator @query.
Commands
| Task | Command |
|---|---|
| Build all TypeSpec emitters | pnpm run build |
| Regenerate SDK from TypeSpec | pnpm --filter @openmeter/api-spec-aip run generate |
| Run the SDK conformance tests | pnpm run test:sdk |
| Install / refresh lockfile | pnpm install --config.confirmModulesPurge=false |
The emitters are bound by package name (@openmeter/typespec-typescript,
@openmeter/typespec-go) in packages/aip/tspconfig.yaml (both the emit: list
and the options: keys). The internal lib names in each src/lib.ts and their
…: state keys are separate identities used for diagnostics/state and have no
cross-package references.
The emitted SDK: conventions the generator must reproduce
The hand-written runtime files and conformance tests under
typespec-typescript/templates/ define the exact shape the generator must
reproduce. The tests are the conformance target — the generated SDK is "done"
when it passes them.
Casing: camelCase public surface, snake_case wire
The AIP API is snake_case on the wire (TypeSpec, OpenAPI, and the casing lint
rule stay snake). The generated JS SDK exposes a camelCase public surface — the
TS interfaces and zod schemas are camelCase — and a boundary mapper
(src/lib/wire.ts) translates at the edge: toWire (camelCase → snake_case) on
request bodies and query objects, fromWire (snake_case → camelCase) on responses.
camelCase is the TypeScript-specific public surface, not a wire change — the
wire stays snake_case for every SDK. Other language generators are expected to apply
their own idiomatic surface transformation over the same snake_case wire: a Go SDK
would use exported UpperCamelCase fields with json:"snake_case" tags, a Python SDK
would keep snake_case (already idiomatic), etc. Keep casing decisions in the
per-language emitter; do not push a language's casing into TypeSpec, OpenAPI, or the
wire.
The translation is a deterministic casing rule, not a per-field map: every wire
name round-trips through toSnakeCase(toCamelCase(name)), enforced at codegen by a
gate (assertCasingDerivable) that fails the build for any non-derivable name. The
public key is toCamelCase(resolveEncodedName(...)), so the wire key the mapper
emits is exactly the OpenAPI name. The mapper is schema-driven: it walks the zod
schema alongside the data so Record<string, …> keys that are user data (label
names, meter dimension names) are preserved verbatim, while typed field keys
(including AIP filter[field] names and sort.by) are translated.
The same gate (assertCasingDerivable) also fails the build for a non-discriminated
union with two or more object variants reachable from a request body or success
response — the mapper cannot pick a variant without a discriminator, and does not
guess. Use @discriminated for such unions (scalar-vs-object unions, and T | T[]
single-or-batch bodies, are fine — distinguished at runtime by JS type). Discriminated
unions dispatch via a memoized literal→variant map keyed on the (camel public / snake
wire) discriminator value.
Dates: Date public surface, RFC 3339 wire, requests also take strings
Every date-time in the AIP spec is the shared DateTime scalar (utcDateTime with
@encode(rfc3339)). The wire stays the RFC 3339 string; the generated TS surface
types these fields Date — in interfaces, query types, and the camelCase zod
schemas (z.date()) — while the …Wire schemas keep z.string().datetime().
The boundary mapper converts alongside the casing pass: toWire serializes any
Date instance to toISOString() wherever it sits (bodies and query objects alike,
and before …Wire validation, so validate checks the wire form), and fromWire
revives strings into Dates at date-typed schema nodes, including record/array
values. A datetime behind a union (DateTime | null on event.time,
enum-or-DateTime on subscription timing) is revived only when the date variant is
the string's sole plausible owner — enum literals, matching string literals, and
plain-string variants pass through untouched (fail-open, same policy as unmatched
union variants).
Requests additionally accept RFC 3339 strings: each body/query-bearing
…Request alias is wrapped in AcceptDateStrings<T> (exported from lib/wire.ts),
a recursive mapped type turning every Date into Date | DateString, where
DateString = string & Record<never, never> — assignable from any string but
immune to union absorption, so literal siblings of a Date (subscription
timing's 'immediate' | 'next_billing_cycle') keep their autocomplete instead
of collapsing into string. The widening
lives on the request alias only — domain interfaces and …Query interfaces stay
Date, because they also describe responses and are pinned to the schemas by the
model conformance guard and the per-op query input guards; widening them (or forking
input variants per model) is exactly what this avoids. At runtime a request string
passes through the mapper verbatim (never re-parsed or normalized — a non-UTC
offset or malformed string reaches the server as-is unless validate is on, where
the wire schema's UTC datetime() check rejects it).
Response/request mapping drops unknown fields
fromWire/toWire rename keys and map date values only (Date ↔ RFC 3339
string, see above) — they never call schema.parse(), never apply zod defaults, and
never coerce any other value. A field not present in the schema shape
is dropped, so the mapped object exactly matches the typed interface (a
server-added field is not in the type and does not survive). This is a deliberate
choice for strict typing over forward-compatibility. zod is retained for type
derivation (z.input/z.output), query/path coercion, mapper structure, and the
one baseError.safeParse in the error path. Error responses bypass the mapper
(toError reads the raw snake body; HTTPError.getField is a raw, untyped escape
hatch).
Optional wire-payload validation (validate option)
SDKOptions.validate (default off) turns on schema validation of the actual
snake_case wire payload: the request body after toWire (before sending) and the
raw response body before fromWire. Validation uses the generated …Wire
schemas in models/schemas.ts — every model and per-op body/response is emitted a
second time in a snake_case "wire" pass (WireModeContext in the emitter), keyed by
the raw JSON wire name and made z.strictObject, so a wrong-shaped or
leaked-camelCase wire field is rejected, not silently stripped. Open models
(record spread, emitsAsIntersection, e.g. baseError) stay non-strict — strict
would defeat the record arm that exists to accept them. The wire pass uses the same
emitter walk as the camelCase pass (parameterized by direction + key-casing +
strictness + a separate refkey namespace), but it must describe the value after
transport encoding: date-time values are strings, SDK-coded query parameters use
their declared transport type, and defaults are absent. A failure throws
ValidationError, which request() surfaces as Result.error (request validation
runs inside the request() closure so it does not throw synchronously).
Enabling validate re-introduces exactly the rejection the default policy
avoids: a strict wire schema rejects additive/unknown server fields and unknown
enum values. It is opt-in defense-in-depth, not the default, precisely because the
default contract must not break on additive fields.
Models decorated with @useRef still need a local TypeSpec shape that matches the
referenced OpenAPI schema. The TypeScript and Go emitters walk the local TypeSpec
AST; @useRef only changes the emitted OpenAPI reference and does not import the
referenced schema's requiredness or nullability into language-specific SDKs.
TypeSpec defaults belong only on public schemas. toWire reads the public schema to
materialize required request defaults before wire validation; …Wire schemas must
not use Zod .default(...), because the same schema validates responses and a
default wrapper would accept a required field the server omitted. The query
parameter name sort is reserved: it must use Common.SortQuery directly (not an
alias), enforced by the AIP sort-query-type linter rule. Both SDK emitters select
the sort codec from that validated HTTP parameter name. The TypeScript emitter must
validate the public property schema before encoding and the encoded value against
the operation's wire schema afterward.
Generated path-parameter schemas are part of the same boundary: in strict mode,
map path values to their transport representation, validate the mapped object,
then interpolate and URL-encode it. Preserve path binding names during mapping;
unlike JSON object keys, they must not be snake-cased. Keep mapping conditional so
validate: false retains its established runtime behavior.
Documented types: generated from TypeSpec, verified against zod
zod schemas and TypeScript types are separate artifacts with one source. Both are generated from the same TypeSpec, but neither is derived from the other at the type level:
models/schemas.ts— zod schemas (runtime validation in the error path, query/ path coercion). The runtime artifact.models/types.ts— concrete TypeScript interfaces (the public surface that.json<T>()is typed against). Self-contained: it imports neitherzodnorschemas.ts. Field types are walked directly from the TypeSpec AST bytsTypeOfints-types.ts, which mirrors the leaf decisions ofzodBaseSchemaParts(the zod walker) so the two stay type-equivalent.models/types.assert.ts— the inferred types are used only for verification: a mutual-assignability guard tiestypes.tstoschemas.tsat build time.
Why not infer types.ts from z.output<typeof schema>? zod strips .describe()
at the type level, so an inferred type has the shape but no docs; and indexed
access (Meter['name']) couples the public types to the runtime schemas. Walking
TypeSpec directly gives clean concrete types (id: string,
aggregation: "sum" | "count" | …, labels?: Labels) with /** … */ JSDoc from
the TypeSpec @doc, decoupled from zod.
tsTypeOf leaf mapping (must match zodBaseSchemaParts or the guard fails):
- scalars →
string/number/boolean; int64/uint64 →bigint(zod usesz.coerce.bigint()); everything else numeric →number. - dates/times/durations →
string(wire-native; RFC 3339, neverDate). - enums → inlined literal unions (
"a" | "b"); never collected as named interfaces. A named TypeSpecunion(union Price { free: PriceFree, … }) refs its owntypes.tsalias when reachable (see "Named union aliases" below); an anonymous union expression (A | Bwritten inline) still inlines its variants. - named models (incl. named records like
Labels) → ref the interface; anonymous models → inlined object literal; arrays →T[](parenthesized whenTis a union:(A | B)[]); open records →Record<string, V>.
Structural rules the interface emitter follows:
- Optionality follows OUTPUT: a defaulted field is optional-in / required-out,
so
prop.optional && prop.defaultValue === undefineddecides the?. - No-wire-prop models alias to their mapped structure
(
export type Labels = Record<string, string>), never an empty permissiveinterface {}. The alias excludes the model from its own ref resolution so it does not becometype Labels = Labels. extendsthe base interface when the model has abaseModel, so inherited fields/docs propagate (BadRequest extends BaseError).- Open records (
...Record<…>) get an index signature ([key: string]: V). - Named union aliases. Every named TypeSpec
unionthat is reachable from an operation on an included service gets its ownexport type <Name> = <Variant1> | <Variant2> | …intypes.ts(interface-types.ts,unionVariantsTypeints-types.ts) — variants resolve through the sameRefName/refNameInputmachinery as model properties, so a model-variant is named (PriceFree) and an anonymous-object variant inlines. The alias gets the same conformance guard as a model interface, and an…Inputvariant (computeDivergentUnionsininput-variants.ts) only when at least one variant is itself a divergent model (e.g.WorkflowPaymentSettingsInput, becauseWorkflowPaymentSendInvoiceSettingshas a defaulted field) — a union with no divergent variant (e.g.WorkflowCollectionAlignment) has no…Inputalias. Reachability gate: a union can be declared in TypeSpec (and still get a zod schema, sincegetAllDataTypeswalks the whole namespace tree) without anything in the actual SDK surface referencing it —computeReachableUnionsinemitter.tsxwalks every collected operation's request body, query parameters, and response body (success and error) and only aliases unions it reaches. Every operation counts as a reachability root:x-internalandx-privateoperations are emitted under theclient.internal.*surface, so the unions they reach are aliased too (Invoice/InvoiceLine/UpdateInvoiceRequestvia thex-privateinvoice operations,Currencyvia thex-internalcurrency operations).PriceUsageBased,ULIDOrResourceKey, andULIDOrExternalResourceKeyare declared but never referenced by anything, so they stay zod-only (aliasing them would export a degenerate type likestring | string); models are never reachability-gated — only the union alias pass is. This is a deliberately narrower policy than models', to avoid exporting unions nothing in the shipped client can ever produce or accept. - Response wiring picks up named unions too. Because a named union now
resolves through the same
resolveInterface/emittedInterfaceNamespath as a model, an operation whose success body is directly a reachable named union (e.g.get-app→App) wires its…Responsealias to the union alias instead of falling back toz.output<typeof schemas...>— see "Response wiring" below.
Conformance guard (the oracle). Every emitted type — both interfaces and
the no-wire-prop type aliases — is paired with a mutual-assignability check in
models/types.assert.ts
([X] extends [z.output<…>] ? [z.output<…>] extends [X] ? true : {__error}). This
is the only place types.ts and schemas.ts meet: it proves the directly-walked
TS type is type-equivalent to the zod schema, turning any divergence (wrong leaf,
wrong optionality, header leak, open-record gap) into a build error. tsc is
the oracle. The alias branch must guard too: unlike a former z.output alias
(tautological), a tsTypeOf-walked alias like LabelsFieldFilter is an
independent claim that can diverge. One blind spot remains by nature: the check is
vacuous when either side is any — so the output is also grepped for : any (the
AIP spec uses unknown, never any, so no field hits it).
Response wiring. Per-operation …Response aliases point at the documented
interface when the success body resolves to a named model or named union
(e.g. get-app → App; see "Named union aliases" above). The extracted HTTP body
of a list endpoint is anonymous (TypeSpec strips the envelope identity during
body extraction), so sdkOperation falls back to the 2xx response envelope
(HttpOperationResponse.type), whose @friendlyName survives — e.g.
PagePaginatedResponse<Meter> → MeterPagePaginatedResponse. This reuses the
already-emitted, already-guarded paginated interfaces (no synthesis). Net: ~70/81
responses wired to interfaces, 10 void, 1 text (CSV) — none fall back to
z.output<typeof schemas...> now that a directly-returned named union resolves
to its own alias instead.
Compared to the zod-to-ts npm package (which also walks a zod schema to a TS
type with JSDoc from .describe()): that lib inlines nested objects and emits
prop?: T | undefined for optionals, sourcing docs from .describe(). This
generator instead refs named interfaces (better for a published SDK), emits
clean prop?: T output-shaped optionality (defaulted fields required, no
| undefined), and sources docs from TypeSpec @doc — so the emitter does not
depend on .describe() surviving into the runtime schemas.
Factoring: what the generator emits, and how often
- ONCE (shared runtime in
lib/+core.ts): the baseClient/transport (oneky.create), therequest()envelope,Result/ok/err/unwrap, the curatedRequestOptions, the encoders (encodePath,toURLSearchParams,encodeSort,querySerializer),toError, and theHTTPErrorclass. - PER-NAMESPACE (per resource/tag): one façade class that composes a
Client(holds a reference — it does notextends Client) plus one memoized lazy getter on the rootOpenMeter. - PER-OPERATION (×~83): one standalone func = path/query/body assembly +
request(() => http(client).<verb>(…).json<R>()), plus a one-line façade wrapper. The request/response type aliases and per-op…Querytypes live inmodels/operations/<ns>.ts(their guards inmodels/operations/<ns>.assert.ts);funcs/<ns>.tsimports…Request/…Responsefrom there and holds only functions, so the funcs modules stay free of type declarations and guards.
Void responses must not call .json()
The 10 operations whose Response is void (!op.hasResponse — every
delete* plus events.ingest, which return 204 No Content / 202 Accepted
with an empty body) terminate with request(async () => { await http(client).<verb>(…) }),
not .json<R>(). ky's .json() throws SyntaxError: Unexpected end of JSON input
on an empty body (and explicitly on 204), so calling it on a successful
void response rejects a request that actually succeeded server-side — ingest
(the product's hot path) and every delete. Awaiting the ResponsePromise
without parsing still rejects on non-2xx (ky's throwHttpErrors default is on),
so error propagation is preserved. funcBody branches on op.hasResponse for
this; non-void funcs keep the .json<R>() terminal unchanged.
tests/void-responses.spec.ts is the regression guard: success on empty
202/204, still-rejects on a 500 (status-only fallback), and full problem+json
error fidelity preserved on a void op (the ky fork populates e.data at throw
time regardless of .json(), so to-error.ts recovers title/detail/type
identically to non-void ops). Note baseError.safeParse requires instance, so
a problem+json mock without it falls through to the status-only error — include
instance to exercise the structured branch. The test is hand-maintained
(it isn't part of the templates/tests/ set runtime-templates.ts emits), so
it lives directly in tests/ and is not re-emitted by generate — do not
delete it expecting a regen to restore it.
Request types: direct TS, input-variant interfaces
Request/response types are direct TS, not z.input/z.output, and live in
models/operations/<ns>.ts (re-exported from the barrel under their existing
public names). The split mirrors the model types:
- Response → the documented output interface (a model or, since named
unions are aliased too, a union like
App),void, orstring(text/CSV). - Request body → the body model's (or named union's) interface, or its
…Inputvariant when the body's input shape diverges from its output. A model diverges iff a defaulted field — anywhere in its reachable subtree — flips from required (output) to optional (input);computeDivergentModels(ininput-variants.ts) is the transitive fixpoint. A union diverges iff at least one of its own variants is a divergent model (computeDivergentUnions, same file — shallow, not transitive, since a union carries no properties of its own).interface-types.tsemits anXInputinterface/alias (relaxed optionality, refing childYInputvariants) for each divergent model or union — e.g.create-customer-charges's body resolves to the union aliasCreateChargeRequest. ~12 request bodies diverge directly; their closure is ~51…Inputinterfaces. - Query → a per-op
<Base>Queryinterface walked from the query parameter leaves in input mode (inmodels/operations/<ns>.ts). - Path params are ULIDs →
string. - Shared-route JSON body override → a
@sharedRouteendpoint declares one operation per content type (e.g.events.ingest: a single-eventcloudevents+json, a batchcloudevents-batch+json, and a single-or-batchapplication/jsonunion).collectHttpOperationskeeps the first variant (for its doc/summary/response/202), which is the single-event one — so without intervention the request body would beEventInputonly.jsonBodyOverrides(insdk-operations.ts) maps such an endpoint to itsapplication/jsonbody type when that differs from the kept variant's;request-types.tsthen renders the body withtsTypeOf(..., 'input')(→EventInput | EventInput[]) instead of a single named-interface import. Trigger is narrow (only ingest today); the func/facade are unchanged —json: reqserializes an object or array identically, so widening only the request type is sufficient.
Two traps the generator handles:
- Name collision: the op request type
<Base>Requestcollides with a body model interface of the same name (e.g.CreateMeterRequest). The body is imported under a<Name>Bodyalias so the local request declaration owns the name (import type { CreateMeterRequest as CreateMeterRequestBody }). - Coerced leaves:
z.inputof az.coerce.*leaf is the looseunknown(zod 4). The emitted input type deliberately keeps the strict leaf (bigint/number/…) rather than propagate theunknownwart. So input variants and…Querytypes are guarded one-directionally ([XInput] extends [z.input<…>]— "is a valid input"), not bidirectionally. Output interfaces keep the full bidirectional guard. The…Queryguards live in a siblingmodels/operations/<ns>.assert.ts, matching how model guards live intypes.assert.ts.
Selection is unguarded by the shipped guards — verify it separately. A
too-strict request type (refing the output X where XInput was needed) still
satisfies the one-directional [X] extends [z.input], so the shipped guards
can't catch picking the wrong variant or computeDivergentModels under-marking.
Two independent checks close this:
- The 20 conformance tests construct real requests (end-to-end).
- Coverage probe (the authoritative recipe — do NOT use a regex reachability
tracer; it false-matches identifiers inside
.regex(/…/)and.describe()): for every model reachable from a*Bodyschema that has an output interface, assert[X] extends [z.input<typeof schemas.x>]. IfXis too strict (anXInputwas needed but missing), this fails. Run it as a temporary probe file compiled by tsc; zero failures = every request-reachable model is covered.
Dual surface
Every operation exists twice: a standalone func in funcs/ returning
Result<T> (tree-shakeable, non-throwing) and a thin method on the namespace
façade in sdk/ that unwraps and throws. Both call the same func.
Pagination companions (<method>All)
Every page-number or cursor list operation gets a companion facade method
— <method>All alongside <method> (e.g. client.meters.listAll() next to
client.meters.list()) — that returns AsyncIterable<Item> and fetches
following pages lazily as the iterable is consumed. This is purely additive:
existing list()/funcs.listX() signatures and behavior are untouched; only
the facade layer (sdk-files.ts) gains the extra method. No standalone-func
equivalent is emitted — the companion is facade-only, matching the "thin
codegen, shared runtime" split below.
Detection is structural, by AST node identity, not by name. Both
pagination styles are TypeSpec generic response templates in
shared/responses.tsp: Shared.PagePaginatedResponse<T> (meta: Common.PageMeta, i.e. { page: { number, size, total } }) and
Shared.CursorPaginatedResponse<T> (meta: Common.CursorMeta, i.e. { page: { next?, previous?, first?, last?, size? } }). pagination.ts resolves
these two template declarations once per emit
(program.getGlobalNamespaceType().namespaces.get('Shared'), then
.models.get('PagePaginatedResponse'|'CursorPaginatedResponse')) and matches
each operation's success response envelope (successResponseEnvelope,
exported from sdk-operations.ts) against them by .node identity — every
instantiation of a TypeSpec generic model shares the declaration's syntax
node, so this is exact regardless of the instantiation's own
(@friendlyName-interpolated) name. getPagingOperation/@pageItems from
@typespec/compiler was evaluated and rejected: in this spec @pageItems is
the only paging decorator actually used, so it can confirm "this operation is
paginated" but cannot distinguish the two styles — node identity subsumes it
and is the only structural signal that does distinguish them. The item type
T comes from envelope.templateMapper.args[0], resolved to its documented
interface name via the same resolveInterface every other response uses — an
item type with no documented interface (should never happen for a real list
op) gets no companion rather than an untyped one. Shared is looked up by
name because TypeSpec has no other way to name "the two templates this
emitter builds pagination around" (same precedent as SPLIT_BY_INTERFACE);
the per-operation match itself is never name-based.
Runtime helpers, not per-operation loop bodies. The iteration logic lives
once in templates/runtime/paginate.ts → generated src/lib/paginate.ts:
paginatePages advances request.page.number, stopping on a page shorter
than the server's own reported meta.page.size (including an empty page) or
once the running item count reaches meta.page.total; paginateCursor
follows meta.page.next — an opaque cursor token fed back verbatim as
page.after (despite next/previous/first/last carrying a format: uri annotation in the spec — confirmed against the server's own handlers,
e.g. api/v3/handlers/customers/credits/list_transactions.go: "We
intentionally expose opaque cursor tokens instead of URI links" — do not
"fix" this by having the helper fetch next as a URL). Both helpers accept a
generic fetchPage: (req, options) => Promise<Result<Envelope>> and unwrap
each page internally (facades throw HTTPError, matching every other
facade method), cap iteration at MAX_PAGINATION_PAGES (10,000) and throw
PaginationLimitExceededError rather than loop forever on a misbehaving
server (mirroring DepthLimitExceededError in wire.ts), and forward the
caller's RequestOptions (including signal) to every page fetch. The
generated companion only wires the right helper to the right func, binding
this._client in a closure — sdk-files.ts's emitPaginationMethod; no
per-operation loop code is emitted. PaginationLimitExceededError is
exported from the package root (indexFile in sdk-files.ts) alongside the
other typed runtime errors.
Coverage: paginate.ts joins wire.ts in the generated package's
vitest.config.ts coverage include at the same 100%
statement/function/line threshold (85% branch, matching wire.ts) — it has
no compile-time guard either, so its behavior must be covered entirely by
tests/paginate.spec.ts (both helpers: multi-page iteration, early-break
fires no extra requests, empty/short/exact-total page termination, absent-
next-cursor termination, filter/sort/page-size preserved across pages,
AbortSignal propagation, and the PaginationLimitExceededError cap for
both styles — the cap tests drive paginatePages/paginateCursor directly
with an in-memory stub fetchPage, not through fetch-mock, so 10,000
iterations stay fast).
Method/function JSDoc
Every emitted facade method (sdk/*.ts) and standalone function (funcs/*.ts)
carries a JSDoc comment, built by operationJsDoc in sdk-operations.ts: the
@summary decorator text (SdkOperation.summary, short one-liner) followed by
the @doc description body (SdkOperation.doc, longer prose) when it differs
from the summary, and always a final line naming the HTTP route
(POST /openmeter/meters). The route line is unconditional, so every operation
gets a useful IDE hover even the rare one with neither a TypeSpec @doc nor a
@summary — the generator never emits a hollow JSDoc block. Summary and
description appear only when the TypeSpec source declares them; the generator
never fabricates prose, so a method whose JSDoc lacks a description is a
spec-authoring gap (add @doc to the operation), not an emitter bug. *Input variant
interfaces in models/types.ts (interface-types.ts) inherit the base
interface's doc comment verbatim (no doc on the base → none on the variant).
The shared jsdoc() helper (utils.tsx) escapes any literal */ in
doc/summary text so it cannot prematurely close the emitted comment; do not
bypass this helper when adding new doc-emitting call sites.
README
readme.ts emits the package README.md at the package root (emitter-output-dir
is the package root, so non-src/ paths land there; package.json/tests/
survive because writeOutput only writes listed paths). It is built from the
same grouped SdkOperation[] as the SDK files, in groupOperations insertion
order (matching index.ts), so the "Available Resources and Operations" table's
call paths (getter + nestPath + methodName, e.g. customers.credits.grants.create),
HTTP routes, and per-op summaries ($.type.getDoc(op), carried on SdkOperation.doc)
always equal the emitted client. The install/import package name comes from the
required package-name emitter option (context.options['package-name'],
declared in lib.ts with required: ['package-name'] and set in aip/tspconfig.yaml)
— never hardcode it in readme.ts, and there is no fallback: omitting the option
fails the whole compile with an invalid-schema diagnostic (verified by removing
it from tspconfig), so a missing name can never leak undefined into the README.
The example client variable is client (matching the table prefix client.<path>);
if you rename it, update both the fence declarations and operationsTable's prefix
together. The table-of-contents anchors and the headings
are produced by one slug() so TOC links never break. Every code fence is
self-contained (constructs its own client) and typechecks against the real
generated types; the meters.create payload uses the camelCase public surface
(eventType, valueProperty) and the lowercase aggregation enum ('sum'),
matching CreateMeterRequest. The README is emitted raw (compact markdown
tables); the generated aip-client-javascript output and the emitter's own
typespec-typescript/src are not prettier-clean on HEAD (prettier --check .
is already red for both subtrees), so do not pre-align tables in the emitter or
single out the README in .prettierignore.
RequestOptions is curated
RequestOptions = Pick<Options, 'signal' | 'headers' | 'timeout' | 'retry'>.
Do not widen it to the full ky Options — exposing searchParams/json/hooks/
fetch/prefix per call lets callers clobber transport internals.
Errors
toError maps ky failures to the domain HTTPError (RFC7807 problem+json,
charset-tolerant Content-Type match; status-only fallback otherwise). Result's
error type stays Error — the ky fork also throws TimeoutError/NetworkError,
so narrowing to HTTPError would be unsound. Callers narrow with
instanceof HTTPError. A single HTTPError class (no per-status hierarchy);
field-level validation errors are reachable via getField('invalid_parameters').
Server URL templating
baseUrl is required (no default). It may be a ServerList template with
{region}/{port} variables resolved via encodePath(baseUrl, serverVariables),
a concrete URL, or a URL object. region is typed to the enumerated Regions.
Missing template variables throw (fail-loud, never a literal {region} on the
wire). The SDK owns URL construction: it pins baseUrl (trailing-slash
normalized) and prefix: undefined after spreading user options so a
user-supplied prefix cannot redirect requests; the auth hook is appended
after user beforeRequest hooks so SDK auth wins.
ky is a fork — preserve its option names
The vendored ky uses baseUrl/prefix/totalTimeout/retryOnTimeout (not
mainline ky's prefixUrl). The emitter's runtime must use the fork's names; do
not "correct" them to mainline ky.
Query serialization (verified against the server)
api/v3/filters/parse.go is the source of truth for filter encoding:
- deep objects:
page[size],filter[key][eq](bracketed) - scalar
filter[key]=vis shorthand forfilter[key][eq]=v - array operands (
oeq/ocontains) are comma-joined into one param; the server rejects repeated query params. Never emitk=a&k=b. sortserializes to a plain string"<field> [asc|desc]"(single space) on the wire; the SDK accepts a{by, order}object andencodeSortflattens it.byis a camelCase field name in the SDK and istoSnakeCase-translated to the wire field name (the server validates snake field names; seeapi/v3/handlers/.../convert.go). Every query parameter namedsortmust useCommon.SortQuerydirectly; the AIPsort-query-typerule protects the name-based codec selection used by both SDK emitters.
Tests
The conformance tests (Vitest + @fetch-mock/vitest, matching the legacy SDK's
stack) live under typespec-typescript/templates/tests/ and are emitted into
the generated SDK by runtime-templates.ts. They are the generator's spec: it
is "done" when these tests pass against the emitted aip-client-javascript
output.
pnpm run test:sdk roots at packages/aip-client-javascript and runs the
generated tests against the generated SDK, so generate followed by
test:sdk is fully self-contained. The generated package is never
hand-edited; to change the runtime or tests, edit the files under
typespec-typescript/templates/ and re-run generate (see the layout note
above).
Vitest strips types without checking them, so the package typecheck script
runs twice: tsc --noEmit (the build tsconfig, src/ only, keeps declaration
diagnostics) and tsc -p tsconfig.tests.json (adds tests/, no emit,
skipLibCheck because @fetch-mock/vitest's own d.ts imports the undeclared
jest expect package). Without the second run, test files are never
type-checked by any gate — type-level probes placed in tests/ prove nothing.
tsconfig.tests.json is hand-maintained at the package root (like
package.json/vitest.config.ts, it survives regeneration) and is
.npmignored.
The meters namespace is behaviorally verified end-to-end by these 19 tests. The
other namespaces are generated and type-checked (tsc clean across all 13) but
not yet behaviorally tested — add a smoke test per namespace if broader runtime
coverage is wanted.
Emitter-level tests (in-memory compile harness)
typespec-typescript/test/emit.ts builds an EmitterTester with
createTester from @typespec/compiler/testing: it compiles a fixture
TypeSpec program in-memory, runs the emitter through the compiler's real emit
pipeline, and returns the emitted files as outputs: Record<path, content>
(paths relative to the emitter output dir, e.g. src/sdk/internal.ts). Use it
to pin generator behavior that should be caught before regenerating the real
client — test/internal-surface.test.ts (the x-private/x-internal routing to
the client.internal.* surface) is the model. Constraints:
- The tester resolves the emitter by its package name through
package.jsonexports, i.e. it runs the builtdist/— the packagetestscript runsalloy buildfirst for exactly this reason. A stale manualvitestrun tests stale code. - Fixture specs must author operations via the same
extendspattern the real spec uses (interface Endpoints extends Domain.Operations {}inside a@servicenamespace) or grouping falls intoungrouped-operation. Pagination detection requires a top-levelSharednamespace declaringPagePaginatedResponse/CursorPaginatedResponse. - The harness is what surfaced the unawaited-
writeOutputrace in$onEmit: the tsp CLI keeps the process alive past the pending writes, but in-memory compilation returns immediately, observing a partial output dir. KeepwriteOutputawaited.
make -C api/spec test runs pnpm --filter @openmeter/typespec-typescript run check (typecheck + these tests) alongside test:sdk:coverage, and the
aip-npm-release workflow runs that target before publishing.
Go SDK emitter
Output and wiring
typespec-goemits a single-package Go SDK (package openmeter) intoapi/v3/clientat the repo root — not underapi/spec/packages/. It is a standalone nested Go module,github.com/openmeterio/openmeter/api/v3/client, with its owngo.mod/go.sum(sole dependency:github.com/oapi-codegen/nullable). The rootgo test ./...never reaches it; usemake test-go-sdkat the repo root.- Wiring lives in
packages/aip/tspconfig.yamlunder@openmeter/typespec-go:emitter-output-dir: '{output-dir}/../../../v3/client'plus the optionsmodule-path,package-name: 'openmeter',include-services: ['OpenMeter'],strip-name-prefixes, andreadme-note.sdk-versionis deliberately not set there, so day-to-day regeneration stamps the0.0.0-devplaceholder; the release process sets it (see Releases below). The full option surface is declared intypespec-go/src/lib.ts. - Never hand-edit generated files in
api/v3/client. Changetypespec-goemitter components orsrc/runtime-templates.ts, then regenerate. The output cleaner deletes previously generated entries before emission (so file renames cannot leave duplicate declarations) but preserves*_test.gofiles andtestdata/: hand-written Go wire tests live inapi/v3/clientalongside the generated files and survive regeneration. - Grouping and nesting follow the same TypeSpec source-namespace rules as the
TypeScript SDK. Public Go names use PascalCase fields and methods with
json:"snake_case"tags; there is no runtime casing mapper. - Static Go runtime files live as reviewable TypeScript template strings in
typespec-go/src/runtime-templates.ts. Do not place Go files,go.mod, orgo.sumunder atypespec-go/runtime/directory; that makes the emitter source tree look like a standalone Go package. - Every generated
.gofile carries the// Code generated by @openmeter/typespec-go. DO NOT EDIT.header before the package clause, and generation gofmt-formats the output (a runnablegofmton PATH is a hard requirement of generation).
Model projection rules
- Model emission is payload-context aware. The response reachability walk
filters properties by
Lifecycle.Readvisibility, so create-only fields do not leak into read models. A model reachable only from requests emits its input projection under its natural name (e.g.CreateMeterRequest); a model reachable from both requests and responses emits one declaration when the projections agree, or a read declaration plus anInputtwin when they diverge (e.g.EventandEventInput). Seesrc/projections.ts. - Structural dedupe collapses visibility-projection twins onto canonical types:
a
Create/Update/Upsert-prefixed declaration whose rendered shape is structurally identical to another emitted declaration is dropped and every reference is redirected to the canonical name, so read-modify-write flows need no type mapping (computeStructuralAliasesinsrc/projections.ts). - Anonymous inline models are promoted to deterministic names derived from the
enclosing type plus field (
SubscriptionCreate.customer→SubscriptionCreateCustomer); a promoted-name collision is a generation error, resolved with@friendlyName. - Named
*FieldFilterunions (theStringFieldFilterfamily) are runtime-backed: an exact-name map insrc/go-types.tsx(runtimeFilterTypesByUnionName) resolves them to the static runtime filter types (StringFilter,StringExactFilter,DateTimeFilter,NumericFilter,BooleanFilter), and they are excluded from the model reachability walk so their variants never emit dead declarations. An unmapped*FieldFilterunion name fails generation instead of guessing. - Formatless TypeSpec
integer(andsafeint) map toint64; neither fits a narrower sized Go integer by declaration.
Wire-shape rules
- Shared-route representations are retained when media type or body shape
differs. Events ingest intentionally emits
Events.IngestEvent,Events.IngestEvents, andEvents.IngestEventsJSON, each with its own requestContent-Type. Response-only siblings such as meter CSV can reuse the JSON request body while keeping a distinct responseAccept. - TypeSpec
T | nullemits value-typedNullable[T]backed bygithub.com/oapi-codegen/nullable, not*Nullable[T]. Optional nullable fields rely onomitemptyfor the unspecified state while still preserving explicitnulland concrete values on marshal/unmarshal. - Optional maps and slices in request input models emit as pointers
(
*map[...]...,*[]...) so callers can distinguish omission from an explicit empty object/array. Keep this input-only through the projection rules above so response models remain ergonomic value maps/slices. - Go string enum constants stay prefixed as
<Type><Value>and every generated enum exposesValid() bool; unknown wire values must still decode and re-encode unchanged for forward compatibility. - Union wrappers are raw-preserving:
UnmarshalJSONandMarshalJSONcopy the payload with cloned buffers (append([]byte(nil), ...)), the zero-value union marshals as JSONnull, and unknown discriminator values round-trip unchanged.<Union>From<Variant>constructors stamp the variant's discriminator field before marshaling, keeping request construction ergonomic without weakening unknown-discriminator round-tripping. <List>Alliterator methods are emitted only for list responses with the canonical{data, meta}page envelope. A paginated response carrying any extra top-level field gets only the plain method returning the full envelope, because the iterator surfaces page elements alone.
Releases
- The
sdk-versionemitter option stampsconst Versioninapi/v3/client/option.go(also the defaultUser-Agentversion); it defaults to0.0.0-dev. - A release is an
api/v3/client/vX.Y.Zgit tag (-dev.N/-beta.Nprerelease suffixes are also accepted)..github/workflows/release-go-sdk.yamlgates the tag: it verifies the stampedVersionconstant matches the tag version, runsmake test-go-sdk, and creates a GitHub release for visibility. - Release steps: set
sdk-versionunder the@openmeter/typespec-gooptions inpackages/aip/tspconfig.yaml, regenerate (make gen-api), commit the stamped output, then push the matchingapi/v3/client/vX.Y.Ztag.
Verification
Verify Go emitter changes with (first two from api/spec, third from the repo
root):
pnpm --filter @openmeter/typespec-go run check
pnpm --filter @openmeter/api-spec-aip run generate # or: make gen-api (repo root)
(cd api/v3/client && gofmt -l . && go build ./... && go vet ./... && go test ./...)
make test-go-sdk at the repo root is the build/vet/test part of the last
line. In CI, the generators-openapi job runs the generated-output drift check
(make update-openapi + clean git diff) and the emitter's check script, and
the go-sdk job runs make test-go-sdk.