openmeter / api /spec /AGENTS.md
Leon4gr45's picture
Upload folder using huggingface_hub
048b1e8 verified
|
Raw
History Blame Contribute Delete
53.4 kB

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-typescript is a TypeSpec emitter built on @alloy-js + @typespec/emitter-framework. It walks HTTP operations and emits the full SDK.
  • aip-client-javascript is its output directory (emitter-output-dir in packages/aip/tspconfig.yaml points here). Everything it contains is regenerable — never hand-edit it. A single generate emits the complete SDK (schemas, runtime, per-namespace surface, barrel) plus the conformance tests.
  • typespec-go is a TypeSpec emitter built on @alloy-js/go + @typespec/emitter-framework. It emits the Go SDK into api/v3/client, which is also fully regenerable generated output.

How the emitter is structured

  • emitter.tsx$onEmit: emits schemas.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, via readFileSync, from the committed templates/runtime/ and templates/tests/ directories at build time. Edit those files directly to change the runtime or tests; templates/ is excluded from this package's own tsconfig.json include (only checked downstream, as part of the generated aip-client-javascript package'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 .ts file under src/runtime/ instead (type-checked and unit-tested by the emitter package's own tooling) and embedded verbatim via readFileSync at build time (see src/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 package README.md (emitted at the package root, not under src/) from the same grouped SdkOperation[] 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 + MetersQueryEndpointsmeters (keeps meters.query); all Customer*Endpointscustomers. 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.grantscreate.

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-metergetMeter.
  • facade method = operationId with the group's resource noun(s) and the cross-cutting metering qualifier stripped: get-meterget; get-customer-billinggetBilling; ingest-metering-eventsingest. Singular/plural folded. The resource name is split into strip-words on separators and case boundaries — both camelCase (PlanAddonsplan, addons) and acronym→word (LLMCostllm, cost) — so multi-word and acronym-prefixed namespaces strip fully (create-plan-addoncreate, create-llm-cost-overridecreateOverride). 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 (PlanPlans); PascalCase class / camelCase getter.
  • request type composed from direct-TS parts (no z.input): query-only → <Base>Query; body-only → the body interface (its …Input variant 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, typed string. 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/http and add using TypeSpec.Http; in that file; otherwise compilation fails with Unknown 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 neither zod nor schemas.ts. Field types are walked directly from the TypeSpec AST by tsTypeOf in ts-types.ts, which mirrors the leaf decisions of zodBaseSchemaParts (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 ties types.ts to schemas.ts at 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 uses z.coerce.bigint()); everything else numeric → number.
  • dates/times/durations → string (wire-native; RFC 3339, never Date).
  • enums → inlined literal unions ("a" | "b"); never collected as named interfaces. A named TypeSpec union (union Price { free: PriceFree, … }) refs its own types.ts alias when reachable (see "Named union aliases" below); an anonymous union expression (A | B written inline) still inlines its variants.
  • named models (incl. named records like Labels) → ref the interface; anonymous models → inlined object literal; arrays → T[] (parenthesized when T is 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 === undefined decides the ?.
  • No-wire-prop models alias to their mapped structure (export type Labels = Record<string, string>), never an empty permissive interface {}. The alias excludes the model from its own ref resolution so it does not become type Labels = Labels.
  • extends the base interface when the model has a baseModel, so inherited fields/docs propagate (BadRequest extends BaseError).
  • Open records (...Record<…>) get an index signature ([key: string]: V).
  • Named union aliases. Every named TypeSpec union that is reachable from an operation on an included service gets its own export type <Name> = <Variant1> | <Variant2> | … in types.ts (interface-types.ts, unionVariantsType in ts-types.ts) — variants resolve through the same RefName/refNameInput machinery 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 …Input variant (computeDivergentUnions in input-variants.ts) only when at least one variant is itself a divergent model (e.g. WorkflowPaymentSettingsInput, because WorkflowPaymentSendInvoiceSettings has a defaulted field) — a union with no divergent variant (e.g. WorkflowCollectionAlignment) has no …Input alias. Reachability gate: a union can be declared in TypeSpec (and still get a zod schema, since getAllDataTypes walks the whole namespace tree) without anything in the actual SDK surface referencing it — computeReachableUnions in emitter.tsx walks 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-internal and x-private operations are emitted under the client.internal.* surface, so the unions they reach are aliased too (Invoice/InvoiceLine/ UpdateInvoiceRequest via the x-private invoice operations, Currency via the x-internal currency operations). PriceUsageBased, ULIDOrResourceKey, and ULIDOrExternalResourceKey are declared but never referenced by anything, so they stay zod-only (aliasing them would export a degenerate type like string | 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/emittedInterfaceNames path as a model, an operation whose success body is directly a reachable named union (e.g. get-appApp) wires its …Response alias to the union alias instead of falling back to z.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-appApp; 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 base Client/transport (one ky.create), the request() envelope, Result/ok/err/unwrap, the curated RequestOptions, the encoders (encodePath, toURLSearchParams, encodeSort, querySerializer), toError, and the HTTPError class.
  • PER-NAMESPACE (per resource/tag): one façade class that composes a Client (holds a reference — it does not extends Client) plus one memoized lazy getter on the root OpenMeter.
  • 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 …Query types live in models/operations/<ns>.ts (their guards in models/operations/<ns>.assert.ts); funcs/<ns>.ts imports …Request/…Response from 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, or string (text/CSV).
  • Request body → the body model's (or named union's) interface, or its …Input variant 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 (in input-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.ts emits an XInput interface/alias (relaxed optionality, refing child YInput variants) for each divergent model or union — e.g. create-customer-charges's body resolves to the union alias CreateChargeRequest. ~12 request bodies diverge directly; their closure is ~51 …Input interfaces.
  • Query → a per-op <Base>Query interface walked from the query parameter leaves in input mode (in models/operations/<ns>.ts).
  • Path params are ULIDs → string.
  • Shared-route JSON body override → a @sharedRoute endpoint declares one operation per content type (e.g. events.ingest: a single-event cloudevents+json, a batch cloudevents-batch+json, and a single-or-batch application/json union). collectHttpOperations keeps the first variant (for its doc/summary/response/202), which is the single-event one — so without intervention the request body would be EventInput only. jsonBodyOverrides (in sdk-operations.ts) maps such an endpoint to its application/json body type when that differs from the kept variant's; request-types.ts then renders the body with tsTypeOf(..., 'input') (→ EventInput | EventInput[]) instead of a single named-interface import. Trigger is narrow (only ingest today); the func/facade are unchanged — json: req serializes 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>Request collides with a body model interface of the same name (e.g. CreateMeterRequest). The body is imported under a <Name>Body alias so the local request declaration owns the name (import type { CreateMeterRequest as CreateMeterRequestBody }).
  • Coerced leaves: z.input of a z.coerce.* leaf is the loose unknown (zod 4). The emitted input type deliberately keeps the strict leaf (bigint/number/…) rather than propagate the unknown wart. So input variants and …Query types are guarded one-directionally ([XInput] extends [z.input<…>] — "is a valid input"), not bidirectionally. Output interfaces keep the full bidirectional guard. The …Query guards live in a sibling models/operations/<ns>.assert.ts, matching how model guards live in types.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 *Body schema that has an output interface, assert [X] extends [z.input<typeof schemas.x>]. If X is too strict (an XInput was 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]=v is shorthand for filter[key][eq]=v
  • array operands (oeq/ocontains) are comma-joined into one param; the server rejects repeated query params. Never emit k=a&k=b.
  • sort serializes to a plain string "<field> [asc|desc]" (single space) on the wire; the SDK accepts a {by, order} object and encodeSort flattens it. by is a camelCase field name in the SDK and is toSnakeCase-translated to the wire field name (the server validates snake field names; see api/v3/handlers/.../convert.go). Every query parameter named sort must use Common.SortQuery directly; the AIP sort-query-type rule 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.json exports, i.e. it runs the built dist/ — the package test script runs alloy build first for exactly this reason. A stale manual vitest run tests stale code.
  • Fixture specs must author operations via the same extends pattern the real spec uses (interface Endpoints extends Domain.Operations {} inside a @service namespace) or grouping falls into ungrouped-operation. Pagination detection requires a top-level Shared namespace declaring PagePaginatedResponse/CursorPaginatedResponse.
  • The harness is what surfaced the unawaited-writeOutput race in $onEmit: the tsp CLI keeps the process alive past the pending writes, but in-memory compilation returns immediately, observing a partial output dir. Keep writeOutput awaited.

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-go emits a single-package Go SDK (package openmeter) into api/v3/client at the repo root — not under api/spec/packages/. It is a standalone nested Go module, github.com/openmeterio/openmeter/api/v3/client, with its own go.mod/go.sum (sole dependency: github.com/oapi-codegen/nullable). The root go test ./... never reaches it; use make test-go-sdk at the repo root.
  • Wiring lives in packages/aip/tspconfig.yaml under @openmeter/typespec-go: emitter-output-dir: '{output-dir}/../../../v3/client' plus the options module-path, package-name: 'openmeter', include-services: ['OpenMeter'], strip-name-prefixes, and readme-note. sdk-version is deliberately not set there, so day-to-day regeneration stamps the 0.0.0-dev placeholder; the release process sets it (see Releases below). The full option surface is declared in typespec-go/src/lib.ts.
  • Never hand-edit generated files in api/v3/client. Change typespec-go emitter components or src/runtime-templates.ts, then regenerate. The output cleaner deletes previously generated entries before emission (so file renames cannot leave duplicate declarations) but preserves *_test.go files and testdata/: hand-written Go wire tests live in api/v3/client alongside 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, or go.sum under a typespec-go/runtime/ directory; that makes the emitter source tree look like a standalone Go package.
  • Every generated .go file carries the // Code generated by @openmeter/typespec-go. DO NOT EDIT. header before the package clause, and generation gofmt-formats the output (a runnable gofmt on PATH is a hard requirement of generation).

Model projection rules

  • Model emission is payload-context aware. The response reachability walk filters properties by Lifecycle.Read visibility, 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 an Input twin when they diverge (e.g. Event and EventInput). See src/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 (computeStructuralAliases in src/projections.ts).
  • Anonymous inline models are promoted to deterministic names derived from the enclosing type plus field (SubscriptionCreate.customerSubscriptionCreateCustomer); a promoted-name collision is a generation error, resolved with @friendlyName.
  • Named *FieldFilter unions (the StringFieldFilter family) are runtime-backed: an exact-name map in src/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 *FieldFilter union name fails generation instead of guessing.
  • Formatless TypeSpec integer (and safeint) map to int64; 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, and Events.IngestEventsJSON, each with its own request Content-Type. Response-only siblings such as meter CSV can reuse the JSON request body while keeping a distinct response Accept.
  • TypeSpec T | null emits value-typed Nullable[T] backed by github.com/oapi-codegen/nullable, not *Nullable[T]. Optional nullable fields rely on omitempty for the unspecified state while still preserving explicit null and 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 exposes Valid() bool; unknown wire values must still decode and re-encode unchanged for forward compatibility.
  • Union wrappers are raw-preserving: UnmarshalJSON and MarshalJSON copy the payload with cloned buffers (append([]byte(nil), ...)), the zero-value union marshals as JSON null, 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>All iterator 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-version emitter option stamps const Version in api/v3/client/option.go (also the default User-Agent version); it defaults to 0.0.0-dev.
  • A release is an api/v3/client/vX.Y.Z git tag (-dev.N/-beta.N prerelease suffixes are also accepted). .github/workflows/release-go-sdk.yaml gates the tag: it verifies the stamped Version constant matches the tag version, runs make test-go-sdk, and creates a GitHub release for visibility.
  • Release steps: set sdk-version under the @openmeter/typespec-go options in packages/aip/tspconfig.yaml, regenerate (make gen-api), commit the stamped output, then push the matching api/v3/client/vX.Y.Z tag.

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.