From 097553b7ce9dc153a4edbc7966a9d2d88ca618bc Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:31:31 +0200 Subject: [PATCH 1/5] feat(api): make the AIP TypeScript SDK release-ready Implements the fixes from the @openmeter/client release-readiness review. Correctness: - toWire materializes required-with-default fields, so the minimal documented events.ingest() call carries specversion and is accepted - bigint (int64) request fields map to JSON numbers on the wire (typed UnsafeIntegerError beyond the JSON-safe range) and revive on responses - the package root no longer shadows 18 operation request types with same-named domain models - operations marked x-private/x-internal are omitted from the SDK (they remain in the OpenAPI output) Emitter robustness: - one shared isSuccessStatus resolver (was four divergent copies) - diagnostics on every silent-degradation path (any/unknown fallbacks, ungrouped operations) - generated-file headers on every emitted file - runtime templates are committed files under templates/ instead of base64 blobs generated from an out-of-repo baseline Developer experience: - JSDoc on every generated method/func (summary, description, route) and on Input variant interfaces - named TypeSpec unions (Price, App, Charge, ...) are exported, narrowable types - auto-pagination companions (listAll) for page-number and cursor lists with a shared runtime helper and a hard page cap - HTTPError carries name/invalidParameters/retryAfter; a User-Agent header is sent with the version stamped at publish - README documents transport config, runtime validation, the ./zod export, Result/unwrap, and pagination Packaging and CI: - LICENSE ships in the tarball; engines, caret dependency ranges, sideEffects, files allowlist, and main/types fallbacks added - the SDK conformance suite and emitter checks run in CI and gate the npm publish workflow --- .github/workflows/aip-npm-release.yaml | 6 + .github/workflows/ci.yaml | 4 + AGENTS.md | 4 +- Makefile | 5 + api/spec/AGENTS.md | 224 +++- api/spec/Makefile | 8 + .../packages/aip-client-javascript/.npmignore | 22 - .../packages/aip-client-javascript/LICENSE | 202 ++++ .../packages/aip-client-javascript/README.md | 346 ++++++- .../aip-client-javascript/package.json | 30 +- .../aip-client-javascript/src/core.ts | 10 + .../aip-client-javascript/src/funcs/addons.ts | 51 + .../aip-client-javascript/src/funcs/apps.ts | 16 + .../src/funcs/billing.ts | 48 + .../src/funcs/currencies.ts | 131 --- .../src/funcs/customers.ts | 150 +++ .../src/funcs/defaults.ts | 12 + .../src/funcs/entitlements.ts | 7 + .../aip-client-javascript/src/funcs/events.ts | 16 + .../src/funcs/features.ts | 44 + .../src/funcs/governance.ts | 46 - .../aip-client-javascript/src/funcs/index.ts | 5 +- .../src/funcs/invoices.ts | 114 --- .../src/funcs/llmCost.ts | 38 + .../aip-client-javascript/src/funcs/meters.ts | 55 + .../src/funcs/planAddons.ts | 37 + .../aip-client-javascript/src/funcs/plans.ts | 51 + .../src/funcs/subscriptions.ts | 84 +- .../aip-client-javascript/src/funcs/tax.ts | 27 + .../aip-client-javascript/src/index.ts | 53 +- .../aip-client-javascript/src/lib/config.ts | 2 + .../src/lib/encodings.ts | 2 + .../aip-client-javascript/src/lib/paginate.ts | 99 ++ .../aip-client-javascript/src/lib/request.ts | 2 + .../aip-client-javascript/src/lib/to-error.ts | 2 + .../aip-client-javascript/src/lib/types.ts | 2 + .../aip-client-javascript/src/lib/version.ts | 6 + .../aip-client-javascript/src/lib/wire.ts | 119 ++- .../src/models/errors.ts | 37 + .../src/models/operations/addons.ts | 2 + .../src/models/operations/apps.ts | 6 +- .../src/models/operations/billing.ts | 2 + .../src/models/operations/currencies.ts | 65 -- .../src/models/operations/customers.ts | 12 +- .../src/models/operations/defaults.ts | 2 + .../src/models/operations/entitlements.ts | 2 + .../src/models/operations/events.ts | 2 + .../src/models/operations/features.ts | 2 + .../src/models/operations/governance.ts | 19 - .../src/models/operations/invoices.ts | 56 - .../src/models/operations/llmCost.ts | 2 + .../src/models/operations/meters.ts | 2 + .../src/models/operations/planAddons.ts | 2 + .../src/models/operations/plans.ts | 2 + .../src/models/operations/subscriptions.ts | 9 +- .../src/models/operations/tax.ts | 2 + .../src/models/schemas.ts | 227 +--- .../aip-client-javascript/src/models/types.ts | 968 ++++++++---------- .../aip-client-javascript/src/sdk/addons.ts | 73 ++ .../aip-client-javascript/src/sdk/apps.ts | 38 + .../aip-client-javascript/src/sdk/billing.ts | 70 ++ .../src/sdk/currencies.ts | 50 - .../src/sdk/customers.ts | 242 +++++ .../aip-client-javascript/src/sdk/defaults.ts | 12 + .../src/sdk/entitlements.ts | 7 + .../aip-client-javascript/src/sdk/events.ts | 38 + .../aip-client-javascript/src/sdk/features.ts | 66 ++ .../src/sdk/governance.ts | 18 - .../aip-client-javascript/src/sdk/invoices.ts | 50 - .../aip-client-javascript/src/sdk/llmCost.ts | 80 ++ .../aip-client-javascript/src/sdk/meters.ts | 77 ++ .../src/sdk/planAddons.ts | 59 ++ .../aip-client-javascript/src/sdk/plans.ts | 73 ++ .../aip-client-javascript/src/sdk/sdk.ts | 20 +- .../src/sdk/subscriptions.ts | 104 +- .../aip-client-javascript/src/sdk/tax.ts | 47 + .../tests/client.spec.ts | 33 + .../tests/errors.spec.ts | 105 ++ .../tests/index-exports.spec.ts | 51 + .../tests/meters.spec.ts | 2 + .../tests/nesting.spec.ts | 2 + .../tests/paginate.spec.ts | 396 +++++++ .../tests/wire-helpers.ts | 11 + .../aip-client-javascript/tests/wire.spec.ts | 111 +- .../aip-client-javascript/tsconfig.json | 2 - .../aip-client-javascript/vitest.config.ts | 19 +- api/spec/packages/aip/tspconfig.yaml | 3 +- .../scripts/gen-runtime-templates.mjs | 69 -- .../typespec-typescript/src/ZodOperations.tsx | 46 +- .../typespec-typescript/src/casing-gate.ts | 13 +- .../typespec-typescript/src/emitter.tsx | 146 ++- .../typespec-typescript/src/http-status.ts | 24 + .../typespec-typescript/src/input-variants.ts | 33 +- .../src/interface-types.ts | 95 +- .../packages/typespec-typescript/src/lib.ts | 26 +- .../typespec-typescript/src/pagination.ts | 90 ++ .../typespec-typescript/src/readme.ts | 298 +++++- .../src/runtime-templates.ts | 63 +- .../typespec-typescript/src/runtime/wire.ts | 117 ++- .../typespec-typescript/src/sdk-files.ts | 121 ++- .../typespec-typescript/src/sdk-operations.ts | 85 +- .../typespec-typescript/src/ts-types.ts | 33 + .../typespec-typescript/src/utils.tsx | 9 +- .../typespec-typescript/src/visibility.ts | 21 +- .../typespec-typescript/src/zodBaseSchema.tsx | 20 + .../templates/runtime/config.ts | 31 + .../templates/runtime/core.ts | 55 + .../templates/runtime/encodings.ts | 75 ++ .../templates/runtime/errors.ts | 83 ++ .../templates/runtime/paginate.ts | 97 ++ .../templates/runtime/request.ts | 10 + .../templates/runtime/to-error.ts | 16 + .../templates/runtime/types.ts | 25 + .../templates/runtime/version.ts | 4 + .../templates/tests/client.spec.ts | 176 ++++ .../templates/tests/errors.spec.ts | 173 ++++ .../templates/tests/meters.spec.ts | 102 ++ .../templates/tests/nesting.spec.ts | 33 + .../typespec-typescript/vitest.config.ts | 14 + api/spec/pnpm-lock.yaml | 4 +- 120 files changed, 5809 insertions(+), 1758 deletions(-) delete mode 100644 api/spec/packages/aip-client-javascript/.npmignore create mode 100644 api/spec/packages/aip-client-javascript/LICENSE delete mode 100644 api/spec/packages/aip-client-javascript/src/funcs/currencies.ts delete mode 100644 api/spec/packages/aip-client-javascript/src/funcs/governance.ts delete mode 100644 api/spec/packages/aip-client-javascript/src/funcs/invoices.ts create mode 100644 api/spec/packages/aip-client-javascript/src/lib/paginate.ts create mode 100644 api/spec/packages/aip-client-javascript/src/lib/version.ts delete mode 100644 api/spec/packages/aip-client-javascript/src/models/operations/currencies.ts delete mode 100644 api/spec/packages/aip-client-javascript/src/models/operations/governance.ts delete mode 100644 api/spec/packages/aip-client-javascript/src/models/operations/invoices.ts delete mode 100644 api/spec/packages/aip-client-javascript/src/sdk/currencies.ts delete mode 100644 api/spec/packages/aip-client-javascript/src/sdk/governance.ts delete mode 100644 api/spec/packages/aip-client-javascript/src/sdk/invoices.ts create mode 100644 api/spec/packages/aip-client-javascript/tests/index-exports.spec.ts create mode 100644 api/spec/packages/aip-client-javascript/tests/paginate.spec.ts delete mode 100644 api/spec/packages/typespec-typescript/scripts/gen-runtime-templates.mjs create mode 100644 api/spec/packages/typespec-typescript/src/http-status.ts create mode 100644 api/spec/packages/typespec-typescript/src/pagination.ts create mode 100644 api/spec/packages/typespec-typescript/templates/runtime/config.ts create mode 100644 api/spec/packages/typespec-typescript/templates/runtime/core.ts create mode 100644 api/spec/packages/typespec-typescript/templates/runtime/encodings.ts create mode 100644 api/spec/packages/typespec-typescript/templates/runtime/errors.ts create mode 100644 api/spec/packages/typespec-typescript/templates/runtime/paginate.ts create mode 100644 api/spec/packages/typespec-typescript/templates/runtime/request.ts create mode 100644 api/spec/packages/typespec-typescript/templates/runtime/to-error.ts create mode 100644 api/spec/packages/typespec-typescript/templates/runtime/types.ts create mode 100644 api/spec/packages/typespec-typescript/templates/runtime/version.ts create mode 100644 api/spec/packages/typespec-typescript/templates/tests/client.spec.ts create mode 100644 api/spec/packages/typespec-typescript/templates/tests/errors.spec.ts create mode 100644 api/spec/packages/typespec-typescript/templates/tests/meters.spec.ts create mode 100644 api/spec/packages/typespec-typescript/templates/tests/nesting.spec.ts create mode 100644 api/spec/packages/typespec-typescript/vitest.config.ts diff --git a/.github/workflows/aip-npm-release.yaml b/.github/workflows/aip-npm-release.yaml index ef9d4a5ca5..8c832fb095 100644 --- a/.github/workflows/aip-npm-release.yaml +++ b/.github/workflows/aip-npm-release.yaml @@ -60,6 +60,12 @@ jobs: cache-dependency-path: api/spec/pnpm-lock.yaml registry-url: "https://registry.npmjs.org" + # Publish gate: the SDK conformance suite and emitter checks must pass on + # the exact checkout being published, so a broken build can never reach npm + # even though this workflow runs independently of the CI workflow. + - name: Run SDK and emitter tests + run: make -C api/spec test + - name: Publish NPM package run: make -C api/spec publish-aip-sdk env: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 15c0135891..a0d71d7494 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -542,6 +542,10 @@ jobs: run: | nix develop --impure .#ci -c make lint-api-spec + - name: Run tests - api spec SDK + run: | + nix develop --impure .#ci -c make test-api-spec + - name: Run linters - openapi run: | nix develop --impure .#ci -c make lint-openapi diff --git a/AGENTS.md b/AGENTS.md index c4bd8fd389..12af4b775e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,9 +96,9 @@ All generated files have `// Code generated by X, DO NOT EDIT.` headers — neve 2. Run `make gen-api` to regenerate OpenAPI spec and SDKs 3. Run `make generate` to regenerate Go server/client code -The TypeSpec JS client emitted from `api/spec/packages/aip` now lands in `api/spec/packages/aip-client-javascript/`. The emitter regenerates `src/` and `README.md` only; `package.json` is **stable, hand-maintained, and committed** (the emitter's `writeOutput` only writes the paths it lists, so the manifest survives regeneration). Keep hand-written Playwright config, tests, and helpers outside `src/`, and put test-runner dependencies/scripts in the `api/spec/package.json` workspace root rather than the client package manifest. Static publish metadata (`name`, `license`, `homepage`, `repository`) lives directly in the client `package.json`; only the per-release `version` is injected at publish time. +The TypeSpec JS client emitted from `api/spec/packages/aip` now lands in `api/spec/packages/aip-client-javascript/`. The emitter regenerates `src/`, `README.md`, and four conformance test files (`tests/client.spec.ts`, `tests/meters.spec.ts`, `tests/errors.spec.ts`, `tests/nesting.spec.ts`); every regenerated file carries a `Code generated by @openmeter/typespec-typescript. DO NOT EDIT.` header — treat files without that header as hand-written. `package.json` is **stable, hand-maintained, and committed** (the emitter's `writeOutput` only writes the paths it lists, so the manifest survives regeneration). The test suite is vitest + `@fetch-mock/vitest`; keep hand-written tests and helpers in `tests/` (never `src/`), and put test-runner dependencies/scripts in the `api/spec/package.json` workspace root rather than the client package manifest. Static publish metadata (`name`, `license`, `homepage`, `repository`) lives directly in the client `package.json`; only the per-release `version` is injected at publish time. Operations marked `x-private` or `x-internal` in the TypeSpec source are omitted from the generated SDK (they stay in the OpenAPI output); removing an operation from the spec (or marking it private/internal) leaves stale generated files behind — the emitter never deletes outputs, so `git rm` them. -The emitted `api/spec/packages/aip-client-javascript/src/openMeterClient.ts` exposes aggregated sub-client getters on `OpenMeterClient` (e.g. `meters`, `customers`, `features`, `productCatalog`, `subscriptions`, `billing`, `entitlements`). Access operations through those getters, e.g. `sdk.meters.list()`, `sdk.customers.create(...)`, `sdk.productCatalog.createPlan(...)`. Note that resources are grouped by tag, so some operations live under a grouped client rather than a same-named top-level getter (for example plan operations are `sdk.productCatalog.createPlan`, not `sdk.plans.create`). +The emitted `api/spec/packages/aip-client-javascript/src/sdk/sdk.ts` exposes aggregated sub-client getters on the `OpenMeter` class (e.g. `events`, `meters`, `customers`, `entitlements`, `subscriptions`, `billing`, `features`, `plans`, `addons`, `planAddons`, `tax`, `defaults`). Access operations through those getters, e.g. `sdk.meters.list()`, `sdk.customers.create(...)`, `sdk.plans.create(...)`. These grouped-client methods throw `HTTPError` on failure; the same operations are also available as tree-shakeable standalone functions under `src/funcs/` that return a `Result` instead of throwing. **Workflow for changing Go types/DI:** diff --git a/Makefile b/Makefile index ea31ac2498..126217c82b 100644 --- a/Makefile +++ b/Makefile @@ -260,6 +260,11 @@ lint-api-spec: ## Lint OpenAPI spec $(call print-target) $(MAKE) -C api/spec lint +.PHONY: test-api-spec +test-api-spec: ## Run AIP TypeScript SDK and emitter tests + $(call print-target) + $(MAKE) -C api/spec test + .PHONY: lint-openapi lint-openapi: ## Lint OpenAPI spec $(call print-target) diff --git a/api/spec/AGENTS.md b/api/spec/AGENTS.md index 2f8e4ec2c3..5eefeb804e 100644 --- a/api/spec/AGENTS.md +++ b/api/spec/AGENTS.md @@ -15,14 +15,13 @@ packages/ aip-client-javascript/ # generator OUTPUT: the emitted TypeScript SDK ``` -The **baseline** (the frozen hand-written SDK + conformance tests the generator -reproduces) is NOT kept in the repo — its vitest/vite devDeps trip the workspace -`minimumReleaseAge` constraint. Its content is already embedded in -`typespec-typescript/src/runtime-templates.ts` (base64) and emitted into the -generated SDK, so the pipeline does not need it. To edit the runtime templates -or tests, restore the baseline (e.g. from `/tmp/om-aip-sdk-baseline` or git -history) and re-run `gen-runtime-templates.mjs` with `BASELINE_DIR` pointing at -it. +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 @@ -40,19 +39,24 @@ it. - `emitter.tsx` — `$onEmit`: emits `schemas.ts` (Alloy components, the original path), the static runtime files, and the per-namespace surface files, all as sibling `` children of one ``. -- `runtime-templates.ts` — base64-embedded copies of the fixed runtime files - (`core.ts`, `lib/*`, `models/errors.ts`) and the conformance tests, generated - from `baseline/` by `scripts/gen-runtime-templates.mjs`. Re-run that script - when the baseline runtime or tests change. -- New runtime helpers that aren't part of the frozen baseline (e.g. +- `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/` - (type-checked and unit-tested by the emitter package's own tooling) and - embedded verbatim via `readFileSync` at build time (see + 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 @@ -157,9 +161,10 @@ cross-package references. ## The emitted SDK: conventions the generator must reproduce -The hand-written baseline (kept under a temporary reference folder) defines the -exact shape the generator must reproduce. Its tests are the conformance target — -the generated SDK is "done" when it passes them. +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 @@ -287,8 +292,11 @@ the TypeSpec `@doc`, decoupled from zod. - 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 and unions → inlined literal/variant unions (`"a" | "b"`, `A | B`); they - are not collected as named interfaces. +- 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`. @@ -304,8 +312,38 @@ Structural rules the interface emitter follows: - **`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`). -- **Unions stay on `z.output`** at the _response_ layer (e.g. `GetAppResponse`) — a - discriminated union has no single interface, so wiring it to one would be wrong. +- **Named union aliases.** Every named TypeSpec `union` that is reachable from a + non-internal/non-private operation on an included service gets its own + `export type = | | …` 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 included, non-internal/private operation's request + body, query parameters, and response body (success and error) and only aliases + unions it reaches. `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`); `Invoice`/`InvoiceLine`/`UpdateInvoiceRequest`/`Currency` are + reachable only through invoice/currency operations marked + `x-internal`/`x-unstable`, which are excluded from the generated client + entirely, so their unions are excluded too even though the underlying models + (e.g. `InvoiceStandard`) still get interfaces (models are never + reachability-gated — only the new 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-app` → `App`) wires its `…Response` alias to the union alias instead + of falling back to `z.output` — see "Response wiring" below. **Conformance guard (the oracle).** Every emitted type — both `interface`s **and** the no-wire-prop `type` aliases — is paired with a mutual-assignability check in @@ -321,13 +359,16 @@ vacuous when either side is `any` — so the output is also grepped for `: any` 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. The extracted HTTP body +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` → `MeterPagePaginatedResponse`. This reuses the -already-emitted, already-guarded paginated interfaces (no synthesis). Net: ~72/83 -responses wired to interfaces, 10 void, 1 union on `z.output`. +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` 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 @@ -372,9 +413,9 @@ 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** -(the baseline that feeds `runtime-templates.ts` is not in the repo), so it lives -directly in `tests/` and is not re-emitted by `generate` — do not delete it -expecting a regen to restore it. +(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 @@ -382,15 +423,20 @@ Request/response types are direct TS, not `z.input`/`z.output`, and live in `models/operations/.ts` (re-exported from the barrel under their existing public names). The split mirrors the model types: -- **Response** → the documented output interface (or `void`, or the one - `z.output` union `GetAppResponse`). -- **Request body** → the body model's interface, or its **`…Input` variant** - when the body's input shape diverges from its output. A body 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; `interface-types.ts` emits an `XInput` interface - (relaxed optionality, refing child `YInput` variants) for each divergent model. - ~12 request bodies diverge directly; their closure is ~51 `…Input` interfaces. +- **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 `Query` interface walked from the query parameter leaves in input mode (in `models/operations/.ts`). - **Path** params are ULIDs → `string`. @@ -442,6 +488,94 @@ Every operation exists twice: a standalone func in `funcs/` returning `Result` (tree-shakeable, non-throwing) and a thin method on the namespace façade in `sdk/` that `unwrap`s and throws. Both call the same func. +### Pagination companions (`All`) + +Every page-number or cursor **list** operation gets a companion facade method +— `All` alongside `` (e.g. `client.meters.listAll()` next to +`client.meters.list()`) — that returns `AsyncIterable` 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` (`meta: +Common.PageMeta`, i.e. `{ page: { number, size, total } }`) and +`Shared.CursorPaginatedResponse` (`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>` 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. `*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` @@ -450,7 +584,7 @@ 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.summary`) +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`) @@ -519,15 +653,17 @@ not "correct" them to mainline ky. ## Tests The conformance tests (Vitest + `@fetch-mock/vitest`, matching the legacy SDK's -stack) are embedded in `runtime-templates.ts` and emitted into the generated -SDK. They are the generator's spec: it is "done" when these tests pass against -the emitted `aip-client-javascript` output. +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 — no baseline needed. The generated package -is never hand-edited; to change the runtime or tests, edit the restored baseline -and re-run `gen-runtime-templates.mjs` (see the layout note above). +`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 diff --git a/api/spec/Makefile b/api/spec/Makefile index 35da6c2b8d..8fc6e057cc 100644 --- a/api/spec/Makefile +++ b/api/spec/Makefile @@ -27,6 +27,13 @@ generate: ## Generate OpenAPI spec cp packages/legacy/output/openapi.OpenMeterCloud.yaml ../openapi.cloud.yaml cp packages/aip/output/definitions/metering-and-billing/v3/openapi.Test.yaml ../v3/test/openapi.test.yaml +.PHONY: test +test: ## Run AIP TypeScript SDK and emitter tests + $(call print-target) + pnpm --frozen-lockfile install + pnpm run test:sdk:coverage + pnpm --filter @openmeter/typespec-typescript run check + .PHONY: publish-aip-sdk publish-aip-sdk: ## Publish the AIP TypeScript SDK (@openmeter/client) to npm $(call print-target) @@ -46,6 +53,7 @@ publish-aip-sdk: ## Publish the AIP TypeScript SDK (@openmeter/client) to npm cd packages/aip-client-javascript && \ pnpm version "$${AIP_SDK_RELEASE_VERSION}" --no-git-tag-version && \ + node -e "const fs=require('node:fs');const p='src/lib/version.ts';const v=process.env.AIP_SDK_RELEASE_VERSION;fs.writeFileSync(p, fs.readFileSync(p,'utf8').replace(/SDK_VERSION = '[^']*'/, \"SDK_VERSION = '\" + v + \"'\"))" && \ CACHE_BUSTER="$$(date --rfc-3339=seconds)" pnpm publish --no-git-checks --access public --tag "$${AIP_SDK_RELEASE_TAG}" @echo "✅ Published $${AIP_SDK_RELEASE_TAG} AIP TypeScript SDK (@openmeter/client) version $${AIP_SDK_RELEASE_VERSION} with tag $${AIP_SDK_RELEASE_TAG}" diff --git a/api/spec/packages/aip-client-javascript/.npmignore b/api/spec/packages/aip-client-javascript/.npmignore deleted file mode 100644 index df82cc1522..0000000000 --- a/api/spec/packages/aip-client-javascript/.npmignore +++ /dev/null @@ -1,22 +0,0 @@ -# This is the active publish surface control for @openmeter/client: package.json -# has no "files" allowlist, so these patterns determine what ships. They keep dev -# files, source maps, and compile-time-only artifacts out of the published -# package, leaving the dist/ runtime and type declarations. - -# Generated conformance guards — compile-time only, no runtime value. -*.assert.* - -# Sources and dev tooling -src/ -tests/ -tsconfig.json -tsconfig.tests.json -tsconfig.tsbuildinfo -vitest.config.ts -.gitignore - -# Test coverage output -coverage/ - -# Source maps -*.map diff --git a/api/spec/packages/aip-client-javascript/LICENSE b/api/spec/packages/aip-client-javascript/LICENSE new file mode 100644 index 0000000000..d645695673 --- /dev/null +++ b/api/spec/packages/aip-client-javascript/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/api/spec/packages/aip-client-javascript/README.md b/api/spec/packages/aip-client-javascript/README.md index 83c419db12..5065e61920 100644 --- a/api/spec/packages/aip-client-javascript/README.md +++ b/api/spec/packages/aip-client-javascript/README.md @@ -1,11 +1,12 @@ + + # OpenMeter SDK TypeScript client for the OpenMeter API — usage metering and billing for AI and DevTool companies. This package is generated from the OpenMeter TypeSpec definitions and ships fully-typed request and response models. -> [!IMPORTANT] -> This SDK is a work in progress. +> **Important:** This SDK is a work in progress. > > This SDK targets the [OpenMeter API v3](https://openmeter.io/docs/api/v3), > a rewrite of the OpenMeter API following AIP (API Improvement Proposal) @@ -15,7 +16,9 @@ TypeSpec definitions and ships fully-typed request and response models. - [Installation](#installation) - [Initialization](#initialization) +- [Configuration](#configuration) - [Usage](#usage) +- [Pagination](#pagination) - [Available Resources and Operations](#available-resources-and-operations) - [Events](#events) - [Meters](#meters) @@ -24,16 +27,15 @@ TypeSpec definitions and ships fully-typed request and response models. - [Subscriptions](#subscriptions) - [Apps](#apps) - [Billing](#billing) - - [Invoices](#invoices) - [Tax](#tax) - - [Currencies](#currencies) - [Features](#features) - [LLMCost](#llmcost) - [Plans](#plans) - [Addons](#addons) - [PlanAddons](#planaddons) - [Defaults](#defaults) - - [Governance](#governance) +- [Runtime Validation (validate option)](#runtime-validation-validate-option) +- [Zod Schemas (./zod export)](#zod-schemas-zod-export) - [Error Handling](#error-handling) - [Standalone Functions](#standalone-functions) @@ -80,6 +82,76 @@ const client = new OpenMeter({ The `apiKey` may also be a function returning a `string` or `Promise`, so tokens can be refreshed per request. +## Configuration + +`SDKOptions` extends [ky](https://github.com/sindresorhus/ky)'s +`Options`, so every transport setting ky supports is a top-level client +option: retry policy, per-attempt timeout (ky defaults to 10 seconds), +lifecycle hooks, a custom `fetch`, and so on. + +```typescript +import { OpenMeter } from '@openmeter/client' + +const client = new OpenMeter({ + baseUrl: 'https://openmeter.cloud/api/v3', + apiKey: process.env.OPENMETER_API_KEY, + timeout: 30_000, + hooks: { + beforeRequest: [ + ({ request }) => { + console.log(`-> ${request.method} ${request.url}`) + }, + ], + }, + fetch: async (input, init) => { + const start = Date.now() + const response = await fetch(input, init) + console.log(`${response.status} in ${Date.now() - start}ms`) + return response + }, +}) +``` + +ky only retries the idempotent methods by default — `get`, `put`, +`head`, `delete`, `options`, `trace` — never `post`. That means a +dropped `client.events.ingest` call is not retried on a network error +or a 5xx response unless you opt in explicitly: + +```typescript +import { OpenMeter } from '@openmeter/client' + +const client = new OpenMeter({ + baseUrl: 'https://openmeter.cloud/api/v3', + apiKey: process.env.OPENMETER_API_KEY, + retry: { limit: 3, methods: ['get', 'put', 'head', 'delete', 'post'] }, +}) +``` + +This is safe specifically for event ingestion: the event `id` is its +deduplication key server-side, so resending the same event on retry is +a no-op rather than a duplicate. + +Every method also takes a per-request `RequestOptions` as its second +argument — a curated subset of ky's options (`signal`, `headers`, +`timeout`, `retry`) applied to that call only: + +```typescript +import { OpenMeter } from '@openmeter/client' + +const client = new OpenMeter({ + baseUrl: 'https://openmeter.cloud/api/v3', + apiKey: process.env.OPENMETER_API_KEY, +}) + +const controller = new AbortController() +setTimeout(() => controller.abort(), 5_000) + +const meters = await client.meters.list(undefined, { + signal: controller.signal, + headers: { 'X-Request-Id': 'batch-42' }, +}) +``` + ## Usage Every operation is reachable through a fluent, namespaced client and @@ -112,6 +184,70 @@ Responses return date-time fields as native `Date` objects (every either a `Date` or an RFC 3339 string — a meter query `from`/`to`, an ingested event's `time`, filter operands, all alike. +## Pagination + +Every list operation that returns pages also has an `…All` companion — +`client.meters.listAll(request?)` alongside `client.meters.list(request?)` — +that returns an `AsyncIterable` of items instead of one page. It fetches +each following page lazily, only when the previous page is exhausted, so a +`break` (or a `return`) partway through never fires a request for a page +nothing consumes: + +```typescript +import { OpenMeter } from '@openmeter/client' + +const client = new OpenMeter({ + baseUrl: 'https://openmeter.cloud/api/v3', + apiKey: process.env.OPENMETER_API_KEY, +}) + +for await (const meter of client.meters.listAll()) { + console.log(meter.key) +} +``` + +The request object accepts the same filters, sort, and page size as the +single-page method — only the page cursor/number itself advances between +requests: + +```typescript +for await (const meter of client.meters.listAll({ filter: { key: 'api' } })) { + if (meter.key === 'api-requests') { + break // stops iterating; no further pages are fetched + } +} +``` + +Cursor-paginated resources (`events`, credit transactions) work the same +way: + +```typescript +for await (const event of client.events.listAll()) { + console.log(event.event.id) +} +``` + +Auto-pagination takes the same optional `RequestOptions` as every other +method, so one `AbortSignal` cancels the whole iteration, not just the page +in flight: + +```typescript +const controller = new AbortController() +setTimeout(() => controller.abort(), 30_000) + +for await (const meter of client.meters.listAll(undefined, { + signal: controller.signal, +})) { + console.log(meter.key) +} +``` + +Iteration stops after the server’s last page — an empty or short-of-`size` +page for a page-number resource, or a response with no next cursor for a +cursor resource. A misbehaving server that never signals the end of the +list fails fast instead of looping forever: after 10,000 pages the +iterable throws `PaginationLimitExceededError`. + ## Available Resources and Operations Operations are grouped by resource and exposed as methods on the client. @@ -140,14 +276,14 @@ The full call path, HTTP route, and a short description are listed below. | Method | HTTP | Description | | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `client.customers.create` | `POST /openmeter/customers` | | -| `client.customers.get` | `GET /openmeter/customers/{customerId}` | | -| `client.customers.list` | `GET /openmeter/customers` | | -| `client.customers.upsert` | `PUT /openmeter/customers/{customerId}` | | -| `client.customers.delete` | `DELETE /openmeter/customers/{customerId}` | | -| `client.customers.billing.get` | `GET /openmeter/customers/{customerId}/billing` | | -| `client.customers.billing.update` | `PUT /openmeter/customers/{customerId}/billing` | | -| `client.customers.billing.updateAppData` | `PUT /openmeter/customers/{customerId}/billing/app-data` | | +| `client.customers.create` | `POST /openmeter/customers` | Create customer | +| `client.customers.get` | `GET /openmeter/customers/{customerId}` | Get customer | +| `client.customers.list` | `GET /openmeter/customers` | List customers | +| `client.customers.upsert` | `PUT /openmeter/customers/{customerId}` | Upsert customer | +| `client.customers.delete` | `DELETE /openmeter/customers/{customerId}` | Delete customer | +| `client.customers.billing.get` | `GET /openmeter/customers/{customerId}/billing` | Get customer billing data | +| `client.customers.billing.update` | `PUT /openmeter/customers/{customerId}/billing` | Update customer billing data | +| `client.customers.billing.updateAppData` | `PUT /openmeter/customers/{customerId}/billing/app-data` | Update customer billing app data | | `client.customers.billing.createStripeCheckoutSession` | `POST /openmeter/customers/{customerId}/billing/stripe/checkout-sessions` | Create a [Stripe Checkout Session](https://docs.stripe.com/payments/checkout) for the customer. Creates a Checkout Session for collecting payment method information from customers. The session operates in "setup" mode, which collects payment details without charging the customer immediately. The collected payment method can be used for future subscription billing. For hosted checkout sessions, redirect customers to the returned URL. For embedded sessions, use the client_secret to initialize Stripe.js in your application. | | `client.customers.billing.createStripePortalSession` | `POST /openmeter/customers/{customerId}/billing/stripe/portal-sessions` | Create Stripe Customer Portal Session. Useful to redirect the customer to the Stripe Customer Portal to manage their payment methods, change their billing address and access their invoice history. Only returns URL if the customer billing profile is linked to a stripe app and customer. | | `client.customers.credits.grants.create` | `POST /openmeter/customers/{customerId}/credits/grants` | Create a new credit grant. A credit grant represents an allocation of prepaid credits to a customer. | @@ -162,21 +298,20 @@ The full call path, HTTP route, and a short description are listed below. ### Entitlements -| Method | HTTP | Description | -| ---------------------------------------- | ---------------------------------------------------------- | ----------- | -| `client.entitlements.listCustomerAccess` | `GET /openmeter/customers/{customerId}/entitlement-access` | | +| Method | HTTP | Description | +| ---------------------------------------- | ---------------------------------------------------------- | -------------------------------- | +| `client.entitlements.listCustomerAccess` | `GET /openmeter/customers/{customerId}/entitlement-access` | List customer entitlement access | ### Subscriptions | Method | HTTP | Description | | -------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `client.subscriptions.create` | `POST /openmeter/subscriptions` | | -| `client.subscriptions.list` | `GET /openmeter/subscriptions` | | -| `client.subscriptions.get` | `GET /openmeter/subscriptions/{subscriptionId}` | | +| `client.subscriptions.create` | `POST /openmeter/subscriptions` | Create subscription | +| `client.subscriptions.list` | `GET /openmeter/subscriptions` | List subscriptions | +| `client.subscriptions.get` | `GET /openmeter/subscriptions/{subscriptionId}` | Get subscription | | `client.subscriptions.cancel` | `POST /openmeter/subscriptions/{subscriptionId}/cancel` | Cancels the subscription. Will result in a scheduling conflict if there are other subscriptions scheduled to start after the cancelation time. | | `client.subscriptions.unscheduleCancelation` | `POST /openmeter/subscriptions/{subscriptionId}/unschedule-cancelation` | Unschedules the subscription cancelation. | | `client.subscriptions.change` | `POST /openmeter/subscriptions/{subscriptionId}/change` | Closes a running subscription and starts a new one according to the specification. Can be used for upgrades, downgrades, and plan changes. | -| `client.subscriptions.createAddon` | `POST /openmeter/subscriptions/{subscriptionId}/addons` | Add add-on to a subscription. | | `client.subscriptions.listAddons` | `GET /openmeter/subscriptions/{subscriptionId}/addons` | List the add-ons of a subscription. | | `client.subscriptions.getAddon` | `GET /openmeter/subscriptions/{subscriptionId}/addons/{subscriptionAddonId}` | Get an add-on association for a subscription. | @@ -197,33 +332,15 @@ The full call path, HTTP route, and a short description are listed below. | `client.billing.updateProfile` | `PUT /openmeter/profiles/{id}` | Update a billing profile. | | `client.billing.deleteProfile` | `DELETE /openmeter/profiles/{id}` | Delete a billing profile. Only such billing profiles can be deleted that are: - not the default profile - not pinned to any customer using customer overrides - only have finalized invoices | -### Invoices - -| Method | HTTP | Description | -| ------------------------ | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `client.invoices.list` | `GET /openmeter/billing/invoices` | List billing invoices. Returns a page of invoices. Gathering invoices are never included. Use `filter` to narrow by status, customer, dates, or service period start. Use `sort` to control ordering. | -| `client.invoices.get` | `GET /openmeter/billing/invoices/{invoiceId}` | Get a billing invoice by ID. Returns the full invoice resource including line items, status details, totals, and workflow configuration snapshot. | -| `client.invoices.update` | `PUT /openmeter/billing/invoices/{invoiceId}` | Update a billing invoice. Only the mutable fields of the invoice can be edited: description, labels, supplier, customer, workflow settings, and top-level lines. Top-level lines are matched by `id`; lines without an `id` are created, and existing lines omitted from `lines` are deleted. Detailed (child) lines are always computed and cannot be edited directly. Only invoices in draft status can be updated. | -| `client.invoices.delete` | `DELETE /openmeter/billing/invoices/{invoiceId}` | Delete a billing invoice. Only standard invoices in draft status can be deleted. Deleting an invoice will also delete all associated line items and workflow configuration. | - ### Tax -| Method | HTTP | Description | -| ----------------------- | ----------------------------------------- | ----------- | -| `client.tax.createCode` | `POST /openmeter/tax-codes` | | -| `client.tax.getCode` | `GET /openmeter/tax-codes/{taxCodeId}` | | -| `client.tax.listCodes` | `GET /openmeter/tax-codes` | | -| `client.tax.upsertCode` | `PUT /openmeter/tax-codes/{taxCodeId}` | | -| `client.tax.deleteCode` | `DELETE /openmeter/tax-codes/{taxCodeId}` | | - -### Currencies - -| Method | HTTP | Description | -| ---------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `client.currencies.list` | `GET /openmeter/currencies` | List currencies supported by the billing system. | -| `client.currencies.createCustomCurrency` | `POST /openmeter/currencies/custom` | Create a custom currency. This operation allows defining your own custom currency for billing purposes. | -| `client.currencies.listCostBases` | `GET /openmeter/currencies/custom/{currencyId}/cost-bases` | List cost bases for a currency. For custom currencies, there can be multiple cost bases with different `effective_from` dates. | -| `client.currencies.createCostBasis` | `POST /openmeter/currencies/custom/{currencyId}/cost-bases` | Create a cost basis for a currency. | +| Method | HTTP | Description | +| ----------------------- | ----------------------------------------- | --------------- | +| `client.tax.createCode` | `POST /openmeter/tax-codes` | Create tax code | +| `client.tax.getCode` | `GET /openmeter/tax-codes/{taxCodeId}` | Get tax code | +| `client.tax.listCodes` | `GET /openmeter/tax-codes` | List tax codes | +| `client.tax.upsertCode` | `PUT /openmeter/tax-codes/{taxCodeId}` | Upsert tax code | +| `client.tax.deleteCode` | `DELETE /openmeter/tax-codes/{taxCodeId}` | Delete tax code | ### Features @@ -282,21 +399,85 @@ The full call path, HTTP route, and a short description are listed below. ### Defaults -| Method | HTTP | Description | -| -------------------------------------------- | ----------------------------------- | ----------- | -| `client.defaults.getOrganizationTaxCodes` | `GET /openmeter/defaults/tax-codes` | | -| `client.defaults.updateOrganizationTaxCodes` | `PUT /openmeter/defaults/tax-codes` | | +| Method | HTTP | Description | +| -------------------------------------------- | ----------------------------------- | ------------------------------------- | +| `client.defaults.getOrganizationTaxCodes` | `GET /openmeter/defaults/tax-codes` | Get organization default tax codes | +| `client.defaults.updateOrganizationTaxCodes` | `PUT /openmeter/defaults/tax-codes` | Update organization default tax codes | + +## Runtime Validation (validate option) + +`validate` is off by default. The SDK's normal request/response mapping +only renames keys and converts dates — it never rejects a payload — so +an additive server-side change (a new response field, a new enum value) +never breaks a client running an older SDK version. + +Set `validate: true` to additionally check the wire payload — the +request body after mapping to snake_case, and the raw response before +mapping back — against the generated `…Wire` schemas. Those schemas are +strict: an unknown field or an unrecognized enum value fails validation +instead of being silently accepted. That makes `validate` a tool for +catching SDK/server contract drift in development or CI, not something +to run in production, where forward compatibility with additive server +changes matters more than strict rejection. + +```typescript +import { OpenMeter, ValidationError } from '@openmeter/client' + +const client = new OpenMeter({ + baseUrl: 'https://openmeter.cloud/api/v3', + apiKey: process.env.OPENMETER_API_KEY, + validate: true, +}) + +try { + await client.meters.get({ meterId: 'meter_123' }) +} catch (error) { + if (error instanceof ValidationError) { + console.error(error.issues) + } +} +``` + +The standalone functions surface the same failure as a `Result` instead +of throwing — check `result.error instanceof ValidationError`. + +## Zod Schemas (./zod export) -### Governance +`@openmeter/client/zod` exports every model twice: once shaped like the +SDK's public surface (camelCase keys, `Date` for date-time fields — the +same shape `meter.eventType`/`meter.createdAt` have in TypeScript) and +once as a strict `…Wire` schema shaped like the literal JSON the server +sends and accepts (snake_case keys, RFC 3339 date-time strings, unknown +fields rejected) — the same `…Wire` schemas the `validate` option checks +internally. -| Method | HTTP | Description | -| ------------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `client.governance.queryAccess` | `POST /openmeter/governance/query` | Query feature access for a list of customers. The endpoint resolves each provided identifier to a customer and returns the access status for the requested features, plus optional credit balance availability. _Designed to be called on a fixed refresh interval and the query response is intended to be cached._ | +Use the public schemas to validate a payload the SDK did not produce — +a webhook body, a cached record, a test fixture — before trusting its +shape: + +```typescript +import * as schemas from '@openmeter/client/zod' + +const parsed = schemas.meter.safeParse({ + id: '01HZY3W6VXQK6H3NPC6DFA0PJT', + name: 'Tokens', + key: 'tokens', + aggregation: 'sum', + eventType: 'request', + createdAt: new Date(), + updatedAt: new Date(), +}) + +if (parsed.success) { + console.log(parsed.data.eventType) +} +``` ## Error Handling -A non-2xx response rejects with an `HTTPError` carrying the problem-details -fields (`status`, `type`, `title`, `url`) from the response. +A non-2xx response rejects with an `HTTPError` (`error.name === 'HTTPError'`) +carrying the problem-details fields (`status`, `type`, `title`, `url`) +from the response. ```typescript import { OpenMeter, HTTPError } from '@openmeter/client' @@ -315,6 +496,46 @@ try { } ``` +`error.retryAfter` is the delta-seconds form of a numeric `Retry-After` +header (the common case on 429 and 503 responses) and `undefined` +otherwise. A 400 Bad Request additionally carries a typed +`error.invalidParameters` array describing which fields failed +validation; `error.getField(key)` is an untyped escape hatch for any +other problem-details extension member: + +```typescript +import { OpenMeter, HTTPError } from '@openmeter/client' + +const client = new OpenMeter({ + baseUrl: 'https://openmeter.cloud/api/v3', + apiKey: process.env.OPENMETER_API_KEY, +}) + +try { + await client.meters.create({ + name: 'Tokens', + key: 'tokens', + aggregation: 'sum', + eventType: 'request', + }) +} catch (error) { + if (error instanceof HTTPError && error.status === 400) { + for (const param of error.invalidParameters ?? []) { + console.error(param) + } + } +} +``` + +The SDK's other typed errors are `ValidationError` (see +[Runtime Validation (validate option)](#runtime-validation-validate-option)), `UnsafeIntegerError` +(an `int64`/`uint64` value exceeds what JSON can represent without +precision loss), `DepthLimitExceededError` (response data nested deeper +than the mapper's safety limit), and `PaginationLimitExceededError` (an +[Pagination](#pagination) iterable exceeded +its page-count safety limit) — each distinguished the same way, by +`instanceof` or by `.name`. + ## Standalone Functions Every method is also available as a standalone, tree-shakeable function @@ -335,3 +556,18 @@ if (result.ok) { console.error(result.error) } ``` + +`ok`, `err`, and `unwrap` — the helpers `Result` is built from — are +exported too, so a func call can be unwrapped back into a throwing call +where that is more convenient: + +```typescript +import { Client, funcs, unwrap } from '@openmeter/client' + +const client = new Client({ + baseUrl: 'https://openmeter.cloud/api/v3', + apiKey: process.env.OPENMETER_API_KEY, +}) + +const meters = unwrap(await funcs.listMeters(client)) +``` diff --git a/api/spec/packages/aip-client-javascript/package.json b/api/spec/packages/aip-client-javascript/package.json index 8e4d24b3f1..6b75789f11 100644 --- a/api/spec/packages/aip-client-javascript/package.json +++ b/api/spec/packages/aip-client-javascript/package.json @@ -1,6 +1,16 @@ { "name": "@openmeter/client", "version": "1.0.0", + "description": "Official TypeScript SDK for the OpenMeter API — usage metering and billing", + "keywords": [ + "openmeter", + "metering", + "billing", + "usage-based-billing", + "entitlements", + "sdk", + "api-client" + ], "license": "Apache-2.0", "homepage": "https://openmeter.io", "repository": { @@ -8,10 +18,15 @@ "url": "git+https://github.com/openmeterio/openmeter.git", "directory": "api/spec/packages/aip-client-javascript" }, + "bugs": "https://github.com/openmeterio/openmeter/issues", "type": "module", + "engines": { + "node": ">=22" + }, + "sideEffects": false, "dependencies": { - "ky": "2.0.2", - "zod": "4.4.3" + "ky": "^2.0.2", + "zod": "^4.4.3" }, "devDependencies": { "@types/node": "25.9.2", @@ -22,6 +37,14 @@ "prepack": "tsc", "typecheck": "tsc --noEmit && tsc -p tsconfig.tests.json" }, + "files": [ + "dist", + "!dist/**/*.assert.*", + "!dist/**/*.map", + "!dist/**/*.tsbuildinfo" + ], + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { "./zod": { "types": "./dist/models/schemas.d.ts", @@ -30,6 +53,7 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" - } + }, + "./package.json": "./package.json" } } diff --git a/api/spec/packages/aip-client-javascript/src/core.ts b/api/spec/packages/aip-client-javascript/src/core.ts index 604188ade6..37464ed34d 100644 --- a/api/spec/packages/aip-client-javascript/src/core.ts +++ b/api/spec/packages/aip-client-javascript/src/core.ts @@ -1,6 +1,9 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import ky, { type KyInstance as HTTPClient } from 'ky' import { type SDKOptions } from './lib/config.js' import { encodePath } from './lib/encodings.js' +import { SDK_VERSION } from './lib/version.js' export class Client { readonly _options: SDKOptions @@ -26,6 +29,13 @@ export class Client { beforeRequest: [ ...(options.hooks?.beforeRequest ?? []), async ({ request }) => { + // Browsers treat User-Agent as a forbidden header and silently drop + // it; that's fine here, this is only observable server-side (e.g. + // request logs) and Node/server callers get it. + if (!request.headers.has('User-Agent')) { + request.headers.set('User-Agent', `openmeter-node/${SDK_VERSION}`) + } + const token = typeof options.apiKey === 'function' ? await options.apiKey() diff --git a/api/spec/packages/aip-client-javascript/src/funcs/addons.ts b/api/spec/packages/aip-client-javascript/src/funcs/addons.ts index db4158d6a9..e97224e3ed 100644 --- a/api/spec/packages/aip-client-javascript/src/funcs/addons.ts +++ b/api/spec/packages/aip-client-javascript/src/funcs/addons.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client, http } from '../core.js' import { type Result, type RequestOptions } from '../lib/types.js' import { request } from '../lib/request.js' @@ -21,6 +23,13 @@ import type { PublishAddonResponse, } from '../models/operations/addons.js' +/** + * List add-ons + * + * List all add-ons. + * + * GET /openmeter/addons + */ export function listAddons( client: Client, req: ListAddonsRequest = {}, @@ -51,6 +60,13 @@ export function listAddons( }) } +/** + * Create add-on + * + * Create a new add-on. + * + * POST /openmeter/addons + */ export function createAddon( client: Client, req: CreateAddonRequest, @@ -73,6 +89,13 @@ export function createAddon( }) } +/** + * Update add-on + * + * Update an add-on by id. + * + * PUT /openmeter/addons/{addonId} + */ export function updateAddon( client: Client, req: UpdateAddonRequest, @@ -101,6 +124,13 @@ export function updateAddon( }) } +/** + * Get add-on + * + * Get add-on by id. + * + * GET /openmeter/addons/{addonId} + */ export function getAddon( client: Client, req: GetAddonRequest, @@ -125,6 +155,13 @@ export function getAddon( }) } +/** + * Soft delete add-on + * + * Soft delete add-on by id. + * + * DELETE /openmeter/addons/{addonId} + */ export function deleteAddon( client: Client, req: DeleteAddonRequest, @@ -141,6 +178,13 @@ export function deleteAddon( }) } +/** + * Archive add-on version + * + * Archive an add-on version. + * + * POST /openmeter/addons/{addonId}/archive + */ export function archiveAddon( client: Client, req: ArchiveAddonRequest, @@ -165,6 +209,13 @@ export function archiveAddon( }) } +/** + * Publish add-on version + * + * Publish an add-on version. + * + * POST /openmeter/addons/{addonId}/publish + */ export function publishAddon( client: Client, req: PublishAddonRequest, diff --git a/api/spec/packages/aip-client-javascript/src/funcs/apps.ts b/api/spec/packages/aip-client-javascript/src/funcs/apps.ts index dd1ab31c57..6edfcb75c7 100644 --- a/api/spec/packages/aip-client-javascript/src/funcs/apps.ts +++ b/api/spec/packages/aip-client-javascript/src/funcs/apps.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client, http } from '../core.js' import { type Result, type RequestOptions } from '../lib/types.js' import { request } from '../lib/request.js' @@ -11,6 +13,13 @@ import type { GetAppResponse, } from '../models/operations/apps.js' +/** + * List apps + * + * List installed apps. + * + * GET /openmeter/apps + */ export function listApps( client: Client, req: ListAppsRequest = {}, @@ -39,6 +48,13 @@ export function listApps( }) } +/** + * Get app + * + * Get an installed app. + * + * GET /openmeter/apps/{appId} + */ export function getApp( client: Client, req: GetAppRequest, diff --git a/api/spec/packages/aip-client-javascript/src/funcs/billing.ts b/api/spec/packages/aip-client-javascript/src/funcs/billing.ts index c656423ff8..e7d6f6207d 100644 --- a/api/spec/packages/aip-client-javascript/src/funcs/billing.ts +++ b/api/spec/packages/aip-client-javascript/src/funcs/billing.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client, http } from '../core.js' import { type Result, type RequestOptions } from '../lib/types.js' import { request } from '../lib/request.js' @@ -17,6 +19,13 @@ import type { DeleteBillingProfileResponse, } from '../models/operations/billing.js' +/** + * List billing profiles + * + * List billing profiles. + * + * GET /openmeter/profiles + */ export function listBillingProfiles( client: Client, req: ListBillingProfilesRequest = {}, @@ -45,6 +54,18 @@ export function listBillingProfiles( }) } +/** + * Create a new billing profile + * + * Create a new billing profile. + * + * Billing profiles contain the settings for billing and controls invoice + * generation. An organization can have multiple billing profiles defined. A + * billing profile is linked to a specific app. This association is established + * during the billing profile's creation and remains immutable. + * + * POST /openmeter/profiles + */ export function createBillingProfile( client: Client, req: CreateBillingProfileRequest, @@ -67,6 +88,13 @@ export function createBillingProfile( }) } +/** + * Get a billing profile + * + * Get a billing profile. + * + * GET /openmeter/profiles/{id} + */ export function getBillingProfile( client: Client, req: GetBillingProfileRequest, @@ -91,6 +119,13 @@ export function getBillingProfile( }) } +/** + * Update a billing profile + * + * Update a billing profile. + * + * PUT /openmeter/profiles/{id} + */ export function updateBillingProfile( client: Client, req: UpdateBillingProfileRequest, @@ -119,6 +154,19 @@ export function updateBillingProfile( }) } +/** + * Delete a billing profile + * + * Delete a billing profile. + * + * Only such billing profiles can be deleted that are: + * + * - not the default profile + * - not pinned to any customer using customer overrides + * - only have finalized invoices + * + * DELETE /openmeter/profiles/{id} + */ export function deleteBillingProfile( client: Client, req: DeleteBillingProfileRequest, diff --git a/api/spec/packages/aip-client-javascript/src/funcs/currencies.ts b/api/spec/packages/aip-client-javascript/src/funcs/currencies.ts deleted file mode 100644 index 758f3be546..0000000000 --- a/api/spec/packages/aip-client-javascript/src/funcs/currencies.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { type Client, http } from '../core.js' -import { type Result, type RequestOptions } from '../lib/types.js' -import { request } from '../lib/request.js' -import { toURLSearchParams, encodeSort } from '../lib/encodings.js' -import { toWire, fromWire, assertValid, toSnakeCase } from '../lib/wire.js' -import * as schemas from '../models/schemas.js' -import type { - ListCurrenciesRequest, - ListCurrenciesResponse, - CreateCustomCurrencyRequest, - CreateCustomCurrencyResponse, - ListCostBasesRequest, - ListCostBasesResponse, - CreateCostBasisRequest, - CreateCostBasisResponse, -} from '../models/operations/currencies.js' - -export function listCurrencies( - client: Client, - req: ListCurrenciesRequest = {}, - options?: RequestOptions, -): Promise> { - return request(() => { - const query = toWire( - { - page: req.page, - sort: encodeSort(req.sort, toSnakeCase), - filter: req.filter, - }, - schemas.listCurrenciesQueryParams, - ) - if (client._options.validate) { - assertValid(schemas.listCurrenciesQueryParamsWire, query) - } - const searchParams = toURLSearchParams(query) - return http(client) - .get('openmeter/currencies', { ...options, searchParams }) - .json() - .then((data) => { - if (client._options.validate) { - assertValid(schemas.listCurrenciesResponseWire, data) - } - return fromWire(data, schemas.listCurrenciesResponse) - }) - }) -} - -export function createCustomCurrency( - client: Client, - req: CreateCustomCurrencyRequest, - options?: RequestOptions, -): Promise> { - return request(() => { - const body = toWire(req, schemas.createCustomCurrencyBody) - if (client._options.validate) { - assertValid(schemas.createCustomCurrencyBodyWire, body) - } - return http(client) - .post('openmeter/currencies/custom', { ...options, json: body }) - .json() - .then((data) => { - if (client._options.validate) { - assertValid(schemas.createCustomCurrencyResponseWire, data) - } - return fromWire(data, schemas.createCustomCurrencyResponse) - }) - }) -} - -export function listCostBases( - client: Client, - req: ListCostBasesRequest, - options?: RequestOptions, -): Promise> { - return request(() => { - const path = `openmeter/currencies/custom/${(() => { - if (req.currencyId === undefined) { - throw new Error('missing path parameter: currencyId') - } - return encodeURIComponent(String(req.currencyId)) - })()}/cost-bases` - const query = toWire( - { - filter: req.filter, - page: req.page, - }, - schemas.listCostBasesQueryParams, - ) - if (client._options.validate) { - assertValid(schemas.listCostBasesQueryParamsWire, query) - } - const searchParams = toURLSearchParams(query) - return http(client) - .get(path, { ...options, searchParams }) - .json() - .then((data) => { - if (client._options.validate) { - assertValid(schemas.listCostBasesResponseWire, data) - } - return fromWire(data, schemas.listCostBasesResponse) - }) - }) -} - -export function createCostBasis( - client: Client, - req: CreateCostBasisRequest, - options?: RequestOptions, -): Promise> { - return request(() => { - const path = `openmeter/currencies/custom/${(() => { - if (req.currencyId === undefined) { - throw new Error('missing path parameter: currencyId') - } - return encodeURIComponent(String(req.currencyId)) - })()}/cost-bases` - const body = toWire(req.body, schemas.createCostBasisBody) - if (client._options.validate) { - assertValid(schemas.createCostBasisBodyWire, body) - } - return http(client) - .post(path, { ...options, json: body }) - .json() - .then((data) => { - if (client._options.validate) { - assertValid(schemas.createCostBasisResponseWire, data) - } - return fromWire(data, schemas.createCostBasisResponse) - }) - }) -} diff --git a/api/spec/packages/aip-client-javascript/src/funcs/customers.ts b/api/spec/packages/aip-client-javascript/src/funcs/customers.ts index cc403c0934..bef89f27ba 100644 --- a/api/spec/packages/aip-client-javascript/src/funcs/customers.ts +++ b/api/spec/packages/aip-client-javascript/src/funcs/customers.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client, http } from '../core.js' import { type Result, type RequestOptions } from '../lib/types.js' import { request } from '../lib/request.js' @@ -45,6 +47,11 @@ import type { CreateCustomerChargesResponse, } from '../models/operations/customers.js' +/** + * Create customer + * + * POST /openmeter/customers + */ export function createCustomer( client: Client, req: CreateCustomerRequest, @@ -67,6 +74,11 @@ export function createCustomer( }) } +/** + * Get customer + * + * GET /openmeter/customers/{customerId} + */ export function getCustomer( client: Client, req: GetCustomerRequest, @@ -91,6 +103,11 @@ export function getCustomer( }) } +/** + * List customers + * + * GET /openmeter/customers + */ export function listCustomers( client: Client, req: ListCustomersRequest = {}, @@ -121,6 +138,11 @@ export function listCustomers( }) } +/** + * Upsert customer + * + * PUT /openmeter/customers/{customerId} + */ export function upsertCustomer( client: Client, req: UpsertCustomerRequest, @@ -149,6 +171,11 @@ export function upsertCustomer( }) } +/** + * Delete customer + * + * DELETE /openmeter/customers/{customerId} + */ export function deleteCustomer( client: Client, req: DeleteCustomerRequest, @@ -165,6 +192,11 @@ export function deleteCustomer( }) } +/** + * Get customer billing data + * + * GET /openmeter/customers/{customerId}/billing + */ export function getCustomerBilling( client: Client, req: GetCustomerBillingRequest, @@ -189,6 +221,11 @@ export function getCustomerBilling( }) } +/** + * Update customer billing data + * + * PUT /openmeter/customers/{customerId}/billing + */ export function updateCustomerBilling( client: Client, req: UpdateCustomerBillingRequest, @@ -217,6 +254,11 @@ export function updateCustomerBilling( }) } +/** + * Update customer billing app data + * + * PUT /openmeter/customers/{customerId}/billing/app-data + */ export function updateCustomerBillingAppData( client: Client, req: UpdateCustomerBillingAppDataRequest, @@ -245,6 +287,23 @@ export function updateCustomerBillingAppData( }) } +/** + * Create Stripe Checkout Session + * + * Create a [Stripe Checkout Session](https://docs.stripe.com/payments/checkout) + * for the customer. + * + * Creates a Checkout Session for collecting payment method information from + * customers. The session operates in "setup" mode, which collects payment details + * without charging the customer immediately. The collected payment method can be + * used for future subscription billing. + * + * For hosted checkout sessions, redirect customers to the returned URL. For + * embedded sessions, use the client_secret to initialize Stripe.js in your + * application. + * + * POST /openmeter/customers/{customerId}/billing/stripe/checkout-sessions + */ export function createCustomerStripeCheckoutSession( client: Client, req: CreateCustomerStripeCheckoutSessionRequest, @@ -282,6 +341,18 @@ export function createCustomerStripeCheckoutSession( }) } +/** + * Create Stripe customer portal session + * + * Create Stripe Customer Portal Session. + * + * Useful to redirect the customer to the Stripe Customer Portal to manage their + * payment methods, change their billing address and access their invoice history. + * Only returns URL if the customer billing profile is linked to a stripe app and + * customer. + * + * POST /openmeter/customers/{customerId}/billing/stripe/portal-sessions + */ export function createCustomerStripePortalSession( client: Client, req: CreateCustomerStripePortalSessionRequest, @@ -313,6 +384,14 @@ export function createCustomerStripePortalSession( }) } +/** + * Create a new credit grant + * + * Create a new credit grant. A credit grant represents an allocation of prepaid + * credits to a customer. + * + * POST /openmeter/customers/{customerId}/credits/grants + */ export function createCreditGrant( client: Client, req: CreateCreditGrantRequest, @@ -341,6 +420,13 @@ export function createCreditGrant( }) } +/** + * Get a credit grant + * + * Get a credit grant. + * + * GET /openmeter/customers/{customerId}/credits/grants/{creditGrantId} + */ export function getCreditGrant( client: Client, req: GetCreditGrantRequest, @@ -370,6 +456,13 @@ export function getCreditGrant( }) } +/** + * List credit grants + * + * List credit grants. + * + * GET /openmeter/customers/{customerId}/credits/grants + */ export function listCreditGrants( client: Client, req: ListCreditGrantsRequest, @@ -405,6 +498,13 @@ export function listCreditGrants( }) } +/** + * Get a customer's credit balance + * + * Get a credit balance. + * + * GET /openmeter/customers/{customerId}/credits/balance + */ export function getCustomerCreditBalance( client: Client, req: GetCustomerCreditBalanceRequest, @@ -440,6 +540,18 @@ export function getCustomerCreditBalance( }) } +/** + * Create a credit adjustment + * + * A credit adjustment can be used to make manual adjustments to a customer's + * credit balance. + * + * Supported use-cases: + * + * - Usage correction + * + * POST /openmeter/customers/{customerId}/credits/adjustments + */ export function createCreditAdjustment( client: Client, req: CreateCreditAdjustmentRequest, @@ -468,6 +580,16 @@ export function createCreditAdjustment( }) } +/** + * Update credit grant external settlement status + * + * Update the payment settlement status of an externally funded credit grant. + * + * Use this endpoint to synchronize the payment state of an external payment with + * the system so that revenue recognition and credit availability work as expected. + * + * POST /openmeter/customers/{customerId}/credits/grants/{creditGrantId}/settlement/external + */ export function updateCreditGrantExternalSettlement( client: Client, req: UpdateCreditGrantExternalSettlementRequest, @@ -510,6 +632,17 @@ export function updateCreditGrantExternalSettlement( }) } +/** + * List credit transactions + * + * List credit transactions for a customer. + * + * Returns an immutable, chronological record of credit movements: funded credits + * and consumed credits. Transactions are returned in reverse chronological order + * by default. + * + * GET /openmeter/customers/{customerId}/credits/transactions + */ export function listCreditTransactions( client: Client, req: ListCreditTransactionsRequest, @@ -545,6 +678,16 @@ export function listCreditTransactions( }) } +/** + * List customer charges + * + * List customer charges. + * + * Returns the customer's charges that are represented as either flat fee or + * usage-based charges. + * + * GET /openmeter/customers/{customerId}/charges + */ export function listCustomerCharges( client: Client, req: ListCustomerChargesRequest, @@ -582,6 +725,13 @@ export function listCustomerCharges( }) } +/** + * Create customer charge + * + * Create customer charge. + * + * POST /openmeter/customers/{customerId}/charges + */ export function createCustomerCharges( client: Client, req: CreateCustomerChargesRequest, diff --git a/api/spec/packages/aip-client-javascript/src/funcs/defaults.ts b/api/spec/packages/aip-client-javascript/src/funcs/defaults.ts index b61b13d19d..97d59b1f44 100644 --- a/api/spec/packages/aip-client-javascript/src/funcs/defaults.ts +++ b/api/spec/packages/aip-client-javascript/src/funcs/defaults.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client, http } from '../core.js' import { type Result, type RequestOptions } from '../lib/types.js' import { request } from '../lib/request.js' @@ -10,6 +12,11 @@ import type { UpdateOrganizationDefaultTaxCodesResponse, } from '../models/operations/defaults.js' +/** + * Get organization default tax codes + * + * GET /openmeter/defaults/tax-codes + */ export function getOrganizationDefaultTaxCodes( client: Client, req: GetOrganizationDefaultTaxCodesRequest, @@ -28,6 +35,11 @@ export function getOrganizationDefaultTaxCodes( ) } +/** + * Update organization default tax codes + * + * PUT /openmeter/defaults/tax-codes + */ export function updateOrganizationDefaultTaxCodes( client: Client, req: UpdateOrganizationDefaultTaxCodesRequest, diff --git a/api/spec/packages/aip-client-javascript/src/funcs/entitlements.ts b/api/spec/packages/aip-client-javascript/src/funcs/entitlements.ts index 576bb6f06d..d32accaafd 100644 --- a/api/spec/packages/aip-client-javascript/src/funcs/entitlements.ts +++ b/api/spec/packages/aip-client-javascript/src/funcs/entitlements.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client, http } from '../core.js' import { type Result, type RequestOptions } from '../lib/types.js' import { request } from '../lib/request.js' @@ -8,6 +10,11 @@ import type { ListCustomerEntitlementAccessResponse, } from '../models/operations/entitlements.js' +/** + * List customer entitlement access + * + * GET /openmeter/customers/{customerId}/entitlement-access + */ export function listCustomerEntitlementAccess( client: Client, req: ListCustomerEntitlementAccessRequest, diff --git a/api/spec/packages/aip-client-javascript/src/funcs/events.ts b/api/spec/packages/aip-client-javascript/src/funcs/events.ts index f4734d4e63..543726598e 100644 --- a/api/spec/packages/aip-client-javascript/src/funcs/events.ts +++ b/api/spec/packages/aip-client-javascript/src/funcs/events.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client, http } from '../core.js' import { type Result, type RequestOptions } from '../lib/types.js' import { request } from '../lib/request.js' @@ -11,6 +13,13 @@ import type { IngestMeteringEventsResponse, } from '../models/operations/events.js' +/** + * List metering events + * + * List ingested events. + * + * GET /openmeter/events + */ export function listMeteringEvents( client: Client, req: ListMeteringEventsRequest = {}, @@ -41,6 +50,13 @@ export function listMeteringEvents( }) } +/** + * Ingest metering events + * + * Ingests an event or batch of events following the CloudEvents specification. + * + * POST /openmeter/events + */ export function ingestMeteringEvents( client: Client, req: IngestMeteringEventsRequest, diff --git a/api/spec/packages/aip-client-javascript/src/funcs/features.ts b/api/spec/packages/aip-client-javascript/src/funcs/features.ts index 1238b2a7e9..a8e776197c 100644 --- a/api/spec/packages/aip-client-javascript/src/funcs/features.ts +++ b/api/spec/packages/aip-client-javascript/src/funcs/features.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client, http } from '../core.js' import { type Result, type RequestOptions } from '../lib/types.js' import { request } from '../lib/request.js' @@ -19,6 +21,13 @@ import type { QueryFeatureCostResponse, } from '../models/operations/features.js' +/** + * List features + * + * List all features. + * + * GET /openmeter/features + */ export function listFeatures( client: Client, req: ListFeaturesRequest = {}, @@ -49,6 +58,13 @@ export function listFeatures( }) } +/** + * Create feature + * + * Create a feature. + * + * POST /openmeter/features + */ export function createFeature( client: Client, req: CreateFeatureRequest, @@ -71,6 +87,13 @@ export function createFeature( }) } +/** + * Get feature + * + * Get a feature by id. + * + * GET /openmeter/features/{featureId} + */ export function getFeature( client: Client, req: GetFeatureRequest, @@ -95,6 +118,13 @@ export function getFeature( }) } +/** + * Update feature + * + * Update a feature by id. Currently only the unit_cost field can be updated. + * + * PATCH /openmeter/features/{featureId} + */ export function updateFeature( client: Client, req: UpdateFeatureRequest, @@ -123,6 +153,13 @@ export function updateFeature( }) } +/** + * Delete feature + * + * Delete a feature by id. + * + * DELETE /openmeter/features/{featureId} + */ export function deleteFeature( client: Client, req: DeleteFeatureRequest, @@ -139,6 +176,13 @@ export function deleteFeature( }) } +/** + * Query feature cost + * + * Query the cost of a feature. + * + * POST /openmeter/features/{featureId}/cost/query + */ export function queryFeatureCost( client: Client, req: QueryFeatureCostRequest, diff --git a/api/spec/packages/aip-client-javascript/src/funcs/governance.ts b/api/spec/packages/aip-client-javascript/src/funcs/governance.ts deleted file mode 100644 index 5749d143f1..0000000000 --- a/api/spec/packages/aip-client-javascript/src/funcs/governance.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { type Client, http } from '../core.js' -import { type Result, type RequestOptions } from '../lib/types.js' -import { request } from '../lib/request.js' -import { toURLSearchParams, encodeSort } from '../lib/encodings.js' -import { toWire, fromWire, assertValid } from '../lib/wire.js' -import * as schemas from '../models/schemas.js' -import type { - QueryGovernanceAccessRequest, - QueryGovernanceAccessResponse, -} from '../models/operations/governance.js' - -export function queryGovernanceAccess( - client: Client, - req: QueryGovernanceAccessRequest, - options?: RequestOptions, -): Promise> { - return request(() => { - const body = toWire(req.body, schemas.queryGovernanceAccessBody) - if (client._options.validate) { - assertValid(schemas.queryGovernanceAccessBodyWire, body) - } - const query = toWire( - { - page: req.page, - }, - schemas.queryGovernanceAccessQueryParams, - ) - if (client._options.validate) { - assertValid(schemas.queryGovernanceAccessQueryParamsWire, query) - } - const searchParams = toURLSearchParams(query) - return http(client) - .post('openmeter/governance/query', { - ...options, - searchParams, - json: body, - }) - .json() - .then((data) => { - if (client._options.validate) { - assertValid(schemas.queryGovernanceAccessResponseWire, data) - } - return fromWire(data, schemas.queryGovernanceAccessResponse) - }) - }) -} diff --git a/api/spec/packages/aip-client-javascript/src/funcs/index.ts b/api/spec/packages/aip-client-javascript/src/funcs/index.ts index fb1f6ed3e8..0de4ce8a97 100644 --- a/api/spec/packages/aip-client-javascript/src/funcs/index.ts +++ b/api/spec/packages/aip-client-javascript/src/funcs/index.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + export * from './events.js' export * from './meters.js' export * from './customers.js' @@ -5,13 +7,10 @@ export * from './entitlements.js' export * from './subscriptions.js' export * from './apps.js' export * from './billing.js' -export * from './invoices.js' export * from './tax.js' -export * from './currencies.js' export * from './features.js' export * from './llmCost.js' export * from './plans.js' export * from './addons.js' export * from './planAddons.js' export * from './defaults.js' -export * from './governance.js' diff --git a/api/spec/packages/aip-client-javascript/src/funcs/invoices.ts b/api/spec/packages/aip-client-javascript/src/funcs/invoices.ts deleted file mode 100644 index fb78181492..0000000000 --- a/api/spec/packages/aip-client-javascript/src/funcs/invoices.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { type Client, http } from '../core.js' -import { type Result, type RequestOptions } from '../lib/types.js' -import { request } from '../lib/request.js' -import { toURLSearchParams, encodeSort } from '../lib/encodings.js' -import { toWire, fromWire, assertValid, toSnakeCase } from '../lib/wire.js' -import * as schemas from '../models/schemas.js' -import type { - ListInvoicesRequest, - ListInvoicesResponse, - GetInvoiceRequest, - GetInvoiceResponse, - UpdateInvoiceRequest, - UpdateInvoiceResponse, - DeleteInvoiceRequest, - DeleteInvoiceResponse, -} from '../models/operations/invoices.js' - -export function listInvoices( - client: Client, - req: ListInvoicesRequest = {}, - options?: RequestOptions, -): Promise> { - return request(() => { - const query = toWire( - { - page: req.page, - sort: encodeSort(req.sort, toSnakeCase), - filter: req.filter, - }, - schemas.listInvoicesQueryParams, - ) - if (client._options.validate) { - assertValid(schemas.listInvoicesQueryParamsWire, query) - } - const searchParams = toURLSearchParams(query) - return http(client) - .get('openmeter/billing/invoices', { ...options, searchParams }) - .json() - .then((data) => { - if (client._options.validate) { - assertValid(schemas.listInvoicesResponseWire, data) - } - return fromWire(data, schemas.listInvoicesResponse) - }) - }) -} - -export function getInvoice( - client: Client, - req: GetInvoiceRequest, - options?: RequestOptions, -): Promise> { - return request(() => { - const path = `openmeter/billing/invoices/${(() => { - if (req.invoiceId === undefined) { - throw new Error('missing path parameter: invoiceId') - } - return encodeURIComponent(String(req.invoiceId)) - })()}` - return http(client) - .get(path, options) - .json() - .then((data) => { - if (client._options.validate) { - assertValid(schemas.getInvoiceResponseWire, data) - } - return fromWire(data, schemas.getInvoiceResponse) - }) - }) -} - -export function updateInvoice( - client: Client, - req: UpdateInvoiceRequest, - options?: RequestOptions, -): Promise> { - return request(() => { - const path = `openmeter/billing/invoices/${(() => { - if (req.invoiceId === undefined) { - throw new Error('missing path parameter: invoiceId') - } - return encodeURIComponent(String(req.invoiceId)) - })()}` - const body = toWire(req.body, schemas.updateInvoiceBody) - if (client._options.validate) { - assertValid(schemas.updateInvoiceBodyWire, body) - } - return http(client) - .put(path, { ...options, json: body }) - .json() - .then((data) => { - if (client._options.validate) { - assertValid(schemas.updateInvoiceResponseWire, data) - } - return fromWire(data, schemas.updateInvoiceResponse) - }) - }) -} - -export function deleteInvoice( - client: Client, - req: DeleteInvoiceRequest, - options?: RequestOptions, -): Promise> { - return request(async () => { - const path = `openmeter/billing/invoices/${(() => { - if (req.invoiceId === undefined) { - throw new Error('missing path parameter: invoiceId') - } - return encodeURIComponent(String(req.invoiceId)) - })()}` - await http(client).delete(path, options) - }) -} diff --git a/api/spec/packages/aip-client-javascript/src/funcs/llmCost.ts b/api/spec/packages/aip-client-javascript/src/funcs/llmCost.ts index e0ba3ec420..ef5bdd3458 100644 --- a/api/spec/packages/aip-client-javascript/src/funcs/llmCost.ts +++ b/api/spec/packages/aip-client-javascript/src/funcs/llmCost.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client, http } from '../core.js' import { type Result, type RequestOptions } from '../lib/types.js' import { request } from '../lib/request.js' @@ -17,6 +19,13 @@ import type { DeleteLlmCostOverrideResponse, } from '../models/operations/llmCost.js' +/** + * List LLM cost prices + * + * List global LLM cost prices. Returns prices with overrides applied if any. + * + * GET /openmeter/llm-cost/prices + */ export function listLlmCostPrices( client: Client, req: ListLlmCostPricesRequest = {}, @@ -47,6 +56,14 @@ export function listLlmCostPrices( }) } +/** + * Get LLM cost price + * + * Get a specific LLM cost price by ID. Returns the price with overrides applied if + * any. + * + * GET /openmeter/llm-cost/prices/{priceId} + */ export function getLlmCostPrice( client: Client, req: GetLlmCostPriceRequest, @@ -71,6 +88,13 @@ export function getLlmCostPrice( }) } +/** + * List LLM cost overrides + * + * List per-namespace price overrides. + * + * GET /openmeter/llm-cost/overrides + */ export function listLlmCostOverrides( client: Client, req: ListLlmCostOverridesRequest = {}, @@ -100,6 +124,13 @@ export function listLlmCostOverrides( }) } +/** + * Create LLM cost override + * + * Create a per-namespace price override. + * + * POST /openmeter/llm-cost/overrides + */ export function createLlmCostOverride( client: Client, req: CreateLlmCostOverrideRequest, @@ -122,6 +153,13 @@ export function createLlmCostOverride( }) } +/** + * Delete LLM cost override + * + * Delete a per-namespace price override. + * + * DELETE /openmeter/llm-cost/overrides/{priceId} + */ export function deleteLlmCostOverride( client: Client, req: DeleteLlmCostOverrideRequest, diff --git a/api/spec/packages/aip-client-javascript/src/funcs/meters.ts b/api/spec/packages/aip-client-javascript/src/funcs/meters.ts index c73cedc064..050ae88431 100644 --- a/api/spec/packages/aip-client-javascript/src/funcs/meters.ts +++ b/api/spec/packages/aip-client-javascript/src/funcs/meters.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client, http } from '../core.js' import { type Result, type RequestOptions } from '../lib/types.js' import { request } from '../lib/request.js' @@ -21,6 +23,13 @@ import type { QueryMeterCsvResponse, } from '../models/operations/meters.js' +/** + * Create meter + * + * Create a meter. + * + * POST /openmeter/meters + */ export function createMeter( client: Client, req: CreateMeterRequest, @@ -43,6 +52,13 @@ export function createMeter( }) } +/** + * Get meter + * + * Get a meter by ID. + * + * GET /openmeter/meters/{meterId} + */ export function getMeter( client: Client, req: GetMeterRequest, @@ -67,6 +83,13 @@ export function getMeter( }) } +/** + * List meters + * + * List meters. + * + * GET /openmeter/meters + */ export function listMeters( client: Client, req: ListMetersRequest = {}, @@ -97,6 +120,13 @@ export function listMeters( }) } +/** + * Update meter + * + * Update a meter. + * + * PUT /openmeter/meters/{meterId} + */ export function updateMeter( client: Client, req: UpdateMeterRequest, @@ -125,6 +155,13 @@ export function updateMeter( }) } +/** + * Delete meter + * + * Delete a meter. + * + * DELETE /openmeter/meters/{meterId} + */ export function deleteMeter( client: Client, req: DeleteMeterRequest, @@ -141,6 +178,23 @@ export function deleteMeter( }) } +/** + * Query meter + * + * Query a meter for usage. + * + * Set `Accept: application/json` (the default) to get a structured JSON response. + * Set `Accept: text/csv` to download the same data as a CSV file suitable for + * spreadsheets. The CSV columns, in order, are: + * + * `from, to, [subject,] [customer_id, customer_key, customer_name,] , value` + * + * The `subject` column is emitted only when `subject` is in the query's + * `group_by_dimensions`. The three `customer_*` columns are emitted together only + * when `customer_id` is in the query's `group_by_dimensions`. + * + * POST /openmeter/meters/{meterId}/query + */ export function queryMeter( client: Client, req: QueryMeterRequest, @@ -169,6 +223,7 @@ export function queryMeter( }) } +/** POST /openmeter/meters/{meterId}/query */ export function queryMeterCsv( client: Client, req: QueryMeterCsvRequest, diff --git a/api/spec/packages/aip-client-javascript/src/funcs/planAddons.ts b/api/spec/packages/aip-client-javascript/src/funcs/planAddons.ts index abfc102974..198eccd6c6 100644 --- a/api/spec/packages/aip-client-javascript/src/funcs/planAddons.ts +++ b/api/spec/packages/aip-client-javascript/src/funcs/planAddons.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client, http } from '../core.js' import { type Result, type RequestOptions } from '../lib/types.js' import { request } from '../lib/request.js' @@ -17,6 +19,13 @@ import type { DeletePlanAddonResponse, } from '../models/operations/planAddons.js' +/** + * List add-ons for plan + * + * List add-ons associated with a plan. + * + * GET /openmeter/plans/{planId}/addons + */ export function listPlanAddons( client: Client, req: ListPlanAddonsRequest, @@ -51,6 +60,13 @@ export function listPlanAddons( }) } +/** + * Add add-on to plan + * + * Add an add-on to a plan. + * + * POST /openmeter/plans/{planId}/addons + */ export function createPlanAddon( client: Client, req: CreatePlanAddonRequest, @@ -79,6 +95,13 @@ export function createPlanAddon( }) } +/** + * Get add-on association for plan + * + * Get an add-on association for a plan. + * + * GET /openmeter/plans/{planId}/addons/{planAddonId} + */ export function getPlanAddon( client: Client, req: GetPlanAddonRequest, @@ -108,6 +131,13 @@ export function getPlanAddon( }) } +/** + * Update add-on association for plan + * + * Update an add-on association for a plan. + * + * PUT /openmeter/plans/{planId}/addons/{planAddonId} + */ export function updatePlanAddon( client: Client, req: UpdatePlanAddonRequest, @@ -141,6 +171,13 @@ export function updatePlanAddon( }) } +/** + * Remove add-on from plan + * + * Remove an add-on from a plan. + * + * DELETE /openmeter/plans/{planId}/addons/{planAddonId} + */ export function deletePlanAddon( client: Client, req: DeletePlanAddonRequest, diff --git a/api/spec/packages/aip-client-javascript/src/funcs/plans.ts b/api/spec/packages/aip-client-javascript/src/funcs/plans.ts index 6609d32b59..61dfbbb89c 100644 --- a/api/spec/packages/aip-client-javascript/src/funcs/plans.ts +++ b/api/spec/packages/aip-client-javascript/src/funcs/plans.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client, http } from '../core.js' import { type Result, type RequestOptions } from '../lib/types.js' import { request } from '../lib/request.js' @@ -21,6 +23,13 @@ import type { PublishPlanResponse, } from '../models/operations/plans.js' +/** + * List plans + * + * List all plans. + * + * GET /openmeter/plans + */ export function listPlans( client: Client, req: ListPlansRequest = {}, @@ -51,6 +60,13 @@ export function listPlans( }) } +/** + * Create plan + * + * Create a new plan. + * + * POST /openmeter/plans + */ export function createPlan( client: Client, req: CreatePlanRequest, @@ -73,6 +89,13 @@ export function createPlan( }) } +/** + * Update plan + * + * Update a plan by id. + * + * PUT /openmeter/plans/{planId} + */ export function updatePlan( client: Client, req: UpdatePlanRequest, @@ -101,6 +124,13 @@ export function updatePlan( }) } +/** + * Get plan + * + * Get a plan by id. + * + * GET /openmeter/plans/{planId} + */ export function getPlan( client: Client, req: GetPlanRequest, @@ -125,6 +155,13 @@ export function getPlan( }) } +/** + * Delete plan + * + * Delete a plan by id. + * + * DELETE /openmeter/plans/{planId} + */ export function deletePlan( client: Client, req: DeletePlanRequest, @@ -141,6 +178,13 @@ export function deletePlan( }) } +/** + * Archive plan version + * + * Archive a plan version. + * + * POST /openmeter/plans/{planId}/archive + */ export function archivePlan( client: Client, req: ArchivePlanRequest, @@ -165,6 +209,13 @@ export function archivePlan( }) } +/** + * Publish plan version + * + * Publish a plan version. + * + * POST /openmeter/plans/{planId}/publish + */ export function publishPlan( client: Client, req: PublishPlanRequest, diff --git a/api/spec/packages/aip-client-javascript/src/funcs/subscriptions.ts b/api/spec/packages/aip-client-javascript/src/funcs/subscriptions.ts index 3ec2cad3e5..2f04071c47 100644 --- a/api/spec/packages/aip-client-javascript/src/funcs/subscriptions.ts +++ b/api/spec/packages/aip-client-javascript/src/funcs/subscriptions.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client, http } from '../core.js' import { type Result, type RequestOptions } from '../lib/types.js' import { request } from '../lib/request.js' @@ -17,14 +19,17 @@ import type { UnscheduleCancelationResponse, ChangeSubscriptionRequest, ChangeSubscriptionResponse, - CreateSubscriptionAddonRequest, - CreateSubscriptionAddonResponse, ListSubscriptionAddonsRequest, ListSubscriptionAddonsResponse, GetSubscriptionAddonRequest, GetSubscriptionAddonResponse, } from '../models/operations/subscriptions.js' +/** + * Create subscription + * + * POST /openmeter/subscriptions + */ export function createSubscription( client: Client, req: CreateSubscriptionRequest, @@ -47,6 +52,11 @@ export function createSubscription( }) } +/** + * List subscriptions + * + * GET /openmeter/subscriptions + */ export function listSubscriptions( client: Client, req: ListSubscriptionsRequest = {}, @@ -77,6 +87,11 @@ export function listSubscriptions( }) } +/** + * Get subscription + * + * GET /openmeter/subscriptions/{subscriptionId} + */ export function getSubscription( client: Client, req: GetSubscriptionRequest, @@ -101,6 +116,14 @@ export function getSubscription( }) } +/** + * Cancel subscription + * + * Cancels the subscription. Will result in a scheduling conflict if there are + * other subscriptions scheduled to start after the cancelation time. + * + * POST /openmeter/subscriptions/{subscriptionId}/cancel + */ export function cancelSubscription( client: Client, req: CancelSubscriptionRequest, @@ -129,6 +152,13 @@ export function cancelSubscription( }) } +/** + * Unschedule subscription cancelation + * + * Unschedules the subscription cancelation. + * + * POST /openmeter/subscriptions/{subscriptionId}/unschedule-cancelation + */ export function unscheduleCancelation( client: Client, req: UnscheduleCancelationRequest, @@ -153,6 +183,14 @@ export function unscheduleCancelation( }) } +/** + * Change subscription + * + * Closes a running subscription and starts a new one according to the + * specification. Can be used for upgrades, downgrades, and plan changes. + * + * POST /openmeter/subscriptions/{subscriptionId}/change + */ export function changeSubscription( client: Client, req: ChangeSubscriptionRequest, @@ -181,34 +219,13 @@ export function changeSubscription( }) } -export function createSubscriptionAddon( - client: Client, - req: CreateSubscriptionAddonRequest, - options?: RequestOptions, -): Promise> { - return request(() => { - const path = `openmeter/subscriptions/${(() => { - if (req.subscriptionId === undefined) { - throw new Error('missing path parameter: subscriptionId') - } - return encodeURIComponent(String(req.subscriptionId)) - })()}/addons` - const body = toWire(req.body, schemas.createSubscriptionAddonBody) - if (client._options.validate) { - assertValid(schemas.createSubscriptionAddonBodyWire, body) - } - return http(client) - .post(path, { ...options, json: body }) - .json() - .then((data) => { - if (client._options.validate) { - assertValid(schemas.createSubscriptionAddonResponseWire, data) - } - return fromWire(data, schemas.createSubscriptionAddonResponse) - }) - }) -} - +/** + * List subscription addons + * + * List the add-ons of a subscription. + * + * GET /openmeter/subscriptions/{subscriptionId}/addons + */ export function listSubscriptionAddons( client: Client, req: ListSubscriptionAddonsRequest, @@ -244,6 +261,13 @@ export function listSubscriptionAddons( }) } +/** + * Get add-on association for subscription + * + * Get an add-on association for a subscription. + * + * GET /openmeter/subscriptions/{subscriptionId}/addons/{subscriptionAddonId} + */ export function getSubscriptionAddon( client: Client, req: GetSubscriptionAddonRequest, diff --git a/api/spec/packages/aip-client-javascript/src/funcs/tax.ts b/api/spec/packages/aip-client-javascript/src/funcs/tax.ts index 1cd5571629..8105551beb 100644 --- a/api/spec/packages/aip-client-javascript/src/funcs/tax.ts +++ b/api/spec/packages/aip-client-javascript/src/funcs/tax.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client, http } from '../core.js' import { type Result, type RequestOptions } from '../lib/types.js' import { request } from '../lib/request.js' @@ -17,6 +19,11 @@ import type { DeleteTaxCodeResponse, } from '../models/operations/tax.js' +/** + * Create tax code + * + * POST /openmeter/tax-codes + */ export function createTaxCode( client: Client, req: CreateTaxCodeRequest, @@ -39,6 +46,11 @@ export function createTaxCode( }) } +/** + * Get tax code + * + * GET /openmeter/tax-codes/{taxCodeId} + */ export function getTaxCode( client: Client, req: GetTaxCodeRequest, @@ -63,6 +75,11 @@ export function getTaxCode( }) } +/** + * List tax codes + * + * GET /openmeter/tax-codes + */ export function listTaxCodes( client: Client, req: ListTaxCodesRequest = {}, @@ -92,6 +109,11 @@ export function listTaxCodes( }) } +/** + * Upsert tax code + * + * PUT /openmeter/tax-codes/{taxCodeId} + */ export function upsertTaxCode( client: Client, req: UpsertTaxCodeRequest, @@ -120,6 +142,11 @@ export function upsertTaxCode( }) } +/** + * Delete tax code + * + * DELETE /openmeter/tax-codes/{taxCodeId} + */ export function deleteTaxCode( client: Client, req: DeleteTaxCodeRequest, diff --git a/api/spec/packages/aip-client-javascript/src/index.ts b/api/spec/packages/aip-client-javascript/src/index.ts index aa879807fe..5e64f493d7 100644 --- a/api/spec/packages/aip-client-javascript/src/index.ts +++ b/api/spec/packages/aip-client-javascript/src/index.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + export { OpenMeter } from './sdk/sdk.js' export { Events } from './sdk/events.js' export { Meters } from './sdk/meters.js' @@ -6,23 +8,26 @@ export { Entitlements } from './sdk/entitlements.js' export { Subscriptions } from './sdk/subscriptions.js' export { Apps } from './sdk/apps.js' export { Billing } from './sdk/billing.js' -export { Invoices } from './sdk/invoices.js' export { Tax } from './sdk/tax.js' -export { Currencies } from './sdk/currencies.js' export { Features } from './sdk/features.js' export { LLMCost } from './sdk/llmCost.js' export { Plans } from './sdk/plans.js' export { Addons } from './sdk/addons.js' export { PlanAddons } from './sdk/planAddons.js' export { Defaults } from './sdk/defaults.js' -export { Governance } from './sdk/governance.js' export { Client } from './core.js' export { HTTPError } from './models/errors.js' -export { ValidationError, DepthLimitExceededError } from './lib/wire.js' +export { + ValidationError, + DepthLimitExceededError, + UnsafeIntegerError, +} from './lib/wire.js' export type { AcceptDateStrings, DateString } from './lib/wire.js' +export { PaginationLimitExceededError } from './lib/paginate.js' export { ServerList, Regions } from './lib/config.js' export type { SDKOptions, Region, ServerVariables } from './lib/config.js' +export { unwrap, ok, err } from './lib/types.js' export type { Result, RequestOptions } from './lib/types.js' export { @@ -41,16 +46,13 @@ export type * from './models/operations/entitlements.js' export type * from './models/operations/subscriptions.js' export type * from './models/operations/apps.js' export type * from './models/operations/billing.js' -export type * from './models/operations/invoices.js' export type * from './models/operations/tax.js' -export type * from './models/operations/currencies.js' export type * from './models/operations/features.js' export type * from './models/operations/llmCost.js' export type * from './models/operations/plans.js' export type * from './models/operations/addons.js' export type * from './models/operations/planAddons.js' export type * from './models/operations/defaults.js' -export type * from './models/operations/governance.js' export type { Labels, @@ -95,7 +97,6 @@ export type { SystemAccountAccessToken, PersonalAccessToken, KonnectAccessToken, - UpdateMeterRequest, AppCustomerDataStripe, AppCustomerDataExternalInvoicing, CurrencyFiat, @@ -164,7 +165,6 @@ export type { CreditGrantFilters, UpsertPlanAddonRequest, ResourceWithKey, - CreateMeterRequest, Meter, PaginatedMeta, QueryFilterStringMapItem, @@ -183,7 +183,6 @@ export type { RateCardMeteredEntitlement, RecurringPeriod, CreditGrantPurchase, - UpdateCreditGrantExternalSettlementRequest, ListCreditGrantsParamsFilter, GetCreditBalanceParamsFilter, ListChargesParamsFilter, @@ -212,7 +211,6 @@ export type { UpsertAppCustomerDataRequest, CreditAdjustment, CreditBalance, - CreateCreditAdjustmentRequest, ListCreditTransactionsParamsFilter, CreditTransaction, PriceTier, @@ -230,9 +228,7 @@ export type { TaxConfig, RateCardTaxConfig, OrganizationDefaultTaxCodes, - UpdateOrganizationDefaultTaxCodesRequest, PlanAddon, - CreatePlanAddonRequest, ProfileAppReferences, InvoiceWorkflowAppsReferences, UpdateRateCardTaxConfig, @@ -247,9 +243,7 @@ export type { CostBasisPagePaginatedResponse, MeterQueryFilters, FeatureMeterReference, - CreateCustomerRequest, Customer, - UpsertCustomerRequest, PartyAddresses, InvoiceCustomer, UpdateBillingPartyAddresses, @@ -267,9 +261,7 @@ export type { AppStripe, AppSandbox, AppExternalInvoicing, - CreateTaxCodeRequest, TaxCode, - UpsertTaxCodeRequest, InvoiceWorkflow, InvoiceStatusDetails, InvoiceLineDiscounts, @@ -284,7 +276,6 @@ export type { UpdatePriceGraduated, UpdatePriceVolume, PricePagePaginatedResponse, - CreateCreditGrantRequest, CreditGrant, CreateChargeFlatFeeRequest, WorkflowTaxSettings, @@ -304,8 +295,6 @@ export type { CurrencyPagePaginatedResponse, GovernanceQueryResult, Feature, - CreateFeatureRequest, - UpdateFeatureRequest, CreditGrantPagePaginatedResponse, BadRequest, InvoiceBase, @@ -326,16 +315,13 @@ export type { SubscriptionAddonRateCard, PlanPhase, Addon, - CreateAddonRequest, UpsertAddonRequest, InvoiceStandardLine, UpdateInvoiceStandardLine, Profile, - CreateBillingProfileRequest, UpsertBillingProfileRequest, SubscriptionAddon, Plan, - CreatePlanRequest, UpsertPlanRequest, AddonPagePaginatedResponse, ProfilePagePaginatedResponse, @@ -345,6 +331,25 @@ export type { InvoiceStandard, UpdateInvoiceStandardRequest, InvoicePagePaginatedResponse, + StringFieldFilter, + MeterAggregation, + MeterQueryGranularity, + StringFieldFilterExact, + PricePaymentTerm, + BillingCurrencyCode, + CreateCurrencyCode, + UlidFieldFilter, + DateTimeFieldFilter, + SubscriptionEditTiming, + WorkflowPaymentSettings, + InvalidParameter, + RateCardEntitlement, + FeatureUnitCost, + WorkflowCollectionAlignment, + App, + Price, + CreateChargeRequest, + Charge, SortQueryInput, BaseErrorInput, WorkflowPaymentSendInvoiceSettingsInput, @@ -412,4 +417,6 @@ export type { InvoiceStandardInput, UpdateInvoiceStandardRequestInput, InvoicePagePaginatedResponseInput, + WorkflowPaymentSettingsInput, + RateCardEntitlementInput, } from './models/types.js' diff --git a/api/spec/packages/aip-client-javascript/src/lib/config.ts b/api/spec/packages/aip-client-javascript/src/lib/config.ts index 84d3e13b40..ec64536264 100644 --- a/api/spec/packages/aip-client-javascript/src/lib/config.ts +++ b/api/spec/packages/aip-client-javascript/src/lib/config.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Options } from 'ky' export const ServerList = [ diff --git a/api/spec/packages/aip-client-javascript/src/lib/encodings.ts b/api/spec/packages/aip-client-javascript/src/lib/encodings.ts index 0c86120e27..9e093f16c1 100644 --- a/api/spec/packages/aip-client-javascript/src/lib/encodings.ts +++ b/api/spec/packages/aip-client-javascript/src/lib/encodings.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + export function encodePath( template: string, params: Record, diff --git a/api/spec/packages/aip-client-javascript/src/lib/paginate.ts b/api/spec/packages/aip-client-javascript/src/lib/paginate.ts new file mode 100644 index 0000000000..d435cdd78b --- /dev/null +++ b/api/spec/packages/aip-client-javascript/src/lib/paginate.ts @@ -0,0 +1,99 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + +import { unwrap, type RequestOptions, type Result } from './types.js' + +// A misbehaving server — inconsistent `meta.page.total`/`size`, or a +// `meta.page.next` cursor that never resolves to an empty page — would +// otherwise make these generators loop forever. 10,000 pages is far beyond +// any real OpenMeter list, so hitting it means the server's pagination +// contract broke, not that the caller has a legitimately large list. +const MAX_PAGINATION_PAGES = 10_000 + +export class PaginationLimitExceededError extends Error { + constructor(limit: number) { + super( + `pagination exceeded the maximum of ${limit} pages; the server may be returning inconsistent pagination metadata`, + ) + this.name = 'PaginationLimitExceededError' + } +} + +interface PageNumberEnvelope { + data: TItem[] + meta: { page: { number: number; size: number; total: number } } +} + +interface PageNumberRequest { + page?: { number?: number; size?: number } +} + +/** + * Iterates every item of a page-number-paginated list operation, advancing + * `page.number` from wherever the caller's own request starts. Every other + * field on `request` — filters, sort, page.size — is sent unchanged on every + * page fetch; only the page number advances. Stops on a page shorter than + * the server's own reported `size` (including an empty page) or once the + * running item count reaches the server's reported `total`, whichever comes + * first — so a final page that exactly fills `total` does not trigger one + * more, empty request. + */ +export async function* paginatePages( + fetchPage: ( + req: TReq, + options?: RequestOptions, + ) => Promise>>, + request: TReq, + options?: RequestOptions, +): AsyncGenerator { + let req = request + let seen = 0 + for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const res = unwrap(await fetchPage(req, options)) + yield* res.data + seen += res.data.length + const { number, size, total } = res.meta.page + if (res.data.length === 0 || res.data.length < size || seen >= total) { + return + } + req = { ...req, page: { ...req.page, number: number + 1 } } as TReq + } + throw new PaginationLimitExceededError(MAX_PAGINATION_PAGES) +} + +interface CursorEnvelope { + data: TItem[] + meta: { page: { next?: string } } +} + +interface CursorRequest { + page?: { after?: string; before?: string; size?: number } +} + +/** + * Iterates every item of a cursor-paginated list operation, following + * `meta.page.next` until the server stops returning one. Despite carrying a + * `format: uri` annotation in the spec, `next` is the opaque cursor token the + * API expects back verbatim in `page.after` on the following request — not a + * URI to fetch directly. Every other field on `request` is sent unchanged on + * every page fetch; only `page.after` advances. + */ +export async function* paginateCursor( + fetchPage: ( + req: TReq, + options?: RequestOptions, + ) => Promise>>, + request: TReq, + options?: RequestOptions, +): AsyncGenerator { + let req = request + for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const res = unwrap(await fetchPage(req, options)) + yield* res.data + const next = res.meta.page.next + if (!next) { + return + } + req = { ...req, page: { ...req.page, after: next } } as TReq + } + throw new PaginationLimitExceededError(MAX_PAGINATION_PAGES) +} diff --git a/api/spec/packages/aip-client-javascript/src/lib/request.ts b/api/spec/packages/aip-client-javascript/src/lib/request.ts index f21e299d98..aa6cc533b5 100644 --- a/api/spec/packages/aip-client-javascript/src/lib/request.ts +++ b/api/spec/packages/aip-client-javascript/src/lib/request.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Result, ok, err } from './types.js' import { toError } from './to-error.js' diff --git a/api/spec/packages/aip-client-javascript/src/lib/to-error.ts b/api/spec/packages/aip-client-javascript/src/lib/to-error.ts index eace195d19..cad387e9a0 100644 --- a/api/spec/packages/aip-client-javascript/src/lib/to-error.ts +++ b/api/spec/packages/aip-client-javascript/src/lib/to-error.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { HTTPError as KyHTTPError } from 'ky' import * as schemas from '../models/schemas.js' import { HTTPError } from '../models/errors.js' diff --git a/api/spec/packages/aip-client-javascript/src/lib/types.ts b/api/spec/packages/aip-client-javascript/src/lib/types.ts index c397259ec0..c553e70429 100644 --- a/api/spec/packages/aip-client-javascript/src/lib/types.ts +++ b/api/spec/packages/aip-client-javascript/src/lib/types.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import type { Options } from 'ky' export type RequestOptions = Pick< diff --git a/api/spec/packages/aip-client-javascript/src/lib/version.ts b/api/spec/packages/aip-client-javascript/src/lib/version.ts new file mode 100644 index 0000000000..934202f02c --- /dev/null +++ b/api/spec/packages/aip-client-javascript/src/lib/version.ts @@ -0,0 +1,6 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + +// The committed value is a dev placeholder. The publish flow +// (`make -C api/spec publish-aip-sdk`) stamps the real release version here +// before `pnpm publish`, after `pnpm version` updates package.json. +export const SDK_VERSION = '0.0.0' diff --git a/api/spec/packages/aip-client-javascript/src/lib/wire.ts b/api/spec/packages/aip-client-javascript/src/lib/wire.ts index 11786b449a..88adc4dff5 100644 --- a/api/spec/packages/aip-client-javascript/src/lib/wire.ts +++ b/api/spec/packages/aip-client-javascript/src/lib/wire.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import type { output, ZodType } from 'zod' export function toCamelCase(name: string): string { @@ -16,6 +18,7 @@ type ZodDef = { discriminator?: string options?: ZodType[] entries?: Record + defaultValue?: unknown } function def(schema: ZodType | undefined): ZodDef | undefined { @@ -45,6 +48,40 @@ function unwrap(schema: ZodType | undefined): ZodType | undefined { return current } +// The default to write for a field the caller omitted, present only when the +// field is required-with-default in the spec — emitted as `.default(x)` with no +// `.optional()` in the wrapper chain (TypeSpec `field: T = x`, not +// `field?: T = x`). Such fields must be present on the wire (e.g. a CloudEvents +// envelope's `specversion`, which the server's parser rejects when absent), yet +// the generated request type allows omitting them so callers get the documented +// default for free — toWire is the layer that has to reconcile the two. +// Spec-optional fields (`.optional().default(x)`) return undefined and stay off +// the wire: their default is the server's to apply, and materializing them +// client-side would silently overwrite server state on update requests. +function requiredDefault( + schema: ZodType | undefined, +): { value: unknown } | undefined { + let current = schema + let found: { value: unknown } | undefined + for (let i = 0; i < 100 && current; i++) { + const d = def(current) + /* v8 ignore next 3 -- every zod schema carries a def; guards the type only */ + if (!d) { + break + } + if (d.type === 'optional') { + return undefined + } + if (d.type === 'default') { + found ??= { value: d.defaultValue } + } else if (d.type !== 'nullable') { + break + } + current = d.innerType + } + return found +} + function shapeOf( schema: ZodType | undefined, ): Record | undefined { @@ -82,7 +119,12 @@ function needsWalk(schema: ZodType | undefined): boolean { if (!d) { return false } - if (d.type === 'object' || d.type === 'record' || d.type === 'date') { + if ( + d.type === 'object' || + d.type === 'record' || + d.type === 'date' || + d.type === 'bigint' + ) { return true } if (d.type === 'array') { @@ -94,6 +136,21 @@ function needsWalk(schema: ZodType | undefined): boolean { return false } +// Thrown by toWire when a bigint value (an int64/uint64 field) is outside +// JSON's exactly-representable integer range. The wire carries int64 as a JSON +// number (the server decodes it into a Go int64), so a value beyond 2^53-1 +// cannot be sent without silent precision corruption — a typed, immediate +// failure is the only honest option. request() catches it like any Error and +// surfaces it as Result.error. +export class UnsafeIntegerError extends Error { + constructor(value: bigint) { + super( + `bigint value ${value} exceeds JSON's safe integer range and cannot be sent without precision loss`, + ) + this.name = 'UnsafeIntegerError' + } +} + // An RFC 3339 string standing in for a `Date` on request input. The // `Record` intersection keeps a plain string assignable while // stopping the union simplifier from absorbing sibling string literals — a @@ -138,6 +195,17 @@ type Direction = { // string, wire string → `Date`. Values already in the target form (or not // convertible) pass through unchanged. mapDate: (value: unknown) => unknown + // The value mapping at a bigint-typed (int64/uint64) node: public `bigint` → + // JSON number (throwing UnsafeIntegerError beyond 2^53-1, where JSON numbers + // lose integer precision), wire number → `bigint`. Without the public→wire + // mapping, JSON.stringify throws an opaque TypeError on any bigint. Values + // already in the target form (or not convertible) pass through unchanged. + mapBigInt: (value: unknown) => unknown + // Whether absent required-with-default fields are materialized (see + // requiredDefault). True only public→wire: requests must satisfy the wire + // contract, while responses are reported as the server sent them — fromWire + // fabricating fields would mask genuine contract violations. + applyDefaults: boolean } // A handful of schemas are genuinely self-referential (e.g. the `and`/`or` @@ -169,10 +237,15 @@ function walk( // A Date can only ever mean its wire serialization, wherever it sits — a // typed date field, a record value, or an unknown-schema position. Wire→ // public data never contains Date instances (it comes from JSON.parse), so - // this only rewrites public→wire. + // this only rewrites public→wire. The same holds for bigint: JSON.parse + // never produces one, and public→wire it must become a JSON number wherever + // it sits. if (data instanceof Date) { return dir.mapDate(data) } + if (typeof data === 'bigint') { + return dir.mapBigInt(data) + } if (depth > MAX_WALK_DEPTH) { throw new DepthLimitExceededError() } @@ -184,6 +257,12 @@ function walk( if (d?.type === 'date') { return dir.mapDate(data) } + // A bigint-typed node revives the wire's JSON number into the public + // `bigint` (public→wire bigints were already mapped by the value check + // above, so only fromWire reaches a number here). + if (d?.type === 'bigint') { + return dir.mapBigInt(data) + } if (Array.isArray(data)) { // The schema may be the array itself or a union with an array variant // (e.g. a single-or-batch body `T | T[]`); resolve the element schema from @@ -251,6 +330,20 @@ function walk( } out[dir.rename(key)] = walk(value, fieldSchema, dir, depth + 1) } + if (dir.applyDefaults) { + // Shape keys are generated camelCase identifiers (never data-controlled), + // so direct indexing into `record` is safe here. Runs after the data loop + // so an explicit `key: undefined` entry is also replaced by the default. + for (const [key, fieldSchema] of Object.entries(shape)) { + if (record[key] !== undefined) { + continue + } + const dflt = requiredDefault(fieldSchema) + if (dflt !== undefined) { + out[dir.rename(key)] = walk(dflt.value, fieldSchema, dir, depth + 1) + } + } + } Object.setPrototypeOf(out, Object.prototype) return out } @@ -358,17 +451,37 @@ const toWireDirection: Direction = { rename: toSnakeCase, discriminatorKey: (camelKey) => camelKey, mapDate: (value) => (value instanceof Date ? value.toISOString() : value), + mapBigInt: (value) => { + if (typeof value !== 'bigint') { + return value + } + if ( + value > BigInt(Number.MAX_SAFE_INTEGER) || + value < -BigInt(Number.MAX_SAFE_INTEGER) + ) { + throw new UnsafeIntegerError(value) + } + return Number(value) + }, + applyDefaults: true, } const fromWireDirection: Direction = { rename: toCamelCase, discriminatorKey: (camelKey) => toSnakeCase(camelKey), mapDate: (value) => (typeof value === 'string' ? new Date(value) : value), + mapBigInt: (value) => + typeof value === 'number' && Number.isInteger(value) + ? BigInt(value) + : value, + applyDefaults: false, } // Rewrite a request body or query object from the camelCase public shape to the // snake_case wire shape, driven by its schema. Record keys (label/dimension names) -// are preserved; `Date` values serialize to RFC 3339 strings. The return is typed +// are preserved; `Date` values serialize to RFC 3339 strings; `bigint` values +// (int64 fields) become JSON numbers; omitted required-with-default fields are +// filled with their declared default (see requiredDefault). The return is typed // as the input `T` so call sites stay cast-free (the runtime object has snake keys // and wire-encoded dates, but the value is write-only — it flows straight into // `json:`/`toURLSearchParams`, both of which accept any object). diff --git a/api/spec/packages/aip-client-javascript/src/models/errors.ts b/api/spec/packages/aip-client-javascript/src/models/errors.ts index 4a435095e8..17f07dace6 100644 --- a/api/spec/packages/aip-client-javascript/src/models/errors.ts +++ b/api/spec/packages/aip-client-javascript/src/models/errors.ts @@ -1,6 +1,19 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { z } from 'zod' import * as schemas from './schemas.js' +// Retry-After is either delta-seconds (an integer) or an HTTP-date. Only the +// delta-seconds form is parsed; HTTP-date values yield undefined rather than +// a parsed Date, keeping the public type a plain number. +function parseRetryAfter(header: string | null): number | undefined { + if (header === null) { + return undefined + } + const trimmed = header.trim() + return /^\d+$/.test(trimmed) ? Number(trimmed) : undefined +} + export class HTTPError extends Error { constructor( message: string, @@ -8,15 +21,19 @@ export class HTTPError extends Error { public title: string, public status: number, public url: string, + public retryAfter: number | undefined, protected __raw?: Record, ) { super(message) + this.name = 'HTTPError' } static fromResponse(resp: { response: Response error?: z.infer }) { + const retryAfter = parseRetryAfter(resp.response.headers.get('Retry-After')) + if ( resp.response.headers .get('Content-Type') @@ -29,6 +46,7 @@ export class HTTPError extends Error { resp.error.title, resp.error.status ?? resp.response.status, resp.response.url, + retryAfter, resp.error, ) } @@ -39,9 +57,28 @@ export class HTTPError extends Error { resp.response.statusText, resp.response.status, resp.response.url, + retryAfter, ) } + /** + * The `invalid_parameters` problem-detail extension member, present on Bad + * Request (400) responses. Typed from the schema, not runtime-validated + * (like the rest of the SDK, this trusts the server rather than rejecting + * additive fields) — `undefined` when the response didn't carry one. + */ + get invalidParameters(): + | z.infer + | undefined { + return this.__raw?.invalid_parameters as + | z.infer + | undefined + } + + /** + * Escape hatch for problem-detail extension members without a typed + * accessor above. + */ getField(key: string) { return this.__raw?.[key] } diff --git a/api/spec/packages/aip-client-javascript/src/models/operations/addons.ts b/api/spec/packages/aip-client-javascript/src/models/operations/addons.ts index 9985308470..79434394f6 100644 --- a/api/spec/packages/aip-client-javascript/src/models/operations/addons.ts +++ b/api/spec/packages/aip-client-javascript/src/models/operations/addons.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { z } from 'zod' import * as schemas from '../schemas.js' import type { AcceptDateStrings } from '../../lib/wire.js' diff --git a/api/spec/packages/aip-client-javascript/src/models/operations/apps.ts b/api/spec/packages/aip-client-javascript/src/models/operations/apps.ts index 63a029f5cb..803235ccbe 100644 --- a/api/spec/packages/aip-client-javascript/src/models/operations/apps.ts +++ b/api/spec/packages/aip-client-javascript/src/models/operations/apps.ts @@ -1,7 +1,9 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { z } from 'zod' import * as schemas from '../schemas.js' import type { AcceptDateStrings } from '../../lib/wire.js' -import type { AppPagePaginatedResponse } from '../types.js' +import type { App, AppPagePaginatedResponse } from '../types.js' export interface ListAppsQuery { /** Determines which page of the collection to retrieve. */ @@ -14,4 +16,4 @@ export type ListAppsResponse = AppPagePaginatedResponse export type GetAppRequest = { appId: string } -export type GetAppResponse = z.output +export type GetAppResponse = App diff --git a/api/spec/packages/aip-client-javascript/src/models/operations/billing.ts b/api/spec/packages/aip-client-javascript/src/models/operations/billing.ts index 59d06253a5..93fe43f455 100644 --- a/api/spec/packages/aip-client-javascript/src/models/operations/billing.ts +++ b/api/spec/packages/aip-client-javascript/src/models/operations/billing.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { z } from 'zod' import * as schemas from '../schemas.js' import type { AcceptDateStrings } from '../../lib/wire.js' diff --git a/api/spec/packages/aip-client-javascript/src/models/operations/currencies.ts b/api/spec/packages/aip-client-javascript/src/models/operations/currencies.ts deleted file mode 100644 index 1d6402bca2..0000000000 --- a/api/spec/packages/aip-client-javascript/src/models/operations/currencies.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { z } from 'zod' -import * as schemas from '../schemas.js' -import type { AcceptDateStrings } from '../../lib/wire.js' -import type { - CostBasis, - CostBasisPagePaginatedResponse, - CreateCostBasisRequest as CreateCostBasisRequestBody, - CreateCurrencyCustomRequest, - CurrencyCustom, - CurrencyPagePaginatedResponse, - ListCostBasesParamsFilter, - ListCurrenciesParamsFilter, - SortQueryInput, -} from '../types.js' - -export interface ListCurrenciesQuery { - /** Determines which page of the collection to retrieve. */ - page?: { size?: number; number?: number } - /** - * Sort currencies returned in the response. Supported sort attributes are: - * - * - `code` (default) - * - `name` - * - * The `asc` suffix is optional as the default sort order is ascending. The `desc` - * suffix is used to specify a descending order. - */ - sort?: SortQueryInput - /** - * Filter currencies returned in the response. - * - * To filter currencies by type add the following query param: filter[type]=custom - */ - filter?: ListCurrenciesParamsFilter -} - -export type ListCurrenciesRequest = AcceptDateStrings -export type ListCurrenciesResponse = CurrencyPagePaginatedResponse - -export type CreateCustomCurrencyRequest = - AcceptDateStrings -export type CreateCustomCurrencyResponse = CurrencyCustom - -export interface ListCostBasesQuery { - /** - * Filter cost bases returned in the response. - * - * To filter cost bases by fiat currency code add the following query param: - * filter[fiat_code]=USD - */ - filter?: ListCostBasesParamsFilter - /** Determines which page of the collection to retrieve. */ - page?: { size?: number; number?: number } -} - -export type ListCostBasesRequest = AcceptDateStrings< - ListCostBasesQuery & { currencyId: string } -> -export type ListCostBasesResponse = CostBasisPagePaginatedResponse - -export type CreateCostBasisRequest = AcceptDateStrings<{ - currencyId: string - body: CreateCostBasisRequestBody -}> -export type CreateCostBasisResponse = CostBasis diff --git a/api/spec/packages/aip-client-javascript/src/models/operations/customers.ts b/api/spec/packages/aip-client-javascript/src/models/operations/customers.ts index 4dcfcfe5e6..db274409ce 100644 --- a/api/spec/packages/aip-client-javascript/src/models/operations/customers.ts +++ b/api/spec/packages/aip-client-javascript/src/models/operations/customers.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { z } from 'zod' import * as schemas from '../schemas.js' import type { AcceptDateStrings } from '../../lib/wire.js' @@ -5,9 +7,9 @@ import type { AppCustomerData, AppStripeCreateCheckoutSessionResult, AppStripeCreateCustomerPortalSessionResult, + Charge, ChargePagePaginatedResponse, - CreateChargeFlatFeeRequest, - CreateChargeUsageBasedRequest, + CreateChargeRequest, CreateCreditAdjustmentRequest as CreateCreditAdjustmentRequestBody, CreateCreditGrantRequestInput, CreateCustomerRequest as CreateCustomerRequestBody, @@ -210,8 +212,6 @@ export type ListCustomerChargesResponse = ChargePagePaginatedResponse export type CreateCustomerChargesRequest = AcceptDateStrings<{ customerId: string - body: CreateChargeFlatFeeRequest | CreateChargeUsageBasedRequest + body: CreateChargeRequest }> -export type CreateCustomerChargesResponse = z.output< - typeof schemas.createCustomerChargesResponse -> +export type CreateCustomerChargesResponse = Charge diff --git a/api/spec/packages/aip-client-javascript/src/models/operations/defaults.ts b/api/spec/packages/aip-client-javascript/src/models/operations/defaults.ts index 8bceb9215a..c1222a5b98 100644 --- a/api/spec/packages/aip-client-javascript/src/models/operations/defaults.ts +++ b/api/spec/packages/aip-client-javascript/src/models/operations/defaults.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import type { AcceptDateStrings } from '../../lib/wire.js' import type { OrganizationDefaultTaxCodes, diff --git a/api/spec/packages/aip-client-javascript/src/models/operations/entitlements.ts b/api/spec/packages/aip-client-javascript/src/models/operations/entitlements.ts index d21d3c5fa6..c954be5c71 100644 --- a/api/spec/packages/aip-client-javascript/src/models/operations/entitlements.ts +++ b/api/spec/packages/aip-client-javascript/src/models/operations/entitlements.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import type { ListCustomerEntitlementAccessResponseData } from '../types.js' export type ListCustomerEntitlementAccessRequest = { diff --git a/api/spec/packages/aip-client-javascript/src/models/operations/events.ts b/api/spec/packages/aip-client-javascript/src/models/operations/events.ts index 790b6e8497..51b165c03e 100644 --- a/api/spec/packages/aip-client-javascript/src/models/operations/events.ts +++ b/api/spec/packages/aip-client-javascript/src/models/operations/events.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { z } from 'zod' import * as schemas from '../schemas.js' import type { AcceptDateStrings } from '../../lib/wire.js' diff --git a/api/spec/packages/aip-client-javascript/src/models/operations/features.ts b/api/spec/packages/aip-client-javascript/src/models/operations/features.ts index 6a68531d45..0e7e0637af 100644 --- a/api/spec/packages/aip-client-javascript/src/models/operations/features.ts +++ b/api/spec/packages/aip-client-javascript/src/models/operations/features.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { z } from 'zod' import * as schemas from '../schemas.js' import type { AcceptDateStrings } from '../../lib/wire.js' diff --git a/api/spec/packages/aip-client-javascript/src/models/operations/governance.ts b/api/spec/packages/aip-client-javascript/src/models/operations/governance.ts deleted file mode 100644 index b6b0c06e89..0000000000 --- a/api/spec/packages/aip-client-javascript/src/models/operations/governance.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { z } from 'zod' -import * as schemas from '../schemas.js' -import type { AcceptDateStrings } from '../../lib/wire.js' -import type { - CursorPaginationQueryPage, - GovernanceQueryRequestInput, - GovernanceQueryResponse, -} from '../types.js' - -export interface QueryGovernanceAccessQuery { - page?: CursorPaginationQueryPage -} - -export type QueryGovernanceAccessRequest = AcceptDateStrings< - { - body: GovernanceQueryRequestInput - } & QueryGovernanceAccessQuery -> -export type QueryGovernanceAccessResponse = GovernanceQueryResponse diff --git a/api/spec/packages/aip-client-javascript/src/models/operations/invoices.ts b/api/spec/packages/aip-client-javascript/src/models/operations/invoices.ts deleted file mode 100644 index 3e6ff9ff27..0000000000 --- a/api/spec/packages/aip-client-javascript/src/models/operations/invoices.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { z } from 'zod' -import * as schemas from '../schemas.js' -import type { AcceptDateStrings } from '../../lib/wire.js' -import type { - InvoicePagePaginatedResponse, - ListInvoicesParamsFilter, - SortQueryInput, - UpdateInvoiceStandardRequestInput, -} from '../types.js' - -export interface ListInvoicesQuery { - /** Determines which page of the collection to retrieve. */ - page?: { size?: number; number?: number } - /** - * Sort invoices returned in the response. Supported sort attributes: - * - * - `issued_at` - * - `created_at` (default) - * - `service_period_start` - * - * The `asc` suffix is optional as the default sort order is ascending. The `desc` - * suffix is used to specify a descending order. - */ - sort?: SortQueryInput - /** - * Filter invoices returned in the response. - * - * Examples: - * - * - `filter[status][oeq]=draft,issued` - * - `filter[customer_id]=01KPDB8K...` - * - `filter[issued_at][gte]=2024-01-01T00:00:00Z` - */ - filter?: ListInvoicesParamsFilter -} - -export type ListInvoicesRequest = AcceptDateStrings -export type ListInvoicesResponse = InvoicePagePaginatedResponse - -export type GetInvoiceRequest = { - invoiceId: string -} -export type GetInvoiceResponse = z.output - -export type UpdateInvoiceRequest = AcceptDateStrings<{ - invoiceId: string - body: UpdateInvoiceStandardRequestInput -}> -export type UpdateInvoiceResponse = z.output< - typeof schemas.updateInvoiceResponse -> - -export type DeleteInvoiceRequest = { - invoiceId: string -} -export type DeleteInvoiceResponse = void diff --git a/api/spec/packages/aip-client-javascript/src/models/operations/llmCost.ts b/api/spec/packages/aip-client-javascript/src/models/operations/llmCost.ts index cc2f0e39da..13cc7c8c4a 100644 --- a/api/spec/packages/aip-client-javascript/src/models/operations/llmCost.ts +++ b/api/spec/packages/aip-client-javascript/src/models/operations/llmCost.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { z } from 'zod' import * as schemas from '../schemas.js' import type { AcceptDateStrings } from '../../lib/wire.js' diff --git a/api/spec/packages/aip-client-javascript/src/models/operations/meters.ts b/api/spec/packages/aip-client-javascript/src/models/operations/meters.ts index f0258e8202..894abacd96 100644 --- a/api/spec/packages/aip-client-javascript/src/models/operations/meters.ts +++ b/api/spec/packages/aip-client-javascript/src/models/operations/meters.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { z } from 'zod' import * as schemas from '../schemas.js' import type { AcceptDateStrings } from '../../lib/wire.js' diff --git a/api/spec/packages/aip-client-javascript/src/models/operations/planAddons.ts b/api/spec/packages/aip-client-javascript/src/models/operations/planAddons.ts index dac5f85d9c..65b3ba9c51 100644 --- a/api/spec/packages/aip-client-javascript/src/models/operations/planAddons.ts +++ b/api/spec/packages/aip-client-javascript/src/models/operations/planAddons.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { z } from 'zod' import * as schemas from '../schemas.js' import type { AcceptDateStrings } from '../../lib/wire.js' diff --git a/api/spec/packages/aip-client-javascript/src/models/operations/plans.ts b/api/spec/packages/aip-client-javascript/src/models/operations/plans.ts index d228a1fca0..367fe525fd 100644 --- a/api/spec/packages/aip-client-javascript/src/models/operations/plans.ts +++ b/api/spec/packages/aip-client-javascript/src/models/operations/plans.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { z } from 'zod' import * as schemas from '../schemas.js' import type { AcceptDateStrings } from '../../lib/wire.js' diff --git a/api/spec/packages/aip-client-javascript/src/models/operations/subscriptions.ts b/api/spec/packages/aip-client-javascript/src/models/operations/subscriptions.ts index 543297bac3..aed474517b 100644 --- a/api/spec/packages/aip-client-javascript/src/models/operations/subscriptions.ts +++ b/api/spec/packages/aip-client-javascript/src/models/operations/subscriptions.ts @@ -1,8 +1,9 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { z } from 'zod' import * as schemas from '../schemas.js' import type { AcceptDateStrings } from '../../lib/wire.js' import type { - CreateSubscriptionAddonRequest as CreateSubscriptionAddonRequestBody, ListSubscriptionsParamsFilter, SortQueryInput, Subscription, @@ -61,12 +62,6 @@ export type ChangeSubscriptionRequest = AcceptDateStrings<{ }> export type ChangeSubscriptionResponse = SubscriptionChangeResponse -export type CreateSubscriptionAddonRequest = AcceptDateStrings<{ - subscriptionId: string - body: CreateSubscriptionAddonRequestBody -}> -export type CreateSubscriptionAddonResponse = SubscriptionAddon - export interface ListSubscriptionAddonsQuery { /** Determines which page of the collection to retrieve. */ page?: { size?: number; number?: number } diff --git a/api/spec/packages/aip-client-javascript/src/models/operations/tax.ts b/api/spec/packages/aip-client-javascript/src/models/operations/tax.ts index 9c8d5a1e1b..b2fb3cd590 100644 --- a/api/spec/packages/aip-client-javascript/src/models/operations/tax.ts +++ b/api/spec/packages/aip-client-javascript/src/models/operations/tax.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { z } from 'zod' import * as schemas from '../schemas.js' import type { AcceptDateStrings } from '../../lib/wire.js' diff --git a/api/spec/packages/aip-client-javascript/src/models/schemas.ts b/api/spec/packages/aip-client-javascript/src/models/schemas.ts index ae549bd703..d6201a7065 100644 --- a/api/spec/packages/aip-client-javascript/src/models/schemas.ts +++ b/api/spec/packages/aip-client-javascript/src/models/schemas.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { z } from 'zod' export const labels = z @@ -5979,14 +5981,6 @@ export const changeSubscriptionBody = subscriptionChange export const changeSubscriptionResponse = subscriptionChangeResponse -export const createSubscriptionAddonPathParams = z.object({ - subscriptionId: ulid, -}) - -export const createSubscriptionAddonBody = createSubscriptionAddonRequest - -export const createSubscriptionAddonResponse = subscriptionAddon - export const listSubscriptionAddonsPathParams = z.object({ subscriptionId: ulid, }) @@ -6084,45 +6078,6 @@ export const deleteBillingProfilePathParams = z.object({ id: ulid, }) -export const listInvoicesQueryParams = z.object({ - page: z - .object({ - size: z.coerce - .number() - .int() - .optional() - .describe('The number of items to include per page.'), - number: z.coerce.number().int().optional().describe('The page number.'), - }) - .optional() - .describe('Determines which page of the collection to retrieve.'), - sort: sortQuery.optional(), - filter: listInvoicesParamsFilter.optional(), -}) - -export const listInvoicesResponse = z.object({ - data: z.array(invoice), - meta: paginatedMeta, -}) - -export const getInvoicePathParams = z.object({ - invoiceId: ulid, -}) - -export const getInvoiceResponse = invoice - -export const updateInvoicePathParams = z.object({ - invoiceId: ulid, -}) - -export const updateInvoiceBody = updateInvoiceRequest - -export const updateInvoiceResponse = invoice - -export const deleteInvoicePathParams = z.object({ - invoiceId: ulid, -}) - export const createTaxCodeBody = createTaxCodeRequest export const createTaxCodeResponse = taxCode @@ -6168,63 +6123,6 @@ export const deleteTaxCodePathParams = z.object({ taxCodeId: ulid, }) -export const listCurrenciesQueryParams = z.object({ - page: z - .object({ - size: z.coerce - .number() - .int() - .optional() - .describe('The number of items to include per page.'), - number: z.coerce.number().int().optional().describe('The page number.'), - }) - .optional() - .describe('Determines which page of the collection to retrieve.'), - sort: sortQuery.optional(), - filter: listCurrenciesParamsFilter.optional(), -}) - -export const listCurrenciesResponse = z.object({ - data: z.array(currency), - meta: paginatedMeta, -}) - -export const createCustomCurrencyBody = createCurrencyCustomRequest - -export const createCustomCurrencyResponse = currencyCustom - -export const listCostBasesPathParams = z.object({ - currencyId: ulid, -}) - -export const listCostBasesQueryParams = z.object({ - filter: listCostBasesParamsFilter.optional(), - page: z - .object({ - size: z.coerce - .number() - .int() - .optional() - .describe('The number of items to include per page.'), - number: z.coerce.number().int().optional().describe('The page number.'), - }) - .optional() - .describe('Determines which page of the collection to retrieve.'), -}) - -export const listCostBasesResponse = z.object({ - data: z.array(costBasis), - meta: paginatedMeta, -}) - -export const createCostBasisPathParams = z.object({ - currencyId: ulid, -}) - -export const createCostBasisBody = createCostBasisRequest - -export const createCostBasisResponse = costBasis - export const listFeaturesQueryParams = z.object({ page: z .object({ @@ -6502,14 +6400,6 @@ export const updateOrganizationDefaultTaxCodesBody = export const updateOrganizationDefaultTaxCodesResponse = organizationDefaultTaxCodes -export const queryGovernanceAccessQueryParams = z.object({ - page: cursorPaginationQueryPage.optional(), -}) - -export const queryGovernanceAccessBody = governanceQueryRequest - -export const queryGovernanceAccessResponse = governanceQueryResponse - export const labelsWire = z .record(z.string(), z.string()) @@ -12516,15 +12406,6 @@ export const changeSubscriptionBodyWire = subscriptionChangeWire export const changeSubscriptionResponseWire = subscriptionChangeResponseWire -export const createSubscriptionAddonPathParamsWire = z.object({ - subscriptionId: ulidWire, -}) - -export const createSubscriptionAddonBodyWire = - createSubscriptionAddonRequestWire - -export const createSubscriptionAddonResponseWire = subscriptionAddonWire - export const listSubscriptionAddonsPathParamsWire = z.object({ subscriptionId: ulidWire, }) @@ -12622,45 +12503,6 @@ export const deleteBillingProfilePathParamsWire = z.object({ id: ulidWire, }) -export const listInvoicesQueryParamsWire = z.object({ - page: z - .strictObject({ - size: z.coerce - .number() - .int() - .optional() - .describe('The number of items to include per page.'), - number: z.coerce.number().int().optional().describe('The page number.'), - }) - .optional() - .describe('Determines which page of the collection to retrieve.'), - sort: sortQueryWire.optional(), - filter: listInvoicesParamsFilterWire.optional(), -}) - -export const listInvoicesResponseWire = z.strictObject({ - data: z.array(invoiceWire), - meta: paginatedMetaWire, -}) - -export const getInvoicePathParamsWire = z.object({ - invoiceId: ulidWire, -}) - -export const getInvoiceResponseWire = invoiceWire - -export const updateInvoicePathParamsWire = z.object({ - invoiceId: ulidWire, -}) - -export const updateInvoiceBodyWire = updateInvoiceRequestWire - -export const updateInvoiceResponseWire = invoiceWire - -export const deleteInvoicePathParamsWire = z.object({ - invoiceId: ulidWire, -}) - export const createTaxCodeBodyWire = createTaxCodeRequestWire export const createTaxCodeResponseWire = taxCodeWire @@ -12706,63 +12548,6 @@ export const deleteTaxCodePathParamsWire = z.object({ taxCodeId: ulidWire, }) -export const listCurrenciesQueryParamsWire = z.object({ - page: z - .strictObject({ - size: z.coerce - .number() - .int() - .optional() - .describe('The number of items to include per page.'), - number: z.coerce.number().int().optional().describe('The page number.'), - }) - .optional() - .describe('Determines which page of the collection to retrieve.'), - sort: sortQueryWire.optional(), - filter: listCurrenciesParamsFilterWire.optional(), -}) - -export const listCurrenciesResponseWire = z.strictObject({ - data: z.array(currencyWire), - meta: paginatedMetaWire, -}) - -export const createCustomCurrencyBodyWire = createCurrencyCustomRequestWire - -export const createCustomCurrencyResponseWire = currencyCustomWire - -export const listCostBasesPathParamsWire = z.object({ - currencyId: ulidWire, -}) - -export const listCostBasesQueryParamsWire = z.object({ - filter: listCostBasesParamsFilterWire.optional(), - page: z - .strictObject({ - size: z.coerce - .number() - .int() - .optional() - .describe('The number of items to include per page.'), - number: z.coerce.number().int().optional().describe('The page number.'), - }) - .optional() - .describe('Determines which page of the collection to retrieve.'), -}) - -export const listCostBasesResponseWire = z.strictObject({ - data: z.array(costBasisWire), - meta: paginatedMetaWire, -}) - -export const createCostBasisPathParamsWire = z.object({ - currencyId: ulidWire, -}) - -export const createCostBasisBodyWire = createCostBasisRequestWire - -export const createCostBasisResponseWire = costBasisWire - export const listFeaturesQueryParamsWire = z.object({ page: z .strictObject({ @@ -13039,11 +12824,3 @@ export const updateOrganizationDefaultTaxCodesBodyWire = export const updateOrganizationDefaultTaxCodesResponseWire = organizationDefaultTaxCodesWire - -export const queryGovernanceAccessQueryParamsWire = z.object({ - page: cursorPaginationQueryPageWire.optional(), -}) - -export const queryGovernanceAccessBodyWire = governanceQueryRequestWire - -export const queryGovernanceAccessResponseWire = governanceQueryResponseWire diff --git a/api/spec/packages/aip-client-javascript/src/models/types.ts b/api/spec/packages/aip-client-javascript/src/models/types.ts index f5430ed8fe..42692a00f8 100644 --- a/api/spec/packages/aip-client-javascript/src/models/types.ts +++ b/api/spec/packages/aip-client-javascript/src/models/types.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + /** * Labels store metadata of an entity that can be used for filtering an entity list * or for searching across entity types. @@ -701,114 +703,23 @@ export interface CursorPaginationQuery { /** Filter options for listing meters. */ export interface ListMetersParamsFilter { /** Filter meters by key. */ - key?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } + key?: StringFieldFilter /** Filter meters by name. */ - name?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } + name?: StringFieldFilter } /** Filter options for listing LLM cost prices. */ export interface ListLlmCostPricesParamsFilter { /** Filter by provider. e.g. ?filter[provider][eq]=openai */ - provider?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } + provider?: StringFieldFilter /** Filter by model ID. e.g. ?filter[model_id][eq]=gpt-4 */ - modelId?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } + modelId?: StringFieldFilter /** Filter by model name. e.g. ?filter[model_name][contains]=gpt */ - modelName?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } + modelName?: StringFieldFilter /** Filter by currency code. e.g. ?filter[currency][eq]=USD */ - currency?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } + currency?: StringFieldFilter /** Filter by source. e.g. ?filter[source][eq]=system */ - source?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } + source?: StringFieldFilter } /** @@ -819,22 +730,7 @@ export interface ListLlmCostPricesParamsFilter { * `filter[labels][key]=value` (nested) and `filter[labels.key]=value` * (dot-notation). */ -export type LabelsFieldFilter = Record< - string, - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } -> +export type LabelsFieldFilter = Record /** Customer reference. */ export interface CustomerReference { @@ -1421,14 +1317,7 @@ export interface CreateMeterRequest { labels?: Labels key: string /** The aggregation type to use for the meter. */ - aggregation: - | 'sum' - | 'count' - | 'unique_count' - | 'avg' - | 'min' - | 'max' - | 'latest' + aggregation: MeterAggregation /** The event type to include in the aggregation. */ eventType: string /** @@ -1479,14 +1368,7 @@ export interface Meter { deletedAt?: Date key: string /** The aggregation type to use for the meter. */ - aggregation: - | 'sum' - | 'count' - | 'unique_count' - | 'avg' - | 'min' - | 'max' - | 'latest' + aggregation: MeterAggregation /** The event type to include in the aggregation. */ eventType: string /** @@ -1847,44 +1729,18 @@ export interface ListCreditGrantsParamsFilter { /** Filter credit grants by currency. */ currency?: string /** Filter credit grants by key. */ - key?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } + key?: StringFieldFilter } /** Filter options for getting a credit balance. */ export interface GetCreditBalanceParamsFilter { /** Filter credit balance by currency. */ - currency?: string | { eq?: string; oeq?: string[]; neq?: string } + currency?: StringFieldFilterExact /** * Filter credit balance by feature key. Omit to return the total portfolio value. * Use `exists=false` to return only unrestricted balance. */ - featureKey?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } + featureKey?: StringFieldFilter } /** Filter options for listing charges. */ @@ -1901,41 +1757,15 @@ export interface ListChargesParamsFilter { * * If omitted, all statuses are returned except for `deleted`. */ - status?: string | { eq?: string; oeq?: string[]; neq?: string } + status?: StringFieldFilterExact } /** Filter options for listing plans. */ export interface ListPlansParamsFilter { - key?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } - name?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } - status?: string | { eq?: string; oeq?: string[]; neq?: string } - currency?: string | { eq?: string; oeq?: string[]; neq?: string } + key?: StringFieldFilter + name?: StringFieldFilter + status?: StringFieldFilterExact + currency?: StringFieldFilterExact } /** Subscription create request. */ @@ -2212,20 +2042,7 @@ export interface InvoiceLineBaseDiscount { /** Filter options for listing currencies. */ export interface ListCurrenciesParamsFilter { type?: 'fiat' | 'custom' - code?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } + code?: StringFieldFilter } /** Describes custom currency. */ @@ -2345,7 +2162,7 @@ export interface CreditAdjustment { /** The credit balance by currency. */ export interface CreditBalance { - currency: string + currency: BillingCurrencyCode /** Credits available after applying currently live charge impacts. */ live: string /** Credits that have been booked on the ledger as of the balance timestamp. */ @@ -2373,7 +2190,7 @@ export interface CreateCreditAdjustmentRequest { description?: string labels?: Labels /** The currency of the granted credits. */ - currency: string + currency: BillingCurrencyCode /** Granted credit amount. */ amount: string } @@ -2383,26 +2200,13 @@ export interface ListCreditTransactionsParamsFilter { /** Filter credit transactions by type. */ type?: 'funded' | 'consumed' | 'expired' /** Filter credit transactions by currency. */ - currency?: string + currency?: BillingCurrencyCode /** * Filter credit transactions by feature key. Omit to return all credit * transactions. Use `exists=false` to return only unrestricted credit * transactions. */ - featureKey?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } + featureKey?: StringFieldFilter } /** @@ -2433,7 +2237,7 @@ export interface CreditTransaction { /** The type of credit transaction. */ type: 'funded' | 'consumed' | 'expired' /** Currency of the balance affected by the transaction. */ - currency: string + currency: BillingCurrencyCode /** * Signed amount of the credit movement. Positive values add balance, negative * values reduce balance. @@ -2602,154 +2406,37 @@ export interface LlmCostOverrideCreate { /** Filter options for listing customers. */ export interface ListCustomersParamsFilter { - key?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } - name?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } - primaryEmail?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } - usageAttributionSubjectKey?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } - planKey?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } - billingProfileId?: string | { eq?: string; oeq?: string[]; neq?: string } + key?: StringFieldFilter + name?: StringFieldFilter + primaryEmail?: StringFieldFilter + usageAttributionSubjectKey?: StringFieldFilter + planKey?: StringFieldFilter + billingProfileId?: UlidFieldFilter } /** Filter options for listing subscriptions. */ export interface ListSubscriptionsParamsFilter { - id?: string | { eq?: string; oeq?: string[]; neq?: string } - customerId?: string | { eq?: string; oeq?: string[]; neq?: string } - status?: string | { eq?: string; oeq?: string[]; neq?: string } - planId?: string | { eq?: string; oeq?: string[]; neq?: string } - planKey?: string | { eq?: string; oeq?: string[]; neq?: string } + id?: UlidFieldFilter + customerId?: UlidFieldFilter + status?: StringFieldFilterExact + planId?: UlidFieldFilter + planKey?: StringFieldFilterExact } /** Filter options for listing features. */ export interface ListFeatureParamsFilter { - meterId?: string | { eq?: string; oeq?: string[]; neq?: string } - key?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } - name?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } + meterId?: UlidFieldFilter + key?: StringFieldFilter + name?: StringFieldFilter } /** Filter options for listing add-ons. */ export interface ListAddonsParamsFilter { - id?: string | { eq?: string; oeq?: string[]; neq?: string } - key?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } - name?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } - status?: string | { eq?: string; oeq?: string[]; neq?: string } - currency?: string | { eq?: string; oeq?: string[]; neq?: string } + id?: UlidFieldFilter + key?: StringFieldFilter + name?: StringFieldFilter + status?: StringFieldFilterExact + currency?: StringFieldFilterExact } /** @@ -2932,114 +2619,45 @@ export interface UpdateRateCardTaxConfig { /** Filter options for listing ingested events. */ export interface ListEventsParamsFilter { /** Filter events by ID. */ - id?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } + id?: StringFieldFilter /** Filter events by source. */ - source?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } + source?: StringFieldFilter /** Filter events by subject. */ - subject?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } + subject?: StringFieldFilter /** Filter events by type. */ - type?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } + type?: StringFieldFilter /** Filter events by the associated customer ID. */ - customerId?: string | { eq?: string; oeq?: string[]; neq?: string } + customerId?: UlidFieldFilter /** Filter events by event time. */ - time?: Date | { eq?: Date; lt?: Date; lte?: Date; gt?: Date; gte?: Date } + time?: DateTimeFieldFilter /** Filter events by the time the event was ingested. */ - ingestedAt?: - | Date - | { eq?: Date; lt?: Date; lte?: Date; gt?: Date; gte?: Date } + ingestedAt?: DateTimeFieldFilter /** Filter events by the time the event was stored. */ - storedAt?: Date | { eq?: Date; lt?: Date; lte?: Date; gt?: Date; gte?: Date } + storedAt?: DateTimeFieldFilter } /** Filter options for listing invoices. */ export interface ListInvoicesParamsFilter { /** Filter by invoice status. */ - status?: string | { eq?: string; oeq?: string[]; neq?: string } + status?: StringFieldFilterExact /** Filter by customer ID. */ - customerId?: string | { eq?: string; oeq?: string[]; neq?: string } + customerId?: UlidFieldFilter /** Filter by the time the invoice was issued. */ - issuedAt?: Date | { eq?: Date; lt?: Date; lte?: Date; gt?: Date; gte?: Date } + issuedAt?: DateTimeFieldFilter /** Filter by service period start. */ - servicePeriodStart?: - | Date - | { eq?: Date; lt?: Date; lte?: Date; gt?: Date; gte?: Date } + servicePeriodStart?: DateTimeFieldFilter /** Filter by invoice creation time. */ - createdAt?: Date | { eq?: Date; lt?: Date; lte?: Date; gt?: Date; gte?: Date } + createdAt?: DateTimeFieldFilter } /** Resource filters. */ export interface ResourceFilters { - name?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } + name?: StringFieldFilter labels?: LabelsFieldFilter publicLabels?: LabelsFieldFilter - createdAt?: Date | { eq?: Date; lt?: Date; lte?: Date; gt?: Date; gte?: Date } - updatedAt?: Date | { eq?: Date; lt?: Date; lte?: Date; gt?: Date; gte?: Date } - deletedAt?: Date | { eq?: Date; lt?: Date; lte?: Date; gt?: Date; gte?: Date } + createdAt?: DateTimeFieldFilter + updatedAt?: DateTimeFieldFilter + deletedAt?: DateTimeFieldFilter } /** Field filters with all supported types. */ @@ -3056,23 +2674,10 @@ export interface FieldFilters { gt?: number gte?: number } - string?: - | string - | { - eq?: string - neq?: string - contains?: string - ocontains?: string[] - oeq?: string[] - gt?: string - gte?: string - lt?: string - lte?: string - exists?: boolean - } - stringExact?: string | { eq?: string; oeq?: string[]; neq?: string } - ulid?: string | { eq?: string; oeq?: string[]; neq?: string } - datetime?: Date | { eq?: Date; lt?: Date; lte?: Date; gt?: Date; gte?: Date } + string?: StringFieldFilter + stringExact?: StringFieldFilterExact + ulid?: UlidFieldFilter + datetime?: DateTimeFieldFilter labels?: LabelsFieldFilter } @@ -3347,7 +2952,7 @@ export interface ChargeFlatFeeSystemIntent { /** The billing period the charge belongs to. */ billingPeriod: ClosedPeriod /** Payment term of the flat fee charge. */ - paymentTerm: 'in_advance' | 'in_arrears' + paymentTerm: PricePaymentTerm /** The discounts applied to the charge. */ discounts?: ChargeFlatFeeDiscounts /** The proration configuration of the charge. */ @@ -3378,7 +2983,7 @@ export interface SubscriptionChangeResponse { /** Request for canceling a subscription. */ export interface SubscriptionCancel { /** If not provided the subscription is canceled immediately. */ - timing: 'immediate' | 'next_billing_cycle' | Date + timing: SubscriptionEditTiming } /** Request for changing a subscription. */ @@ -3416,7 +3021,7 @@ export interface SubscriptionChange { * changing a subscription, the accepted values depend on the subscription * configuration. */ - timing: 'immediate' | 'next_billing_cycle' | Date + timing: SubscriptionEditTiming } /** SubscriptionAddon create request. */ @@ -3430,7 +3035,7 @@ export interface CreateSubscriptionAddonRequest { * The timing of the operation. After the create or update, a new entry will be * created in the timeline. */ - timing: 'immediate' | 'next_billing_cycle' | Date + timing: SubscriptionEditTiming } /** @@ -3672,9 +3277,7 @@ export interface InvoiceWorkflow { /** Invoicing settings for this invoice. */ invoicing?: InvoiceWorkflowInvoicingSettings /** Payment settings for this invoice. */ - payment?: - | WorkflowPaymentChargeAutomaticallySettings - | WorkflowPaymentSendInvoiceSettings + payment?: WorkflowPaymentSettings } /** Detailed status information for a standard invoice. */ @@ -3860,7 +3463,7 @@ export interface CreateCreditGrantRequest { /** Funding method of the grant. */ fundingMethod: 'none' | 'invoice' | 'external' /** The currency of the granted credits. */ - currency: string + currency: CreateCurrencyCode /** Granted credit amount. */ amount: string /** Present when a funding workflow applies (funding_method is not `none`). */ @@ -3928,7 +3531,7 @@ export interface CreditGrant { /** Funding method of the grant. */ fundingMethod: 'none' | 'invoice' | 'external' /** The currency of the granted credits. */ - currency: string + currency: BillingCurrencyCode /** Granted credit amount. */ amount: string /** Present when a funding workflow applies (funding_method is not `none`). */ @@ -4002,7 +3605,7 @@ export interface CreateChargeFlatFeeRequest { /** Tax configuration of the charge. */ taxConfig?: TaxConfig /** Payment term of the flat fee charge. */ - paymentTerm: 'in_advance' | 'in_arrears' + paymentTerm: PricePaymentTerm /** The discounts applied to the charge. */ discounts?: ChargeFlatFeeDiscounts /** The feature associated with the charge, when applicable. */ @@ -4055,13 +3658,7 @@ export interface IngestedEventPaginatedResponse { } /** The list of parameters that failed validation. */ -export type InvalidParameters = ( - | InvalidParameterStandard - | InvalidParameterMinimumLength - | InvalidParameterMaximumLength - | InvalidParameterChoiceItem - | InvalidParameterDependentItem -)[] +export type InvalidParameters = InvalidParameter[] /** A meter query request. */ export interface MeterQueryRequest { @@ -4073,7 +3670,7 @@ export interface MeterQueryRequest { * The size of the time buckets to group the usage into. If not specified, the * usage is aggregated over the entire period. */ - granularity?: 'PT1M' | 'PT1H' | 'P1D' | 'P1M' + granularity?: MeterQueryGranularity /** * The value is the name of the time zone as defined in the IANA Time Zone Database * (http://www.iana.org/time-zones). The time zone is used to determine the start @@ -4392,7 +3989,7 @@ export interface Feature { * "llm" to look up cost from the LLM cost database based on meter group-by * properties. */ - unitCost?: FeatureManualUnitCost | FeatureLlmUnitCost + unitCost?: FeatureUnitCost } /** Feature create request. */ @@ -4421,7 +4018,7 @@ export interface CreateFeatureRequest { * "llm" to look up cost from the LLM cost database based on meter group-by * properties. */ - unitCost?: FeatureManualUnitCost | FeatureLlmUnitCost + unitCost?: FeatureUnitCost } /** @@ -4435,7 +4032,7 @@ export interface UpdateFeatureRequest { * properties. Set to `null` to clear the existing unit cost; omit to leave it * unchanged. */ - unitCost?: FeatureManualUnitCost | FeatureLlmUnitCost | null + unitCost?: FeatureUnitCost | null } /** Page paginated response. */ @@ -4447,13 +4044,7 @@ export interface CreditGrantPagePaginatedResponse { /** Bad Request. */ export interface BadRequest extends BaseError { /** The list of parameters that failed validation. */ - invalidParameters: ( - | InvalidParameterStandard - | InvalidParameterMinimumLength - | InvalidParameterMaximumLength - | InvalidParameterChoiceItem - | InvalidParameterDependentItem - )[] + invalidParameters: InvalidParameter[] } /** @@ -4531,9 +4122,7 @@ export interface CustomerStripeCreateCheckoutSessionRequest { */ export interface WorkflowCollectionSettings { /** The alignment for collecting the pending line items into an invoice. */ - alignment: - | WorkflowCollectionAlignmentSubscription - | WorkflowCollectionAlignmentAnchored + alignment: WorkflowCollectionAlignment /** * This grace period can be used to delay the collection of the pending line items * specified in alignment. @@ -4546,18 +4135,18 @@ export interface WorkflowCollectionSettings { /** Page paginated response. */ export interface AppPagePaginatedResponse { - data: (AppStripe | AppSandbox | AppExternalInvoicing)[] + data: App[] meta: PaginatedMeta } /** Applications used by a billing profile. */ export interface ProfileApps { /** The tax app used for this workflow. */ - tax: AppStripe | AppSandbox | AppExternalInvoicing + tax: App /** The invoicing app used for this workflow. */ - invoicing: AppStripe | AppSandbox | AppExternalInvoicing + invoicing: App /** The payment app used for this workflow. */ - payment: AppStripe | AppSandbox | AppExternalInvoicing + payment: App } /** Response of the governance query. */ @@ -4633,7 +4222,7 @@ export interface ChargeFlatFee { /** Tax configuration of the charge. */ taxConfig?: TaxConfig /** Payment term of the flat fee charge. */ - paymentTerm: 'in_advance' | 'in_arrears' + paymentTerm: PricePaymentTerm /** The discounts applied to the charge. */ discounts?: ChargeFlatFeeDiscounts /** The feature associated with the charge, when applicable. */ @@ -4643,7 +4232,7 @@ export interface ChargeFlatFee { /** The amount after proration of the charge. */ amountAfterProration: CurrencyAmount /** The price of the charge. */ - price: PriceFree | PriceFlat | PriceUnit | PriceGraduated | PriceVolume + price: Price /** * Current intent from the system lifecycle controller for a charge that has an * active manual override. The top-level charge fields remain the effective @@ -4681,7 +4270,7 @@ export interface ChargeUsageBasedSystemIntent { /** Discounts applied to the usage-based charge. */ discounts?: RateCardDiscounts /** The price of the charge. */ - price: PriceFree | PriceFlat | PriceUnit | PriceGraduated | PriceVolume + price: Price /** * The timestamp when the system lifecycle controller intent was deleted. The * effective charge can remain visible while a manual override is active. @@ -4723,7 +4312,7 @@ export interface CreateChargeUsageBasedRequest { /** The feature associated with the charge. */ featureKey: string /** The price of the charge. */ - price: PriceFree | PriceFlat | PriceUnit | PriceGraduated | PriceVolume + price: Price /** The full, unprorated service period of the charge. */ fullServicePeriod?: ClosedPeriod /** The billing period the charge belongs to. */ @@ -4754,7 +4343,7 @@ export interface RateCard { */ billingCadence?: string /** The price of the rate card. */ - price: PriceFree | PriceFlat | PriceUnit | PriceGraduated | PriceVolume + price: Price /** * Unit conversion configuration for the rate card. * @@ -4770,7 +4359,7 @@ export interface RateCard { * The payment term of the rate card. In advance payment term can only be used for * flat prices. */ - paymentTerm: 'in_advance' | 'in_arrears' + paymentTerm: PricePaymentTerm /** * Spend commitments for this rate card. Only applicable to usage-based prices * (unit, graduated, volume). @@ -4784,16 +4373,13 @@ export interface RateCard { * The entitlement template granted to subscribers of a plan or addon containing * this rate card. Requires `feature` to be set. */ - entitlement?: - | RateCardMeteredEntitlement - | RateCardStaticEntitlement - | RateCardBooleanEntitlement + entitlement?: RateCardEntitlement } /** Rate card configuration snapshot for a usage-based invoice line. */ export interface InvoiceLineRateCard { /** The price definition used to calculate charges for this line. */ - price: PriceFree | PriceFlat | PriceUnit | PriceGraduated | PriceVolume + price: Price /** Tax configuration snapshot for this line. */ taxConfig?: RateCardTaxConfig /** The feature key associated with this line's rate card. */ @@ -4832,9 +4418,7 @@ export interface Workflow { /** The invoicing settings for this workflow */ invoicing?: WorkflowInvoicingSettings /** The payment settings for this workflow */ - payment?: - | WorkflowPaymentChargeAutomaticallySettings - | WorkflowPaymentSendInvoiceSettings + payment?: WorkflowPaymentSettings /** The tax settings for this workflow */ tax?: WorkflowTaxSettings } @@ -4905,7 +4489,7 @@ export interface ChargeUsageBased { /** Aggregated booked and realtime totals for the charge. */ totals: ChargeTotals /** The price of the charge. */ - price: PriceFree | PriceFlat | PriceUnit | PriceGraduated | PriceVolume + price: Price /** * Current intent from the system lifecycle controller for a charge that has an * active manual override. The top-level charge fields remain the effective @@ -4986,7 +4570,7 @@ export interface Addon { /** The InstanceType of the add-ons. Can be "single" or "multiple". */ instanceType: 'single' | 'multiple' /** The currency code of the add-on. */ - currency: string + currency: BillingCurrencyCode /** * The date and time when the add-on becomes effective. When not specified, the * add-on is a draft. @@ -5036,7 +4620,7 @@ export interface CreateAddonRequest { /** The InstanceType of the add-ons. Can be "single" or "multiple". */ instanceType: 'single' | 'multiple' /** The currency code of the add-on. */ - currency: string + currency: BillingCurrencyCode /** The rate cards of the add-on. */ rateCards: RateCard[] } @@ -5463,7 +5047,7 @@ export interface ProfilePagePaginatedResponse { /** Page paginated response. */ export interface ChargePagePaginatedResponse { - data: (ChargeFlatFee | ChargeUsageBased)[] + data: Charge[] meta: PaginatedMeta } @@ -5607,6 +5191,145 @@ export interface InvoicePagePaginatedResponse { meta: PaginatedMeta } +/** + * Filters on the given string field value by either exact or fuzzy match. All + * properties are optional; provide exactly one to specify the comparison. + */ +export type StringFieldFilter = + | string + | { + eq?: string + neq?: string + contains?: string + ocontains?: string[] + oeq?: string[] + gt?: string + gte?: string + lt?: string + lte?: string + exists?: boolean + } + +/** The aggregation type to use for the meter. */ +export type MeterAggregation = + | 'sum' + | 'count' + | 'unique_count' + | 'avg' + | 'min' + | 'max' + | 'latest' + +/** + * The granularity of the time grouping. Time durations are specified in ISO 8601 + * format. + */ +export type MeterQueryGranularity = 'PT1M' | 'PT1H' | 'P1D' | 'P1M' + +/** + * Filters on the given string field value by exact match. All properties are + * optional; provide exactly one to specify the comparison. + */ +export type StringFieldFilterExact = + | string + | { eq?: string; oeq?: string[]; neq?: string } + +/** The payment term of a flat price. */ +export type PricePaymentTerm = 'in_advance' | 'in_arrears' + +/** Fiat or custom currency code. */ +export type BillingCurrencyCode = string + +/** Fiat or custom currency code. */ +export type CreateCurrencyCode = string + +/** + * Filters on the given ULID field value by exact match. All properties are + * optional; provide exactly one to specify the comparison. + */ +export type UlidFieldFilter = + | string + | { eq?: string; oeq?: string[]; neq?: string } + +/** + * Filters on the given datetime (RFC-3339) field value. All properties are + * optional; provide exactly one to specify the comparison. + */ +export type DateTimeFieldFilter = + | Date + | { eq?: Date; lt?: Date; lte?: Date; gt?: Date; gte?: Date } + +/** + * Subscription edit timing defined when the changes should take effect. If the + * provided configuration is not supported by the subscription, an error will be + * returned. + */ +export type SubscriptionEditTiming = 'immediate' | 'next_billing_cycle' | Date + +/** Payment settings for a billing workflow. */ +export type WorkflowPaymentSettings = + | WorkflowPaymentChargeAutomaticallySettings + | WorkflowPaymentSendInvoiceSettings + +/** A parameter that failed validation. */ +export type InvalidParameter = + | InvalidParameterStandard + | InvalidParameterMinimumLength + | InvalidParameterMaximumLength + | InvalidParameterChoiceItem + | InvalidParameterDependentItem + +/** + * Entitlement template configured on a rate card. The feature is taken from the + * rate card itself, so it is omitted here. + */ +export type RateCardEntitlement = + | RateCardMeteredEntitlement + | RateCardStaticEntitlement + | RateCardBooleanEntitlement + +/** + * Per-unit cost configuration for a feature. Either a fixed manual amount or a + * dynamic LLM cost lookup. + */ +export type FeatureUnitCost = FeatureManualUnitCost | FeatureLlmUnitCost + +/** + * The alignment for collecting the pending line items into an invoice. + * + * Defaults to subscription, which means that we are to create a new invoice every + * time the a subscription period starts (for in advance items) or ends (for in + * arrears items). + */ +export type WorkflowCollectionAlignment = + | WorkflowCollectionAlignmentSubscription + | WorkflowCollectionAlignmentAnchored + +/** Installed application. */ +export type App = AppStripe | AppSandbox | AppExternalInvoicing + +/** Price. */ +export type Price = + | PriceFree + | PriceFlat + | PriceUnit + | PriceGraduated + | PriceVolume + +/** Customer charge. */ +export type CreateChargeRequest = + | CreateChargeFlatFeeRequest + | CreateChargeUsageBasedRequest + +/** Customer charge. */ +export type Charge = ChargeFlatFee | ChargeUsageBased + +/** + * Sort query. + * + * The `asc` suffix is optional as the default sort order is ascending. The `desc` + * suffix is used to specify a descending order. + */ export interface SortQueryInput { /** The attribute to sort by. */ by: string @@ -5614,6 +5337,7 @@ export interface SortQueryInput { order?: 'asc' | 'desc' } +/** Standard error response. */ export interface BaseErrorInput { /** Type contains a URI that identifies the problem type. */ type?: string @@ -5631,6 +5355,10 @@ export interface BaseErrorInput { [key: string]: unknown } +/** + * Payment settings for a billing workflow when the collection method is send + * invoice. + */ export interface WorkflowPaymentSendInvoiceSettingsInput { /** The collection method for the invoice. */ collectionMethod: 'send_invoice' @@ -5641,6 +5369,13 @@ export interface WorkflowPaymentSendInvoiceSettingsInput { dueAfter?: string } +/** + * Invoice-level invoicing settings. + * + * A subset of BillingWorkflowInvoicingSettings limited to fields that are + * meaningful per-invoice. progressive_billing is omitted as it is a gather-time / + * profile-level decision. + */ export interface InvoiceWorkflowInvoicingSettingsInput { /** Whether to automatically issue the invoice after the draft_period has passed. */ autoAdvance?: boolean @@ -5650,6 +5385,13 @@ export interface InvoiceWorkflowInvoicingSettingsInput { dueAfter?: string } +/** + * Invoice-level invoicing settings. + * + * A subset of BillingWorkflowInvoicingSettings limited to fields that are + * meaningful per-invoice. progressive_billing is omitted as it is a gather-time / + * profile-level decision. + */ export interface UpdateBillingInvoiceWorkflowInvoicingSettingsInput { /** Whether to automatically issue the invoice after the draft_period has passed. */ autoAdvance?: boolean @@ -5657,6 +5399,10 @@ export interface UpdateBillingInvoiceWorkflowInvoicingSettingsInput { draftPeriod?: string } +/** + * Payment settings for a billing workflow when the collection method is send + * invoice. + */ export interface UpdateBillingWorkflowPaymentSendInvoiceSettingsInput { /** The collection method for the invoice. */ collectionMethod: 'send_invoice' @@ -5667,6 +5413,7 @@ export interface UpdateBillingWorkflowPaymentSendInvoiceSettingsInput { dueAfter?: string } +/** Metering event following the CloudEvents specification. */ export interface EventInput { /** Identifies the event. */ id: string @@ -5697,30 +5444,43 @@ export interface EventInput { data?: Record | null } +/** Unauthorized. */ export interface UnauthorizedInput extends BaseErrorInput {} +/** Forbidden. */ export interface ForbiddenInput extends BaseErrorInput {} +/** Not Found. */ export interface NotFoundInput extends BaseErrorInput {} +/** Gone. */ export interface GoneInput extends BaseErrorInput {} +/** Conflict. */ export interface ConflictInput extends BaseErrorInput {} +/** Payload Too Large. */ export interface PayloadTooLargeInput extends BaseErrorInput {} +/** Unsupported Media Type. */ export interface UnsupportedMediaTypeInput extends BaseErrorInput {} +/** Unprocessable Content. */ export interface UnprocessableContentInput extends BaseErrorInput {} +/** Too Many Requests. */ export interface TooManyRequestsInput extends BaseErrorInput {} +/** Internal Server Error. */ export interface InternalInput extends BaseErrorInput {} +/** Not Implemented. */ export interface NotImplementedInput extends BaseErrorInput {} +/** Not Available. */ export interface NotAvailableInput extends BaseErrorInput {} +/** Controls which customer fields can be updated by the checkout session. */ export interface AppStripeCreateCheckoutSessionCustomerUpdateInput { /** * Whether to save the billing address to customer.address. @@ -5742,6 +5502,7 @@ export interface AppStripeCreateCheckoutSessionCustomerUpdateInput { shipping?: 'auto' | 'never' } +/** Tax ID collection configuration for checkout sessions. */ export interface AppStripeCreateCheckoutSessionTaxIdCollectionInput { /** * Enable tax ID collection during checkout. @@ -5757,6 +5518,7 @@ export interface AppStripeCreateCheckoutSessionTaxIdCollectionInput { required?: 'if_supported' | 'never' } +/** Purchase and payment terms of the grant. */ export interface CreateCreditGrantPurchaseInput { /** Currency of the purchase amount. */ currency: string @@ -5778,6 +5540,7 @@ export interface CreateCreditGrantPurchaseInput { availabilityPolicy?: 'on_creation' } +/** The entitlement template of a metered entitlement. */ export interface RateCardMeteredEntitlementInput { /** The type of the entitlement template. */ type: 'metered' @@ -5800,6 +5563,7 @@ export interface RateCardMeteredEntitlementInput { usagePeriod?: string } +/** Purchase and payment terms of the grant. */ export interface CreditGrantPurchaseInput { /** Currency of the purchase amount. */ currency: string @@ -5825,6 +5589,30 @@ export interface CreditGrantPurchaseInput { settlementStatus?: 'pending' | 'authorized' | 'settled' } +/** + * Unit conversion configuration. + * + * Transforms raw metered quantities into billing-ready units before pricing and + * entitlement evaluation. Applied at the rate card level so the same feature can + * be billed in different units across plans. + * + * Examples: + * + * - Meter bytes, bill GB: operation=divide, conversionFactor=1e9, + * rounding=ceiling, displayUnit="GB" + * - Meter seconds, bill hours: operation=divide, conversionFactor=3600, + * rounding=ceiling, displayUnit="hours" + * - Cost + 20% margin: operation=multiply, conversionFactor=1.2 + * - Bill per million tokens: operation=divide, conversionFactor=1e6, + * rounding=ceiling, displayUnit="M" + * + * v1 equivalents: + * + * - DynamicPrice(multiplier): operation=multiply, conversionFactor=multiplier + + * UnitPrice(amount=1) + * - PackagePrice(amount, quantityPerPkg): operation=divide, + * conversionFactor=quantityPerPkg, rounding=ceiling + UnitPrice(amount) + */ export interface UnitConfigInput { /** The arithmetic operation to apply to the raw metered quantity. */ operation: 'divide' | 'multiply' @@ -5860,6 +5648,7 @@ export interface UnitConfigInput { displayUnit?: string } +/** Invoice settings for a billing workflow. */ export interface WorkflowInvoicingSettingsInput { /** Whether to automatically issue the invoice after the draftPeriod has passed. */ autoAdvance?: boolean @@ -5871,6 +5660,7 @@ export interface WorkflowInvoicingSettingsInput { subscriptionEndProrationMode?: 'bill_full_period' | 'bill_actual_period' } +/** Query to evaluate feature access for a list of customers. */ export interface GovernanceQueryRequestInput { /** * Whether to include credit balance availability for each resolved customer. When @@ -5883,6 +5673,7 @@ export interface GovernanceQueryRequestInput { feature?: GovernanceQueryRequestFeatures } +/** An ingested metering event with ingestion metadata. */ export interface IngestedEventInput { /** The original event ingested. */ event: EventInput @@ -5896,11 +5687,17 @@ export interface IngestedEventInput { validationErrors?: IngestedEventValidationError[] } +/** Request for canceling a subscription. */ export interface SubscriptionCancelInput { /** If not provided the subscription is canceled immediately. */ - timing?: 'immediate' | 'next_billing_cycle' | Date + timing?: SubscriptionEditTiming } +/** + * Usage quantity details on an invoice line item when UnitConfig is in effect. + * + * Provides the full audit trail from raw meter output to the invoiced amount. + */ export interface InvoiceUsageQuantityDetailInput { /** The raw quantity as reported by the meter (native units). */ rawQuantity: string @@ -5920,15 +5717,27 @@ export interface InvoiceUsageQuantityDetailInput { appliedUnitConfig: UnitConfigInput } +/** + * Invoice-level snapshot of the workflow configuration. + * + * Contains only the settings that are meaningful for an already-created invoice: + * invoicing behaviour and payment settings. Collection alignment and tax policy + * are gather-time / profile-wide concerns and are not included. + */ export interface InvoiceWorkflowInput { /** Invoicing settings for this invoice. */ invoicing?: InvoiceWorkflowInvoicingSettingsInput /** Payment settings for this invoice. */ - payment?: - | WorkflowPaymentChargeAutomaticallySettings - | WorkflowPaymentSendInvoiceSettingsInput + payment?: WorkflowPaymentSettingsInput } +/** + * Invoice-level snapshot of the workflow configuration. + * + * Contains only the settings that are meaningful for an already-created invoice: + * invoicing behaviour and payment settings. Collection alignment and tax policy + * are gather-time / profile-wide concerns and are not included. + */ export interface UpdateBillingInvoiceWorkflowInput { /** Invoicing settings for this invoice. */ invoicing?: UpdateBillingInvoiceWorkflowInvoicingSettingsInput @@ -5938,6 +5747,7 @@ export interface UpdateBillingInvoiceWorkflowInput { | UpdateBillingWorkflowPaymentSendInvoiceSettingsInput } +/** CreditGrant create request. */ export interface CreateCreditGrantRequestInput { /** * Display name of the resource. @@ -5955,7 +5765,7 @@ export interface CreateCreditGrantRequestInput { /** Funding method of the grant. */ fundingMethod: 'none' | 'invoice' | 'external' /** The currency of the granted credits. */ - currency: string + currency: CreateCurrencyCode /** Granted credit amount. */ amount: string /** Present when a funding workflow applies (funding_method is not `none`). */ @@ -5993,6 +5803,12 @@ export interface CreateCreditGrantRequestInput { key?: string } +/** + * A credit grant allocates credits to a customer. + * + * Credits are drawn down against charges according to the settlement mode + * configured on the rate card. + */ export interface CreditGrantInput { id: string /** @@ -6017,7 +5833,7 @@ export interface CreditGrantInput { /** Funding method of the grant. */ fundingMethod: 'none' | 'invoice' | 'external' /** The currency of the granted credits. */ - currency: string + currency: BillingCurrencyCode /** Granted credit amount. */ amount: string /** Present when a funding workflow applies (funding_method is not `none`). */ @@ -6061,6 +5877,7 @@ export interface CreditGrantInput { status: 'pending' | 'active' | 'expired' | 'voided' } +/** Tax settings for a billing workflow. */ export interface WorkflowTaxSettingsInput { /** * Enable automatic tax calculation when tax is supported by the app. For example, @@ -6085,11 +5902,13 @@ export interface WorkflowTaxSettingsInput { defaultTaxConfig?: TaxConfig } +/** Cursor paginated response. */ export interface IngestedEventPaginatedResponseInput { data: IngestedEventInput[] meta: CursorMeta } +/** A meter query request. */ export interface MeterQueryRequestInput { /** The start of the period the usage is queried from. */ from?: Date @@ -6099,7 +5918,7 @@ export interface MeterQueryRequestInput { * The size of the time buckets to group the usage into. If not specified, the * usage is aggregated over the entire period. */ - granularity?: 'PT1M' | 'PT1H' | 'P1D' | 'P1M' + granularity?: MeterQueryGranularity /** * The value is the name of the time zone as defined in the IANA Time Zone Database * (http://www.iana.org/time-zones). The time zone is used to determine the start @@ -6112,6 +5931,12 @@ export interface MeterQueryRequestInput { filters?: MeterQueryFilters } +/** + * Configuration options for creating a Stripe Checkout Session. + * + * Based on Stripe's + * [Checkout Session API parameters](https://docs.stripe.com/api/checkout/sessions/create). + */ export interface AppStripeCreateCheckoutSessionRequestOptionsInput { /** * Whether to collect the customer's billing address. @@ -6200,6 +6025,7 @@ export interface AppStripeCreateCheckoutSessionRequestOptionsInput { taxIdCollection?: AppStripeCreateCheckoutSessionTaxIdCollectionInput } +/** Snapshot of the billing workflow configuration captured at invoice creation. */ export interface InvoiceWorkflowSettingsInput { /** The apps that will be used to orchestrate the invoice's workflow. */ apps?: InvoiceWorkflowAppsReferences @@ -6216,6 +6042,12 @@ export interface InvoiceWorkflowSettingsInput { workflow: InvoiceWorkflowInput } +/** + * A detailed (child) sub-line belonging to a parent invoice line. + * + * Detailed lines represent the individual flat-fee components that make up a + * usage-based parent line after quantity snapshotting. + */ export interface InvoiceDetailedLineInput { id: string /** @@ -6255,6 +6087,7 @@ export interface InvoiceDetailedLineInput { unitPrice: string } +/** Snapshot of the billing workflow configuration captured at invoice creation. */ export interface UpdateInvoiceWorkflowSettingsInput { /** * The workflow configuration that was active when the invoice was created. @@ -6267,22 +6100,25 @@ export interface UpdateInvoiceWorkflowSettingsInput { workflow: UpdateBillingInvoiceWorkflowInput } +/** Page paginated response. */ export interface CreditGrantPagePaginatedResponseInput { data: CreditGrantInput[] meta: PaginatedMeta } +/** Bad Request. */ export interface BadRequestInput extends BaseErrorInput { /** The list of parameters that failed validation. */ - invalidParameters: ( - | InvalidParameterStandard - | InvalidParameterMinimumLength - | InvalidParameterMaximumLength - | InvalidParameterChoiceItem - | InvalidParameterDependentItem - )[] + invalidParameters: InvalidParameter[] } +/** + * Request to create a Stripe Checkout Session for the customer. + * + * Checkout Sessions are used to collect payment method information from customers + * in a secure, Stripe-hosted interface. This integration uses setup mode to + * collect payment methods that can be charged later for subscription billing. + */ export interface CustomerStripeCreateCheckoutSessionRequestInput { /** * Options for configuring the Stripe Checkout Session. @@ -6293,11 +6129,13 @@ export interface CustomerStripeCreateCheckoutSessionRequestInput { stripeOptions: AppStripeCreateCheckoutSessionRequestOptionsInput } +/** + * Workflow collection specifies how to collect the pending line items for an + * invoice. + */ export interface WorkflowCollectionSettingsInput { /** The alignment for collecting the pending line items into an invoice. */ - alignment?: - | WorkflowCollectionAlignmentSubscription - | WorkflowCollectionAlignmentAnchored + alignment?: WorkflowCollectionAlignment /** * This grace period can be used to delay the collection of the pending line items * specified in alignment. @@ -6308,6 +6146,7 @@ export interface WorkflowCollectionSettingsInput { interval?: string } +/** A rate card defines the pricing and entitlement of a feature or service. */ export interface RateCardInput { /** * Display name of the resource. @@ -6331,7 +6170,7 @@ export interface RateCardInput { */ billingCadence?: string /** The price of the rate card. */ - price: PriceFree | PriceFlat | PriceUnit | PriceGraduated | PriceVolume + price: Price /** * Unit conversion configuration for the rate card. * @@ -6347,7 +6186,7 @@ export interface RateCardInput { * The payment term of the rate card. In advance payment term can only be used for * flat prices. */ - paymentTerm?: 'in_advance' | 'in_arrears' + paymentTerm?: PricePaymentTerm /** * Spend commitments for this rate card. Only applicable to usage-based prices * (unit, graduated, volume). @@ -6361,25 +6200,22 @@ export interface RateCardInput { * The entitlement template granted to subscribers of a plan or addon containing * this rate card. Requires `feature` to be set. */ - entitlement?: - | RateCardMeteredEntitlementInput - | RateCardStaticEntitlement - | RateCardBooleanEntitlement + entitlement?: RateCardEntitlementInput } +/** Billing workflow settings. */ export interface WorkflowInput { /** The collection settings for this workflow */ collection?: WorkflowCollectionSettingsInput /** The invoicing settings for this workflow */ invoicing?: WorkflowInvoicingSettingsInput /** The payment settings for this workflow */ - payment?: - | WorkflowPaymentChargeAutomaticallySettings - | WorkflowPaymentSendInvoiceSettingsInput + payment?: WorkflowPaymentSettingsInput /** The tax settings for this workflow */ tax?: WorkflowTaxSettingsInput } +/** A rate card for a subscription add-on. */ export interface SubscriptionAddonRateCardInput { /** The rate card. */ rateCard: RateCardInput @@ -6387,6 +6223,10 @@ export interface SubscriptionAddonRateCardInput { affectedSubscriptionItemIds: string[] } +/** + * The plan phase or pricing ramp allows changing a plan's rate cards over time as + * a subscription progresses. + */ export interface PlanPhaseInput { /** * Display name of the resource. @@ -6411,6 +6251,10 @@ export interface PlanPhaseInput { rateCards: RateCardInput[] } +/** + * Add-on allows extending subscriptions with compatible plans with additional + * ratecards. + */ export interface AddonInput { id: string /** @@ -6443,7 +6287,7 @@ export interface AddonInput { /** The InstanceType of the add-ons. Can be "single" or "multiple". */ instanceType: 'single' | 'multiple' /** The currency code of the add-on. */ - currency: string + currency: BillingCurrencyCode /** * The date and time when the add-on becomes effective. When not specified, the * add-on is a draft. @@ -6469,6 +6313,7 @@ export interface AddonInput { validationErrors?: ProductCatalogValidationError[] } +/** Addon create request. */ export interface CreateAddonRequestInput { /** * Display name of the resource. @@ -6492,11 +6337,12 @@ export interface CreateAddonRequestInput { /** The InstanceType of the add-ons. Can be "single" or "multiple". */ instanceType: 'single' | 'multiple' /** The currency code of the add-on. */ - currency: string + currency: BillingCurrencyCode /** The rate cards of the add-on. */ rateCards: RateCardInput[] } +/** Addon upsert request. */ export interface UpsertAddonRequestInput { /** * Display name of the resource. @@ -6517,6 +6363,13 @@ export interface UpsertAddonRequestInput { rateCards: RateCardInput[] } +/** + * A top-level line item on an invoice. + * + * Each line represents a single charge, typically associated with a rate card from + * a subscription. Detailed (child) lines are nested under `detailed_lines` when + * present. + */ export interface InvoiceStandardLineInput { /** * Display name of the resource. @@ -6582,6 +6435,10 @@ export interface InvoiceStandardLineInput { charge?: ChargeReference } +/** + * Billing profiles contain the settings for billing and controls invoice + * generation. + */ export interface ProfileInput { id: string /** @@ -6616,6 +6473,7 @@ export interface ProfileInput { default: boolean } +/** BillingProfile create request. */ export interface CreateBillingProfileRequestInput { /** * Display name of the resource. @@ -6643,6 +6501,7 @@ export interface CreateBillingProfileRequestInput { default: boolean } +/** BillingProfile upsert request. */ export interface UpsertBillingProfileRequestInput { /** * Display name of the resource. @@ -6668,6 +6527,7 @@ export interface UpsertBillingProfileRequestInput { default: boolean } +/** Addon purchased with a subscription. */ export interface SubscriptionAddonInput { id: string labels?: Labels @@ -6708,6 +6568,7 @@ export interface SubscriptionAddonInput { rateCards: SubscriptionAddonRateCardInput[] } +/** Plans provide a template for subscriptions. */ export interface PlanInput { id: string /** @@ -6788,6 +6649,7 @@ export interface PlanInput { validationErrors?: ProductCatalogValidationError[] } +/** Plan create request. */ export interface CreatePlanRequestInput { /** * Display name of the resource. @@ -6821,6 +6683,7 @@ export interface CreatePlanRequestInput { phases: PlanPhaseInput[] } +/** Plan upsert request. */ export interface UpsertPlanRequestInput { /** * Display name of the resource. @@ -6844,26 +6707,31 @@ export interface UpsertPlanRequestInput { phases: PlanPhaseInput[] } +/** Page paginated response. */ export interface AddonPagePaginatedResponseInput { data: AddonInput[] meta: PaginatedMeta } +/** Page paginated response. */ export interface ProfilePagePaginatedResponseInput { data: ProfileInput[] meta: PaginatedMeta } +/** Page paginated response. */ export interface SubscriptionAddonPagePaginatedResponseInput { data: SubscriptionAddonInput[] meta: PaginatedMeta } +/** Page paginated response. */ export interface PlanPagePaginatedResponseInput { data: PlanInput[] meta: PaginatedMeta } +/** A standard invoice for charges owed by the customer. */ export interface InvoiceStandardInput { id: string /** @@ -6953,6 +6821,7 @@ export interface InvoiceStandardInput { lines?: InvoiceStandardLineInput[] } +/** InvoiceStandard update request. */ export interface UpdateInvoiceStandardRequestInput { /** * Optional description of the resource. @@ -6984,7 +6853,22 @@ export interface UpdateInvoiceStandardRequestInput { lines?: UpdateInvoiceStandardLine[] } +/** Page paginated response. */ export interface InvoicePagePaginatedResponseInput { data: InvoiceStandardInput[] meta: PaginatedMeta } + +/** Payment settings for a billing workflow. */ +export type WorkflowPaymentSettingsInput = + | WorkflowPaymentChargeAutomaticallySettings + | WorkflowPaymentSendInvoiceSettingsInput + +/** + * Entitlement template configured on a rate card. The feature is taken from the + * rate card itself, so it is omitted here. + */ +export type RateCardEntitlementInput = + | RateCardMeteredEntitlementInput + | RateCardStaticEntitlement + | RateCardBooleanEntitlement diff --git a/api/spec/packages/aip-client-javascript/src/sdk/addons.ts b/api/spec/packages/aip-client-javascript/src/sdk/addons.ts index ee23be105f..a8b8b9e804 100644 --- a/api/spec/packages/aip-client-javascript/src/sdk/addons.ts +++ b/api/spec/packages/aip-client-javascript/src/sdk/addons.ts @@ -1,5 +1,8 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client } from '../core.js' import { unwrap, type RequestOptions } from '../lib/types.js' +import { paginatePages } from '../lib/paginate.js' import { listAddons, createAddon, @@ -25,10 +28,18 @@ import type { PublishAddonRequest, PublishAddonResponse, } from '../models/operations/addons.js' +import type { Addon } from '../models/types.js' export class Addons { constructor(private readonly _client: Client) {} + /** + * List add-ons + * + * List all add-ons. + * + * GET /openmeter/addons + */ async list( request?: ListAddonsRequest, options?: RequestOptions, @@ -36,6 +47,33 @@ export class Addons { return unwrap(await listAddons(this._client, request, options)) } + /** + * List add-ons + * + * List all add-ons. + * + * Iterates every item across all pages, fetching more as the returned iterable is consumed. + * + * GET /openmeter/addons + */ + listAll( + request?: ListAddonsRequest, + options?: RequestOptions, + ): AsyncIterable { + return paginatePages( + (req, opts) => listAddons(this._client, req, opts), + request ?? {}, + options, + ) + } + + /** + * Create add-on + * + * Create a new add-on. + * + * POST /openmeter/addons + */ async create( request: CreateAddonRequest, options?: RequestOptions, @@ -43,6 +81,13 @@ export class Addons { return unwrap(await createAddon(this._client, request, options)) } + /** + * Update add-on + * + * Update an add-on by id. + * + * PUT /openmeter/addons/{addonId} + */ async update( request: UpdateAddonRequest, options?: RequestOptions, @@ -50,6 +95,13 @@ export class Addons { return unwrap(await updateAddon(this._client, request, options)) } + /** + * Get add-on + * + * Get add-on by id. + * + * GET /openmeter/addons/{addonId} + */ async get( request: GetAddonRequest, options?: RequestOptions, @@ -57,6 +109,13 @@ export class Addons { return unwrap(await getAddon(this._client, request, options)) } + /** + * Soft delete add-on + * + * Soft delete add-on by id. + * + * DELETE /openmeter/addons/{addonId} + */ async delete( request: DeleteAddonRequest, options?: RequestOptions, @@ -64,6 +123,13 @@ export class Addons { return unwrap(await deleteAddon(this._client, request, options)) } + /** + * Archive add-on version + * + * Archive an add-on version. + * + * POST /openmeter/addons/{addonId}/archive + */ async archive( request: ArchiveAddonRequest, options?: RequestOptions, @@ -71,6 +137,13 @@ export class Addons { return unwrap(await archiveAddon(this._client, request, options)) } + /** + * Publish add-on version + * + * Publish an add-on version. + * + * POST /openmeter/addons/{addonId}/publish + */ async publish( request: PublishAddonRequest, options?: RequestOptions, diff --git a/api/spec/packages/aip-client-javascript/src/sdk/apps.ts b/api/spec/packages/aip-client-javascript/src/sdk/apps.ts index 761ed6b171..dd6f75a083 100644 --- a/api/spec/packages/aip-client-javascript/src/sdk/apps.ts +++ b/api/spec/packages/aip-client-javascript/src/sdk/apps.ts @@ -1,5 +1,8 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client } from '../core.js' import { unwrap, type RequestOptions } from '../lib/types.js' +import { paginatePages } from '../lib/paginate.js' import { listApps, getApp } from '../funcs/apps.js' import type { ListAppsRequest, @@ -7,10 +10,18 @@ import type { GetAppRequest, GetAppResponse, } from '../models/operations/apps.js' +import type { App } from '../models/types.js' export class Apps { constructor(private readonly _client: Client) {} + /** + * List apps + * + * List installed apps. + * + * GET /openmeter/apps + */ async list( request?: ListAppsRequest, options?: RequestOptions, @@ -18,6 +29,33 @@ export class Apps { return unwrap(await listApps(this._client, request, options)) } + /** + * List apps + * + * List installed apps. + * + * Iterates every item across all pages, fetching more as the returned iterable is consumed. + * + * GET /openmeter/apps + */ + listAll( + request?: ListAppsRequest, + options?: RequestOptions, + ): AsyncIterable { + return paginatePages( + (req, opts) => listApps(this._client, req, opts), + request ?? {}, + options, + ) + } + + /** + * Get app + * + * Get an installed app. + * + * GET /openmeter/apps/{appId} + */ async get( request: GetAppRequest, options?: RequestOptions, diff --git a/api/spec/packages/aip-client-javascript/src/sdk/billing.ts b/api/spec/packages/aip-client-javascript/src/sdk/billing.ts index 73fe6f232c..111d1d2b8b 100644 --- a/api/spec/packages/aip-client-javascript/src/sdk/billing.ts +++ b/api/spec/packages/aip-client-javascript/src/sdk/billing.ts @@ -1,5 +1,8 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client } from '../core.js' import { unwrap, type RequestOptions } from '../lib/types.js' +import { paginatePages } from '../lib/paginate.js' import { listBillingProfiles, createBillingProfile, @@ -19,10 +22,18 @@ import type { DeleteBillingProfileRequest, DeleteBillingProfileResponse, } from '../models/operations/billing.js' +import type { Profile } from '../models/types.js' export class Billing { constructor(private readonly _client: Client) {} + /** + * List billing profiles + * + * List billing profiles. + * + * GET /openmeter/profiles + */ async listProfiles( request?: ListBillingProfilesRequest, options?: RequestOptions, @@ -30,6 +41,38 @@ export class Billing { return unwrap(await listBillingProfiles(this._client, request, options)) } + /** + * List billing profiles + * + * List billing profiles. + * + * Iterates every item across all pages, fetching more as the returned iterable is consumed. + * + * GET /openmeter/profiles + */ + listProfilesAll( + request?: ListBillingProfilesRequest, + options?: RequestOptions, + ): AsyncIterable { + return paginatePages( + (req, opts) => listBillingProfiles(this._client, req, opts), + request ?? {}, + options, + ) + } + + /** + * Create a new billing profile + * + * Create a new billing profile. + * + * Billing profiles contain the settings for billing and controls invoice + * generation. An organization can have multiple billing profiles defined. A + * billing profile is linked to a specific app. This association is established + * during the billing profile's creation and remains immutable. + * + * POST /openmeter/profiles + */ async createProfile( request: CreateBillingProfileRequest, options?: RequestOptions, @@ -37,6 +80,13 @@ export class Billing { return unwrap(await createBillingProfile(this._client, request, options)) } + /** + * Get a billing profile + * + * Get a billing profile. + * + * GET /openmeter/profiles/{id} + */ async getProfile( request: GetBillingProfileRequest, options?: RequestOptions, @@ -44,6 +94,13 @@ export class Billing { return unwrap(await getBillingProfile(this._client, request, options)) } + /** + * Update a billing profile + * + * Update a billing profile. + * + * PUT /openmeter/profiles/{id} + */ async updateProfile( request: UpdateBillingProfileRequest, options?: RequestOptions, @@ -51,6 +108,19 @@ export class Billing { return unwrap(await updateBillingProfile(this._client, request, options)) } + /** + * Delete a billing profile + * + * Delete a billing profile. + * + * Only such billing profiles can be deleted that are: + * + * - not the default profile + * - not pinned to any customer using customer overrides + * - only have finalized invoices + * + * DELETE /openmeter/profiles/{id} + */ async deleteProfile( request: DeleteBillingProfileRequest, options?: RequestOptions, diff --git a/api/spec/packages/aip-client-javascript/src/sdk/currencies.ts b/api/spec/packages/aip-client-javascript/src/sdk/currencies.ts deleted file mode 100644 index 3845f68f17..0000000000 --- a/api/spec/packages/aip-client-javascript/src/sdk/currencies.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { type Client } from '../core.js' -import { unwrap, type RequestOptions } from '../lib/types.js' -import { - listCurrencies, - createCustomCurrency, - listCostBases, - createCostBasis, -} from '../funcs/currencies.js' -import type { - ListCurrenciesRequest, - ListCurrenciesResponse, - CreateCustomCurrencyRequest, - CreateCustomCurrencyResponse, - ListCostBasesRequest, - ListCostBasesResponse, - CreateCostBasisRequest, - CreateCostBasisResponse, -} from '../models/operations/currencies.js' - -export class Currencies { - constructor(private readonly _client: Client) {} - - async list( - request?: ListCurrenciesRequest, - options?: RequestOptions, - ): Promise { - return unwrap(await listCurrencies(this._client, request, options)) - } - - async createCustomCurrency( - request: CreateCustomCurrencyRequest, - options?: RequestOptions, - ): Promise { - return unwrap(await createCustomCurrency(this._client, request, options)) - } - - async listCostBases( - request: ListCostBasesRequest, - options?: RequestOptions, - ): Promise { - return unwrap(await listCostBases(this._client, request, options)) - } - - async createCostBasis( - request: CreateCostBasisRequest, - options?: RequestOptions, - ): Promise { - return unwrap(await createCostBasis(this._client, request, options)) - } -} diff --git a/api/spec/packages/aip-client-javascript/src/sdk/customers.ts b/api/spec/packages/aip-client-javascript/src/sdk/customers.ts index ca356feb8c..4b54847a9c 100644 --- a/api/spec/packages/aip-client-javascript/src/sdk/customers.ts +++ b/api/spec/packages/aip-client-javascript/src/sdk/customers.ts @@ -1,5 +1,8 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client } from '../core.js' import { unwrap, type RequestOptions } from '../lib/types.js' +import { paginateCursor, paginatePages } from '../lib/paginate.js' import { createCustomer, getCustomer, @@ -61,10 +64,21 @@ import type { CreateCustomerChargesRequest, CreateCustomerChargesResponse, } from '../models/operations/customers.js' +import type { + Charge, + CreditGrant, + CreditTransaction, + Customer, +} from '../models/types.js' export class Customers { constructor(private readonly _client: Client) {} + /** + * Create customer + * + * POST /openmeter/customers + */ async create( request: CreateCustomerRequest, options?: RequestOptions, @@ -72,6 +86,11 @@ export class Customers { return unwrap(await createCustomer(this._client, request, options)) } + /** + * Get customer + * + * GET /openmeter/customers/{customerId} + */ async get( request: GetCustomerRequest, options?: RequestOptions, @@ -79,6 +98,11 @@ export class Customers { return unwrap(await getCustomer(this._client, request, options)) } + /** + * List customers + * + * GET /openmeter/customers + */ async list( request?: ListCustomersRequest, options?: RequestOptions, @@ -86,6 +110,29 @@ export class Customers { return unwrap(await listCustomers(this._client, request, options)) } + /** + * List customers + * + * Iterates every item across all pages, fetching more as the returned iterable is consumed. + * + * GET /openmeter/customers + */ + listAll( + request?: ListCustomersRequest, + options?: RequestOptions, + ): AsyncIterable { + return paginatePages( + (req, opts) => listCustomers(this._client, req, opts), + request ?? {}, + options, + ) + } + + /** + * Upsert customer + * + * PUT /openmeter/customers/{customerId} + */ async upsert( request: UpsertCustomerRequest, options?: RequestOptions, @@ -93,6 +140,11 @@ export class Customers { return unwrap(await upsertCustomer(this._client, request, options)) } + /** + * Delete customer + * + * DELETE /openmeter/customers/{customerId} + */ async delete( request: DeleteCustomerRequest, options?: RequestOptions, @@ -119,6 +171,11 @@ export class Customers { export class CustomersBilling { constructor(private readonly _client: Client) {} + /** + * Get customer billing data + * + * GET /openmeter/customers/{customerId}/billing + */ async get( request: GetCustomerBillingRequest, options?: RequestOptions, @@ -126,6 +183,11 @@ export class CustomersBilling { return unwrap(await getCustomerBilling(this._client, request, options)) } + /** + * Update customer billing data + * + * PUT /openmeter/customers/{customerId}/billing + */ async update( request: UpdateCustomerBillingRequest, options?: RequestOptions, @@ -133,6 +195,11 @@ export class CustomersBilling { return unwrap(await updateCustomerBilling(this._client, request, options)) } + /** + * Update customer billing app data + * + * PUT /openmeter/customers/{customerId}/billing/app-data + */ async updateAppData( request: UpdateCustomerBillingAppDataRequest, options?: RequestOptions, @@ -142,6 +209,23 @@ export class CustomersBilling { ) } + /** + * Create Stripe Checkout Session + * + * Create a [Stripe Checkout Session](https://docs.stripe.com/payments/checkout) + * for the customer. + * + * Creates a Checkout Session for collecting payment method information from + * customers. The session operates in "setup" mode, which collects payment details + * without charging the customer immediately. The collected payment method can be + * used for future subscription billing. + * + * For hosted checkout sessions, redirect customers to the returned URL. For + * embedded sessions, use the client_secret to initialize Stripe.js in your + * application. + * + * POST /openmeter/customers/{customerId}/billing/stripe/checkout-sessions + */ async createStripeCheckoutSession( request: CreateCustomerStripeCheckoutSessionRequest, options?: RequestOptions, @@ -151,6 +235,18 @@ export class CustomersBilling { ) } + /** + * Create Stripe customer portal session + * + * Create Stripe Customer Portal Session. + * + * Useful to redirect the customer to the Stripe Customer Portal to manage their + * payment methods, change their billing address and access their invoice history. + * Only returns URL if the customer billing profile is linked to a stripe app and + * customer. + * + * POST /openmeter/customers/{customerId}/billing/stripe/portal-sessions + */ async createStripePortalSession( request: CreateCustomerStripePortalSessionRequest, options?: RequestOptions, @@ -190,6 +286,14 @@ export class CustomersCredits { export class CustomersCreditsGrants { constructor(private readonly _client: Client) {} + /** + * Create a new credit grant + * + * Create a new credit grant. A credit grant represents an allocation of prepaid + * credits to a customer. + * + * POST /openmeter/customers/{customerId}/credits/grants + */ async create( request: CreateCreditGrantRequest, options?: RequestOptions, @@ -197,6 +301,13 @@ export class CustomersCreditsGrants { return unwrap(await createCreditGrant(this._client, request, options)) } + /** + * Get a credit grant + * + * Get a credit grant. + * + * GET /openmeter/customers/{customerId}/credits/grants/{creditGrantId} + */ async get( request: GetCreditGrantRequest, options?: RequestOptions, @@ -204,6 +315,13 @@ export class CustomersCreditsGrants { return unwrap(await getCreditGrant(this._client, request, options)) } + /** + * List credit grants + * + * List credit grants. + * + * GET /openmeter/customers/{customerId}/credits/grants + */ async list( request: ListCreditGrantsRequest, options?: RequestOptions, @@ -211,6 +329,36 @@ export class CustomersCreditsGrants { return unwrap(await listCreditGrants(this._client, request, options)) } + /** + * List credit grants + * + * List credit grants. + * + * Iterates every item across all pages, fetching more as the returned iterable is consumed. + * + * GET /openmeter/customers/{customerId}/credits/grants + */ + listAll( + request: ListCreditGrantsRequest, + options?: RequestOptions, + ): AsyncIterable { + return paginatePages( + (req, opts) => listCreditGrants(this._client, req, opts), + request, + options, + ) + } + + /** + * Update credit grant external settlement status + * + * Update the payment settlement status of an externally funded credit grant. + * + * Use this endpoint to synchronize the payment state of an external payment with + * the system so that revenue recognition and credit availability work as expected. + * + * POST /openmeter/customers/{customerId}/credits/grants/{creditGrantId}/settlement/external + */ async updateExternalSettlement( request: UpdateCreditGrantExternalSettlementRequest, options?: RequestOptions, @@ -224,6 +372,13 @@ export class CustomersCreditsGrants { export class CustomersCreditsBalance { constructor(private readonly _client: Client) {} + /** + * Get a customer's credit balance + * + * Get a credit balance. + * + * GET /openmeter/customers/{customerId}/credits/balance + */ async get( request: GetCustomerCreditBalanceRequest, options?: RequestOptions, @@ -237,6 +392,18 @@ export class CustomersCreditsBalance { export class CustomersCreditsAdjustments { constructor(private readonly _client: Client) {} + /** + * Create a credit adjustment + * + * A credit adjustment can be used to make manual adjustments to a customer's + * credit balance. + * + * Supported use-cases: + * + * - Usage correction + * + * POST /openmeter/customers/{customerId}/credits/adjustments + */ async create( request: CreateCreditAdjustmentRequest, options?: RequestOptions, @@ -248,17 +415,62 @@ export class CustomersCreditsAdjustments { export class CustomersCreditsTransactions { constructor(private readonly _client: Client) {} + /** + * List credit transactions + * + * List credit transactions for a customer. + * + * Returns an immutable, chronological record of credit movements: funded credits + * and consumed credits. Transactions are returned in reverse chronological order + * by default. + * + * GET /openmeter/customers/{customerId}/credits/transactions + */ async list( request: ListCreditTransactionsRequest, options?: RequestOptions, ): Promise { return unwrap(await listCreditTransactions(this._client, request, options)) } + + /** + * List credit transactions + * + * List credit transactions for a customer. + * + * Returns an immutable, chronological record of credit movements: funded credits + * and consumed credits. Transactions are returned in reverse chronological order + * by default. + * + * Iterates every item across all pages, fetching more as the returned iterable is consumed. + * + * GET /openmeter/customers/{customerId}/credits/transactions + */ + listAll( + request: ListCreditTransactionsRequest, + options?: RequestOptions, + ): AsyncIterable { + return paginateCursor( + (req, opts) => listCreditTransactions(this._client, req, opts), + request, + options, + ) + } } export class CustomersCharges { constructor(private readonly _client: Client) {} + /** + * List customer charges + * + * List customer charges. + * + * Returns the customer's charges that are represented as either flat fee or + * usage-based charges. + * + * GET /openmeter/customers/{customerId}/charges + */ async list( request: ListCustomerChargesRequest, options?: RequestOptions, @@ -266,6 +478,36 @@ export class CustomersCharges { return unwrap(await listCustomerCharges(this._client, request, options)) } + /** + * List customer charges + * + * List customer charges. + * + * Returns the customer's charges that are represented as either flat fee or + * usage-based charges. + * + * Iterates every item across all pages, fetching more as the returned iterable is consumed. + * + * GET /openmeter/customers/{customerId}/charges + */ + listAll( + request: ListCustomerChargesRequest, + options?: RequestOptions, + ): AsyncIterable { + return paginatePages( + (req, opts) => listCustomerCharges(this._client, req, opts), + request, + options, + ) + } + + /** + * Create customer charge + * + * Create customer charge. + * + * POST /openmeter/customers/{customerId}/charges + */ async create( request: CreateCustomerChargesRequest, options?: RequestOptions, diff --git a/api/spec/packages/aip-client-javascript/src/sdk/defaults.ts b/api/spec/packages/aip-client-javascript/src/sdk/defaults.ts index 0e2e5c8c87..dbdf48527f 100644 --- a/api/spec/packages/aip-client-javascript/src/sdk/defaults.ts +++ b/api/spec/packages/aip-client-javascript/src/sdk/defaults.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client } from '../core.js' import { unwrap, type RequestOptions } from '../lib/types.js' import { @@ -14,6 +16,11 @@ import type { export class Defaults { constructor(private readonly _client: Client) {} + /** + * Get organization default tax codes + * + * GET /openmeter/defaults/tax-codes + */ async getOrganizationTaxCodes( request: GetOrganizationDefaultTaxCodesRequest, options?: RequestOptions, @@ -23,6 +30,11 @@ export class Defaults { ) } + /** + * Update organization default tax codes + * + * PUT /openmeter/defaults/tax-codes + */ async updateOrganizationTaxCodes( request: UpdateOrganizationDefaultTaxCodesRequest, options?: RequestOptions, diff --git a/api/spec/packages/aip-client-javascript/src/sdk/entitlements.ts b/api/spec/packages/aip-client-javascript/src/sdk/entitlements.ts index b9a93a8ec0..4f88750f85 100644 --- a/api/spec/packages/aip-client-javascript/src/sdk/entitlements.ts +++ b/api/spec/packages/aip-client-javascript/src/sdk/entitlements.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client } from '../core.js' import { unwrap, type RequestOptions } from '../lib/types.js' import { listCustomerEntitlementAccess } from '../funcs/entitlements.js' @@ -9,6 +11,11 @@ import type { export class Entitlements { constructor(private readonly _client: Client) {} + /** + * List customer entitlement access + * + * GET /openmeter/customers/{customerId}/entitlement-access + */ async listCustomerAccess( request: ListCustomerEntitlementAccessRequest, options?: RequestOptions, diff --git a/api/spec/packages/aip-client-javascript/src/sdk/events.ts b/api/spec/packages/aip-client-javascript/src/sdk/events.ts index 206454f2a1..e9726475be 100644 --- a/api/spec/packages/aip-client-javascript/src/sdk/events.ts +++ b/api/spec/packages/aip-client-javascript/src/sdk/events.ts @@ -1,5 +1,8 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client } from '../core.js' import { unwrap, type RequestOptions } from '../lib/types.js' +import { paginateCursor } from '../lib/paginate.js' import { listMeteringEvents, ingestMeteringEvents } from '../funcs/events.js' import type { ListMeteringEventsRequest, @@ -7,10 +10,18 @@ import type { IngestMeteringEventsRequest, IngestMeteringEventsResponse, } from '../models/operations/events.js' +import type { IngestedEvent } from '../models/types.js' export class Events { constructor(private readonly _client: Client) {} + /** + * List metering events + * + * List ingested events. + * + * GET /openmeter/events + */ async list( request?: ListMeteringEventsRequest, options?: RequestOptions, @@ -18,6 +29,33 @@ export class Events { return unwrap(await listMeteringEvents(this._client, request, options)) } + /** + * List metering events + * + * List ingested events. + * + * Iterates every item across all pages, fetching more as the returned iterable is consumed. + * + * GET /openmeter/events + */ + listAll( + request?: ListMeteringEventsRequest, + options?: RequestOptions, + ): AsyncIterable { + return paginateCursor( + (req, opts) => listMeteringEvents(this._client, req, opts), + request ?? {}, + options, + ) + } + + /** + * Ingest metering events + * + * Ingests an event or batch of events following the CloudEvents specification. + * + * POST /openmeter/events + */ async ingest( request: IngestMeteringEventsRequest, options?: RequestOptions, diff --git a/api/spec/packages/aip-client-javascript/src/sdk/features.ts b/api/spec/packages/aip-client-javascript/src/sdk/features.ts index 9875d51462..dfd0d70fbf 100644 --- a/api/spec/packages/aip-client-javascript/src/sdk/features.ts +++ b/api/spec/packages/aip-client-javascript/src/sdk/features.ts @@ -1,5 +1,8 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client } from '../core.js' import { unwrap, type RequestOptions } from '../lib/types.js' +import { paginatePages } from '../lib/paginate.js' import { listFeatures, createFeature, @@ -22,10 +25,18 @@ import type { QueryFeatureCostRequest, QueryFeatureCostResponse, } from '../models/operations/features.js' +import type { Feature } from '../models/types.js' export class Features { constructor(private readonly _client: Client) {} + /** + * List features + * + * List all features. + * + * GET /openmeter/features + */ async list( request?: ListFeaturesRequest, options?: RequestOptions, @@ -33,6 +44,33 @@ export class Features { return unwrap(await listFeatures(this._client, request, options)) } + /** + * List features + * + * List all features. + * + * Iterates every item across all pages, fetching more as the returned iterable is consumed. + * + * GET /openmeter/features + */ + listAll( + request?: ListFeaturesRequest, + options?: RequestOptions, + ): AsyncIterable { + return paginatePages( + (req, opts) => listFeatures(this._client, req, opts), + request ?? {}, + options, + ) + } + + /** + * Create feature + * + * Create a feature. + * + * POST /openmeter/features + */ async create( request: CreateFeatureRequest, options?: RequestOptions, @@ -40,6 +78,13 @@ export class Features { return unwrap(await createFeature(this._client, request, options)) } + /** + * Get feature + * + * Get a feature by id. + * + * GET /openmeter/features/{featureId} + */ async get( request: GetFeatureRequest, options?: RequestOptions, @@ -47,6 +92,13 @@ export class Features { return unwrap(await getFeature(this._client, request, options)) } + /** + * Update feature + * + * Update a feature by id. Currently only the unit_cost field can be updated. + * + * PATCH /openmeter/features/{featureId} + */ async update( request: UpdateFeatureRequest, options?: RequestOptions, @@ -54,6 +106,13 @@ export class Features { return unwrap(await updateFeature(this._client, request, options)) } + /** + * Delete feature + * + * Delete a feature by id. + * + * DELETE /openmeter/features/{featureId} + */ async delete( request: DeleteFeatureRequest, options?: RequestOptions, @@ -61,6 +120,13 @@ export class Features { return unwrap(await deleteFeature(this._client, request, options)) } + /** + * Query feature cost + * + * Query the cost of a feature. + * + * POST /openmeter/features/{featureId}/cost/query + */ async queryCost( request: QueryFeatureCostRequest, options?: RequestOptions, diff --git a/api/spec/packages/aip-client-javascript/src/sdk/governance.ts b/api/spec/packages/aip-client-javascript/src/sdk/governance.ts deleted file mode 100644 index 0e22213028..0000000000 --- a/api/spec/packages/aip-client-javascript/src/sdk/governance.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { type Client } from '../core.js' -import { unwrap, type RequestOptions } from '../lib/types.js' -import { queryGovernanceAccess } from '../funcs/governance.js' -import type { - QueryGovernanceAccessRequest, - QueryGovernanceAccessResponse, -} from '../models/operations/governance.js' - -export class Governance { - constructor(private readonly _client: Client) {} - - async queryAccess( - request: QueryGovernanceAccessRequest, - options?: RequestOptions, - ): Promise { - return unwrap(await queryGovernanceAccess(this._client, request, options)) - } -} diff --git a/api/spec/packages/aip-client-javascript/src/sdk/invoices.ts b/api/spec/packages/aip-client-javascript/src/sdk/invoices.ts deleted file mode 100644 index 7dc3e2793a..0000000000 --- a/api/spec/packages/aip-client-javascript/src/sdk/invoices.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { type Client } from '../core.js' -import { unwrap, type RequestOptions } from '../lib/types.js' -import { - listInvoices, - getInvoice, - updateInvoice, - deleteInvoice, -} from '../funcs/invoices.js' -import type { - ListInvoicesRequest, - ListInvoicesResponse, - GetInvoiceRequest, - GetInvoiceResponse, - UpdateInvoiceRequest, - UpdateInvoiceResponse, - DeleteInvoiceRequest, - DeleteInvoiceResponse, -} from '../models/operations/invoices.js' - -export class Invoices { - constructor(private readonly _client: Client) {} - - async list( - request?: ListInvoicesRequest, - options?: RequestOptions, - ): Promise { - return unwrap(await listInvoices(this._client, request, options)) - } - - async get( - request: GetInvoiceRequest, - options?: RequestOptions, - ): Promise { - return unwrap(await getInvoice(this._client, request, options)) - } - - async update( - request: UpdateInvoiceRequest, - options?: RequestOptions, - ): Promise { - return unwrap(await updateInvoice(this._client, request, options)) - } - - async delete( - request: DeleteInvoiceRequest, - options?: RequestOptions, - ): Promise { - return unwrap(await deleteInvoice(this._client, request, options)) - } -} diff --git a/api/spec/packages/aip-client-javascript/src/sdk/llmCost.ts b/api/spec/packages/aip-client-javascript/src/sdk/llmCost.ts index aa7f8c0739..e4cb641fec 100644 --- a/api/spec/packages/aip-client-javascript/src/sdk/llmCost.ts +++ b/api/spec/packages/aip-client-javascript/src/sdk/llmCost.ts @@ -1,5 +1,8 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client } from '../core.js' import { unwrap, type RequestOptions } from '../lib/types.js' +import { paginatePages } from '../lib/paginate.js' import { listLlmCostPrices, getLlmCostPrice, @@ -19,10 +22,18 @@ import type { DeleteLlmCostOverrideRequest, DeleteLlmCostOverrideResponse, } from '../models/operations/llmCost.js' +import type { LlmCostPrice } from '../models/types.js' export class LLMCost { constructor(private readonly _client: Client) {} + /** + * List LLM cost prices + * + * List global LLM cost prices. Returns prices with overrides applied if any. + * + * GET /openmeter/llm-cost/prices + */ async listPrices( request?: ListLlmCostPricesRequest, options?: RequestOptions, @@ -30,6 +41,34 @@ export class LLMCost { return unwrap(await listLlmCostPrices(this._client, request, options)) } + /** + * List LLM cost prices + * + * List global LLM cost prices. Returns prices with overrides applied if any. + * + * Iterates every item across all pages, fetching more as the returned iterable is consumed. + * + * GET /openmeter/llm-cost/prices + */ + listPricesAll( + request?: ListLlmCostPricesRequest, + options?: RequestOptions, + ): AsyncIterable { + return paginatePages( + (req, opts) => listLlmCostPrices(this._client, req, opts), + request ?? {}, + options, + ) + } + + /** + * Get LLM cost price + * + * Get a specific LLM cost price by ID. Returns the price with overrides applied if + * any. + * + * GET /openmeter/llm-cost/prices/{priceId} + */ async getPrice( request: GetLlmCostPriceRequest, options?: RequestOptions, @@ -37,6 +76,13 @@ export class LLMCost { return unwrap(await getLlmCostPrice(this._client, request, options)) } + /** + * List LLM cost overrides + * + * List per-namespace price overrides. + * + * GET /openmeter/llm-cost/overrides + */ async listOverrides( request?: ListLlmCostOverridesRequest, options?: RequestOptions, @@ -44,6 +90,33 @@ export class LLMCost { return unwrap(await listLlmCostOverrides(this._client, request, options)) } + /** + * List LLM cost overrides + * + * List per-namespace price overrides. + * + * Iterates every item across all pages, fetching more as the returned iterable is consumed. + * + * GET /openmeter/llm-cost/overrides + */ + listOverridesAll( + request?: ListLlmCostOverridesRequest, + options?: RequestOptions, + ): AsyncIterable { + return paginatePages( + (req, opts) => listLlmCostOverrides(this._client, req, opts), + request ?? {}, + options, + ) + } + + /** + * Create LLM cost override + * + * Create a per-namespace price override. + * + * POST /openmeter/llm-cost/overrides + */ async createOverride( request: CreateLlmCostOverrideRequest, options?: RequestOptions, @@ -51,6 +124,13 @@ export class LLMCost { return unwrap(await createLlmCostOverride(this._client, request, options)) } + /** + * Delete LLM cost override + * + * Delete a per-namespace price override. + * + * DELETE /openmeter/llm-cost/overrides/{priceId} + */ async deleteOverride( request: DeleteLlmCostOverrideRequest, options?: RequestOptions, diff --git a/api/spec/packages/aip-client-javascript/src/sdk/meters.ts b/api/spec/packages/aip-client-javascript/src/sdk/meters.ts index d423ddc747..79829e12a7 100644 --- a/api/spec/packages/aip-client-javascript/src/sdk/meters.ts +++ b/api/spec/packages/aip-client-javascript/src/sdk/meters.ts @@ -1,5 +1,8 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client } from '../core.js' import { unwrap, type RequestOptions } from '../lib/types.js' +import { paginatePages } from '../lib/paginate.js' import { createMeter, getMeter, @@ -25,10 +28,18 @@ import type { QueryMeterCsvRequest, QueryMeterCsvResponse, } from '../models/operations/meters.js' +import type { Meter } from '../models/types.js' export class Meters { constructor(private readonly _client: Client) {} + /** + * Create meter + * + * Create a meter. + * + * POST /openmeter/meters + */ async create( request: CreateMeterRequest, options?: RequestOptions, @@ -36,6 +47,13 @@ export class Meters { return unwrap(await createMeter(this._client, request, options)) } + /** + * Get meter + * + * Get a meter by ID. + * + * GET /openmeter/meters/{meterId} + */ async get( request: GetMeterRequest, options?: RequestOptions, @@ -43,6 +61,13 @@ export class Meters { return unwrap(await getMeter(this._client, request, options)) } + /** + * List meters + * + * List meters. + * + * GET /openmeter/meters + */ async list( request?: ListMetersRequest, options?: RequestOptions, @@ -50,6 +75,33 @@ export class Meters { return unwrap(await listMeters(this._client, request, options)) } + /** + * List meters + * + * List meters. + * + * Iterates every item across all pages, fetching more as the returned iterable is consumed. + * + * GET /openmeter/meters + */ + listAll( + request?: ListMetersRequest, + options?: RequestOptions, + ): AsyncIterable { + return paginatePages( + (req, opts) => listMeters(this._client, req, opts), + request ?? {}, + options, + ) + } + + /** + * Update meter + * + * Update a meter. + * + * PUT /openmeter/meters/{meterId} + */ async update( request: UpdateMeterRequest, options?: RequestOptions, @@ -57,6 +109,13 @@ export class Meters { return unwrap(await updateMeter(this._client, request, options)) } + /** + * Delete meter + * + * Delete a meter. + * + * DELETE /openmeter/meters/{meterId} + */ async delete( request: DeleteMeterRequest, options?: RequestOptions, @@ -64,6 +123,23 @@ export class Meters { return unwrap(await deleteMeter(this._client, request, options)) } + /** + * Query meter + * + * Query a meter for usage. + * + * Set `Accept: application/json` (the default) to get a structured JSON response. + * Set `Accept: text/csv` to download the same data as a CSV file suitable for + * spreadsheets. The CSV columns, in order, are: + * + * `from, to, [subject,] [customer_id, customer_key, customer_name,] , value` + * + * The `subject` column is emitted only when `subject` is in the query's + * `group_by_dimensions`. The three `customer_*` columns are emitted together only + * when `customer_id` is in the query's `group_by_dimensions`. + * + * POST /openmeter/meters/{meterId}/query + */ async query( request: QueryMeterRequest, options?: RequestOptions, @@ -71,6 +147,7 @@ export class Meters { return unwrap(await queryMeter(this._client, request, options)) } + /** POST /openmeter/meters/{meterId}/query */ async queryCsv( request: QueryMeterCsvRequest, options?: RequestOptions, diff --git a/api/spec/packages/aip-client-javascript/src/sdk/planAddons.ts b/api/spec/packages/aip-client-javascript/src/sdk/planAddons.ts index 82b168f903..072e5b3377 100644 --- a/api/spec/packages/aip-client-javascript/src/sdk/planAddons.ts +++ b/api/spec/packages/aip-client-javascript/src/sdk/planAddons.ts @@ -1,5 +1,8 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client } from '../core.js' import { unwrap, type RequestOptions } from '../lib/types.js' +import { paginatePages } from '../lib/paginate.js' import { listPlanAddons, createPlanAddon, @@ -19,10 +22,18 @@ import type { DeletePlanAddonRequest, DeletePlanAddonResponse, } from '../models/operations/planAddons.js' +import type { PlanAddon } from '../models/types.js' export class PlanAddons { constructor(private readonly _client: Client) {} + /** + * List add-ons for plan + * + * List add-ons associated with a plan. + * + * GET /openmeter/plans/{planId}/addons + */ async list( request: ListPlanAddonsRequest, options?: RequestOptions, @@ -30,6 +41,33 @@ export class PlanAddons { return unwrap(await listPlanAddons(this._client, request, options)) } + /** + * List add-ons for plan + * + * List add-ons associated with a plan. + * + * Iterates every item across all pages, fetching more as the returned iterable is consumed. + * + * GET /openmeter/plans/{planId}/addons + */ + listAll( + request: ListPlanAddonsRequest, + options?: RequestOptions, + ): AsyncIterable { + return paginatePages( + (req, opts) => listPlanAddons(this._client, req, opts), + request, + options, + ) + } + + /** + * Add add-on to plan + * + * Add an add-on to a plan. + * + * POST /openmeter/plans/{planId}/addons + */ async create( request: CreatePlanAddonRequest, options?: RequestOptions, @@ -37,6 +75,13 @@ export class PlanAddons { return unwrap(await createPlanAddon(this._client, request, options)) } + /** + * Get add-on association for plan + * + * Get an add-on association for a plan. + * + * GET /openmeter/plans/{planId}/addons/{planAddonId} + */ async get( request: GetPlanAddonRequest, options?: RequestOptions, @@ -44,6 +89,13 @@ export class PlanAddons { return unwrap(await getPlanAddon(this._client, request, options)) } + /** + * Update add-on association for plan + * + * Update an add-on association for a plan. + * + * PUT /openmeter/plans/{planId}/addons/{planAddonId} + */ async update( request: UpdatePlanAddonRequest, options?: RequestOptions, @@ -51,6 +103,13 @@ export class PlanAddons { return unwrap(await updatePlanAddon(this._client, request, options)) } + /** + * Remove add-on from plan + * + * Remove an add-on from a plan. + * + * DELETE /openmeter/plans/{planId}/addons/{planAddonId} + */ async delete( request: DeletePlanAddonRequest, options?: RequestOptions, diff --git a/api/spec/packages/aip-client-javascript/src/sdk/plans.ts b/api/spec/packages/aip-client-javascript/src/sdk/plans.ts index 286e79a3b4..028dbe78f7 100644 --- a/api/spec/packages/aip-client-javascript/src/sdk/plans.ts +++ b/api/spec/packages/aip-client-javascript/src/sdk/plans.ts @@ -1,5 +1,8 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client } from '../core.js' import { unwrap, type RequestOptions } from '../lib/types.js' +import { paginatePages } from '../lib/paginate.js' import { listPlans, createPlan, @@ -25,10 +28,18 @@ import type { PublishPlanRequest, PublishPlanResponse, } from '../models/operations/plans.js' +import type { Plan } from '../models/types.js' export class Plans { constructor(private readonly _client: Client) {} + /** + * List plans + * + * List all plans. + * + * GET /openmeter/plans + */ async list( request?: ListPlansRequest, options?: RequestOptions, @@ -36,6 +47,33 @@ export class Plans { return unwrap(await listPlans(this._client, request, options)) } + /** + * List plans + * + * List all plans. + * + * Iterates every item across all pages, fetching more as the returned iterable is consumed. + * + * GET /openmeter/plans + */ + listAll( + request?: ListPlansRequest, + options?: RequestOptions, + ): AsyncIterable { + return paginatePages( + (req, opts) => listPlans(this._client, req, opts), + request ?? {}, + options, + ) + } + + /** + * Create plan + * + * Create a new plan. + * + * POST /openmeter/plans + */ async create( request: CreatePlanRequest, options?: RequestOptions, @@ -43,6 +81,13 @@ export class Plans { return unwrap(await createPlan(this._client, request, options)) } + /** + * Update plan + * + * Update a plan by id. + * + * PUT /openmeter/plans/{planId} + */ async update( request: UpdatePlanRequest, options?: RequestOptions, @@ -50,6 +95,13 @@ export class Plans { return unwrap(await updatePlan(this._client, request, options)) } + /** + * Get plan + * + * Get a plan by id. + * + * GET /openmeter/plans/{planId} + */ async get( request: GetPlanRequest, options?: RequestOptions, @@ -57,6 +109,13 @@ export class Plans { return unwrap(await getPlan(this._client, request, options)) } + /** + * Delete plan + * + * Delete a plan by id. + * + * DELETE /openmeter/plans/{planId} + */ async delete( request: DeletePlanRequest, options?: RequestOptions, @@ -64,6 +123,13 @@ export class Plans { return unwrap(await deletePlan(this._client, request, options)) } + /** + * Archive plan version + * + * Archive a plan version. + * + * POST /openmeter/plans/{planId}/archive + */ async archive( request: ArchivePlanRequest, options?: RequestOptions, @@ -71,6 +137,13 @@ export class Plans { return unwrap(await archivePlan(this._client, request, options)) } + /** + * Publish plan version + * + * Publish a plan version. + * + * POST /openmeter/plans/{planId}/publish + */ async publish( request: PublishPlanRequest, options?: RequestOptions, diff --git a/api/spec/packages/aip-client-javascript/src/sdk/sdk.ts b/api/spec/packages/aip-client-javascript/src/sdk/sdk.ts index 7df7721a0f..9e2b71cc86 100644 --- a/api/spec/packages/aip-client-javascript/src/sdk/sdk.ts +++ b/api/spec/packages/aip-client-javascript/src/sdk/sdk.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { Client } from '../core.js' import { Events } from './events.js' import { Meters } from './meters.js' @@ -6,16 +8,13 @@ import { Entitlements } from './entitlements.js' import { Subscriptions } from './subscriptions.js' import { Apps } from './apps.js' import { Billing } from './billing.js' -import { Invoices } from './invoices.js' import { Tax } from './tax.js' -import { Currencies } from './currencies.js' import { Features } from './features.js' import { LLMCost } from './llmCost.js' import { Plans } from './plans.js' import { Addons } from './addons.js' import { PlanAddons } from './planAddons.js' import { Defaults } from './defaults.js' -import { Governance } from './governance.js' export class OpenMeter extends Client { private _events?: Events @@ -53,21 +52,11 @@ export class OpenMeter extends Client { return (this._billing ??= new Billing(this)) } - private _invoices?: Invoices - get invoices(): Invoices { - return (this._invoices ??= new Invoices(this)) - } - private _tax?: Tax get tax(): Tax { return (this._tax ??= new Tax(this)) } - private _currencies?: Currencies - get currencies(): Currencies { - return (this._currencies ??= new Currencies(this)) - } - private _features?: Features get features(): Features { return (this._features ??= new Features(this)) @@ -97,9 +86,4 @@ export class OpenMeter extends Client { get defaults(): Defaults { return (this._defaults ??= new Defaults(this)) } - - private _governance?: Governance - get governance(): Governance { - return (this._governance ??= new Governance(this)) - } } diff --git a/api/spec/packages/aip-client-javascript/src/sdk/subscriptions.ts b/api/spec/packages/aip-client-javascript/src/sdk/subscriptions.ts index 967495d3e2..a5024e1e4a 100644 --- a/api/spec/packages/aip-client-javascript/src/sdk/subscriptions.ts +++ b/api/spec/packages/aip-client-javascript/src/sdk/subscriptions.ts @@ -1,5 +1,8 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client } from '../core.js' import { unwrap, type RequestOptions } from '../lib/types.js' +import { paginatePages } from '../lib/paginate.js' import { createSubscription, listSubscriptions, @@ -7,7 +10,6 @@ import { cancelSubscription, unscheduleCancelation, changeSubscription, - createSubscriptionAddon, listSubscriptionAddons, getSubscriptionAddon, } from '../funcs/subscriptions.js' @@ -24,17 +26,21 @@ import type { UnscheduleCancelationResponse, ChangeSubscriptionRequest, ChangeSubscriptionResponse, - CreateSubscriptionAddonRequest, - CreateSubscriptionAddonResponse, ListSubscriptionAddonsRequest, ListSubscriptionAddonsResponse, GetSubscriptionAddonRequest, GetSubscriptionAddonResponse, } from '../models/operations/subscriptions.js' +import type { Subscription, SubscriptionAddon } from '../models/types.js' export class Subscriptions { constructor(private readonly _client: Client) {} + /** + * Create subscription + * + * POST /openmeter/subscriptions + */ async create( request: CreateSubscriptionRequest, options?: RequestOptions, @@ -42,6 +48,11 @@ export class Subscriptions { return unwrap(await createSubscription(this._client, request, options)) } + /** + * List subscriptions + * + * GET /openmeter/subscriptions + */ async list( request?: ListSubscriptionsRequest, options?: RequestOptions, @@ -49,6 +60,29 @@ export class Subscriptions { return unwrap(await listSubscriptions(this._client, request, options)) } + /** + * List subscriptions + * + * Iterates every item across all pages, fetching more as the returned iterable is consumed. + * + * GET /openmeter/subscriptions + */ + listAll( + request?: ListSubscriptionsRequest, + options?: RequestOptions, + ): AsyncIterable { + return paginatePages( + (req, opts) => listSubscriptions(this._client, req, opts), + request ?? {}, + options, + ) + } + + /** + * Get subscription + * + * GET /openmeter/subscriptions/{subscriptionId} + */ async get( request: GetSubscriptionRequest, options?: RequestOptions, @@ -56,6 +90,14 @@ export class Subscriptions { return unwrap(await getSubscription(this._client, request, options)) } + /** + * Cancel subscription + * + * Cancels the subscription. Will result in a scheduling conflict if there are + * other subscriptions scheduled to start after the cancelation time. + * + * POST /openmeter/subscriptions/{subscriptionId}/cancel + */ async cancel( request: CancelSubscriptionRequest, options?: RequestOptions, @@ -63,6 +105,13 @@ export class Subscriptions { return unwrap(await cancelSubscription(this._client, request, options)) } + /** + * Unschedule subscription cancelation + * + * Unschedules the subscription cancelation. + * + * POST /openmeter/subscriptions/{subscriptionId}/unschedule-cancelation + */ async unscheduleCancelation( request: UnscheduleCancelationRequest, options?: RequestOptions, @@ -70,6 +119,14 @@ export class Subscriptions { return unwrap(await unscheduleCancelation(this._client, request, options)) } + /** + * Change subscription + * + * Closes a running subscription and starts a new one according to the + * specification. Can be used for upgrades, downgrades, and plan changes. + * + * POST /openmeter/subscriptions/{subscriptionId}/change + */ async change( request: ChangeSubscriptionRequest, options?: RequestOptions, @@ -77,13 +134,13 @@ export class Subscriptions { return unwrap(await changeSubscription(this._client, request, options)) } - async createAddon( - request: CreateSubscriptionAddonRequest, - options?: RequestOptions, - ): Promise { - return unwrap(await createSubscriptionAddon(this._client, request, options)) - } - + /** + * List subscription addons + * + * List the add-ons of a subscription. + * + * GET /openmeter/subscriptions/{subscriptionId}/addons + */ async listAddons( request: ListSubscriptionAddonsRequest, options?: RequestOptions, @@ -91,6 +148,33 @@ export class Subscriptions { return unwrap(await listSubscriptionAddons(this._client, request, options)) } + /** + * List subscription addons + * + * List the add-ons of a subscription. + * + * Iterates every item across all pages, fetching more as the returned iterable is consumed. + * + * GET /openmeter/subscriptions/{subscriptionId}/addons + */ + listAddonsAll( + request: ListSubscriptionAddonsRequest, + options?: RequestOptions, + ): AsyncIterable { + return paginatePages( + (req, opts) => listSubscriptionAddons(this._client, req, opts), + request, + options, + ) + } + + /** + * Get add-on association for subscription + * + * Get an add-on association for a subscription. + * + * GET /openmeter/subscriptions/{subscriptionId}/addons/{subscriptionAddonId} + */ async getAddon( request: GetSubscriptionAddonRequest, options?: RequestOptions, diff --git a/api/spec/packages/aip-client-javascript/src/sdk/tax.ts b/api/spec/packages/aip-client-javascript/src/sdk/tax.ts index 04c18368f4..235adf1016 100644 --- a/api/spec/packages/aip-client-javascript/src/sdk/tax.ts +++ b/api/spec/packages/aip-client-javascript/src/sdk/tax.ts @@ -1,5 +1,8 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import { type Client } from '../core.js' import { unwrap, type RequestOptions } from '../lib/types.js' +import { paginatePages } from '../lib/paginate.js' import { createTaxCode, getTaxCode, @@ -19,10 +22,16 @@ import type { DeleteTaxCodeRequest, DeleteTaxCodeResponse, } from '../models/operations/tax.js' +import type { TaxCode } from '../models/types.js' export class Tax { constructor(private readonly _client: Client) {} + /** + * Create tax code + * + * POST /openmeter/tax-codes + */ async createCode( request: CreateTaxCodeRequest, options?: RequestOptions, @@ -30,6 +39,11 @@ export class Tax { return unwrap(await createTaxCode(this._client, request, options)) } + /** + * Get tax code + * + * GET /openmeter/tax-codes/{taxCodeId} + */ async getCode( request: GetTaxCodeRequest, options?: RequestOptions, @@ -37,6 +51,11 @@ export class Tax { return unwrap(await getTaxCode(this._client, request, options)) } + /** + * List tax codes + * + * GET /openmeter/tax-codes + */ async listCodes( request?: ListTaxCodesRequest, options?: RequestOptions, @@ -44,6 +63,29 @@ export class Tax { return unwrap(await listTaxCodes(this._client, request, options)) } + /** + * List tax codes + * + * Iterates every item across all pages, fetching more as the returned iterable is consumed. + * + * GET /openmeter/tax-codes + */ + listCodesAll( + request?: ListTaxCodesRequest, + options?: RequestOptions, + ): AsyncIterable { + return paginatePages( + (req, opts) => listTaxCodes(this._client, req, opts), + request ?? {}, + options, + ) + } + + /** + * Upsert tax code + * + * PUT /openmeter/tax-codes/{taxCodeId} + */ async upsertCode( request: UpsertTaxCodeRequest, options?: RequestOptions, @@ -51,6 +93,11 @@ export class Tax { return unwrap(await upsertTaxCode(this._client, request, options)) } + /** + * Delete tax code + * + * DELETE /openmeter/tax-codes/{taxCodeId} + */ async deleteCode( request: DeleteTaxCodeRequest, options?: RequestOptions, diff --git a/api/spec/packages/aip-client-javascript/tests/client.spec.ts b/api/spec/packages/aip-client-javascript/tests/client.spec.ts index 6366eb8cbf..76118fd80c 100644 --- a/api/spec/packages/aip-client-javascript/tests/client.spec.ts +++ b/api/spec/packages/aip-client-javascript/tests/client.spec.ts @@ -1,6 +1,9 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import fetchMock from '@fetch-mock/vitest' import { beforeEach, describe, expect, it } from 'vitest' import { OpenMeter, ServerList } from '../src/index.js' +import { SDK_VERSION } from '../src/lib/version.js' const meter = { id: '01ARZ3NDEKTSV4RRFFQ69G5FAV', @@ -32,6 +35,11 @@ function lastAuth(): string | null { return new Headers(headers).get('authorization') } +function lastUserAgent(): string | null { + const headers = fetchMock.callHistory.lastCall()!.options.headers as Headers + return new Headers(headers).get('user-agent') +} + const fetch = fetchMock.fetchHandler describe('base URL construction', () => { @@ -121,6 +129,31 @@ describe('option clobbering is prevented', () => { }) }) +describe('SDK telemetry headers', () => { + it('sets a default User-Agent header', async () => { + mockMeter() + const sdk = new OpenMeter({ + baseUrl: 'https://eu.api.konghq.com/v3', + apiKey: 'k', + fetch, + }) + await sdk.meters.get({ meterId: 'm' }) + expect(lastUserAgent()).toBe(`openmeter-node/${SDK_VERSION}`) + }) + + it('does not overwrite a caller-provided User-Agent', async () => { + mockMeter() + const sdk = new OpenMeter({ + baseUrl: 'https://eu.api.konghq.com/v3', + apiKey: 'k', + headers: { 'User-Agent': 'custom-agent/1.0' }, + fetch, + }) + await sdk.meters.get({ meterId: 'm' }) + expect(lastUserAgent()).toBe('custom-agent/1.0') + }) +}) + describe('namespace composition', () => { it('memoizes namespace accessors', () => { const sdk = new OpenMeter({ diff --git a/api/spec/packages/aip-client-javascript/tests/errors.spec.ts b/api/spec/packages/aip-client-javascript/tests/errors.spec.ts index 003e321829..81fce2a6ec 100644 --- a/api/spec/packages/aip-client-javascript/tests/errors.spec.ts +++ b/api/spec/packages/aip-client-javascript/tests/errors.spec.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import fetchMock from '@fetch-mock/vitest' import { beforeEach, describe, expect, it } from 'vitest' import { Client, HTTPError, funcs } from '../src/index.js' @@ -11,6 +13,10 @@ function client() { baseUrl: 'https://eu.api.konghq.com/v3', apiKey: 'k', fetch: fetchMock.fetchHandler, + // Several of these tests mock retryable statuses (429, 500); disable ky's + // built-in retry so each test hits the mock exactly once and isn't slowed + // down (or, for a 30s Retry-After, timed out) by ky's own backoff. + retry: 0, }) } @@ -40,6 +46,19 @@ describe('error mapping', () => { expect(httpError.message).toBe('nope') }) + it('sets the error name so it reads correctly in logs', async () => { + mockProblem(404, { + type: 't', + title: 'Not Found', + status: 404, + detail: 'nope', + instance: '/x', + }) + const result = await funcs.getMeter(client(), { meterId: 'x' }) + const httpError = result.error as HTTPError + expect(httpError.name).toBe('HTTPError') + }) + it('exposes invalid_parameters via getField', async () => { mockProblem(400, { type: 't', @@ -56,6 +75,36 @@ describe('error mapping', () => { ]) }) + it('exposes invalid_parameters via the typed invalidParameters accessor', async () => { + mockProblem(400, { + type: 't', + title: 'Bad Request', + status: 400, + detail: 'validation failed', + instance: '/x', + invalid_parameters: [ + { field: 'name', reason: 'is required' }, + { + field: 'quantity', + rule: 'min', + minimum: 1, + reason: 'must be at least 1', + }, + ], + }) + const result = await funcs.getMeter(client(), { meterId: 'x' }) + const httpError = result.error as HTTPError + expect(httpError.invalidParameters).toEqual([ + { field: 'name', reason: 'is required' }, + { + field: 'quantity', + rule: 'min', + minimum: 1, + reason: 'must be at least 1', + }, + ]) + }) + it('falls back to a status-only error for non-problem responses', async () => { fetchMock.route('*', { status: 500, @@ -66,5 +115,61 @@ describe('error mapping', () => { expect(result.error).toBeInstanceOf(HTTPError) const httpError = result.error as HTTPError expect(httpError.status).toBe(500) + expect(httpError.invalidParameters).toBeUndefined() + }) +}) + +describe('retryAfter', () => { + it('parses a delta-seconds Retry-After header', async () => { + fetchMock.route('*', { + status: 429, + body: { + type: 't', + title: 'Too Many Requests', + status: 429, + detail: 'slow down', + instance: '/x', + }, + headers: { + 'Content-Type': 'application/problem+json; charset=utf-8', + 'Retry-After': '30', + }, + }) + const result = await funcs.getMeter(client(), { meterId: 'x' }) + const httpError = result.error as HTTPError + expect(httpError.retryAfter).toBe(30) + }) + + it('is undefined when the header is absent', async () => { + mockProblem(500, { + type: 't', + title: 'Internal Server Error', + status: 500, + detail: 'boom', + instance: '/x', + }) + const result = await funcs.getMeter(client(), { meterId: 'x' }) + const httpError = result.error as HTTPError + expect(httpError.retryAfter).toBeUndefined() + }) + + it('is undefined for an HTTP-date Retry-After header', async () => { + fetchMock.route('*', { + status: 429, + body: { + type: 't', + title: 'Too Many Requests', + status: 429, + detail: 'slow down', + instance: '/x', + }, + headers: { + 'Content-Type': 'application/problem+json; charset=utf-8', + 'Retry-After': 'Wed, 21 Oct 2015 07:28:00 GMT', + }, + }) + const result = await funcs.getMeter(client(), { meterId: 'x' }) + const httpError = result.error as HTTPError + expect(httpError.retryAfter).toBeUndefined() }) }) diff --git a/api/spec/packages/aip-client-javascript/tests/index-exports.spec.ts b/api/spec/packages/aip-client-javascript/tests/index-exports.spec.ts new file mode 100644 index 0000000000..5bf8c899b3 --- /dev/null +++ b/api/spec/packages/aip-client-javascript/tests/index-exports.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import type { Price, UpdateMeterRequest } from '../src/index.js' + +// The package root must resolve operation-alias names to the operation envelope, +// not to a raw domain model that shares the name. 18 names used to collide, and +// the explicit model re-export in index.ts silently shadowed the operation alias +// (ESM named exports win over `export type *`) with an incompatible shape — +// e.g. `UpdateMeterRequest` resolved to the bare update body, missing `meterId`. +// Checked at compile time by the tests typecheck; the runtime test only anchors +// the file in the suite. +type _UpdateMeterRequestIsEnvelope = UpdateMeterRequest extends { + meterId: string + body: unknown +} + ? true + : { __error: 'UpdateMeterRequest lost its operation envelope shape' } +const _updateMeterRequestIsEnvelope: _UpdateMeterRequestIsEnvelope = true + +// `Price` is a TypeSpec named union (`@discriminated union Price { free: ..., +// flat: ..., ... }`); before the emitter aliased named unions to a `types.ts` +// declaration, only its expansion (`PriceFree | PriceFlat | ...`) was +// reachable at each use site, so `Price` itself could not be imported, +// annotated, or narrowed by a standalone function. This narrows the imported +// alias by its discriminator and reads a variant-specific field, so a +// regression that inlines the union again (or drops the alias export) fails +// to compile. +function priceAmount(price: Price): string | number { + switch (price.type) { + case 'free': + return 0 + case 'flat': + case 'unit': + return price.amount + case 'graduated': + case 'volume': + return price.tiers.length + } +} + +describe('package-root type exports', () => { + it('resolves formerly colliding names to the operation envelope', () => { + expect(_updateMeterRequestIsEnvelope).toBe(true) + }) + + it('imports and narrows the named union alias Price', () => { + const free: Price = { type: 'free' } + const flat: Price = { type: 'flat', amount: '10' } + expect(priceAmount(free)).toBe(0) + expect(priceAmount(flat)).toBe('10') + }) +}) diff --git a/api/spec/packages/aip-client-javascript/tests/meters.spec.ts b/api/spec/packages/aip-client-javascript/tests/meters.spec.ts index 3ca7af8a6f..c1063be665 100644 --- a/api/spec/packages/aip-client-javascript/tests/meters.spec.ts +++ b/api/spec/packages/aip-client-javascript/tests/meters.spec.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import fetchMock from '@fetch-mock/vitest' import { beforeEach, describe, expect, it } from 'vitest' import { Client, funcs } from '../src/index.js' diff --git a/api/spec/packages/aip-client-javascript/tests/nesting.spec.ts b/api/spec/packages/aip-client-javascript/tests/nesting.spec.ts index feb73019f3..79f63abd52 100644 --- a/api/spec/packages/aip-client-javascript/tests/nesting.spec.ts +++ b/api/spec/packages/aip-client-javascript/tests/nesting.spec.ts @@ -1,3 +1,5 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + import fetchMock from '@fetch-mock/vitest' import { beforeEach, describe, expect, it } from 'vitest' import { OpenMeter } from '../src/index.js' diff --git a/api/spec/packages/aip-client-javascript/tests/paginate.spec.ts b/api/spec/packages/aip-client-javascript/tests/paginate.spec.ts new file mode 100644 index 0000000000..b65df81994 --- /dev/null +++ b/api/spec/packages/aip-client-javascript/tests/paginate.spec.ts @@ -0,0 +1,396 @@ +import fetchMock from '@fetch-mock/vitest' +import { beforeEach, describe, expect, it } from 'vitest' +import { OpenMeter, type IngestedEvent, type Meter } from '../src/index.js' +import { + PaginationLimitExceededError, + paginateCursor, + paginatePages, +} from '../src/lib/paginate.js' +import type { Result } from '../src/lib/types.js' + +beforeEach(() => { + fetchMock.mockReset() +}) + +function client() { + return new OpenMeter({ + baseUrl: 'https://eu.api.konghq.com/v3', + apiKey: 'k', + fetch: fetchMock.fetchHandler, + }) +} + +function meterFixture(key: string) { + return { + id: `01ARZ3NDEKTSV4RRFFQ69G5${key.toUpperCase().padStart(4, '0')}`, + name: key, + key, + aggregation: 'count', + event_type: 'api-request', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + } +} + +function eventFixture(id: string) { + return { + event: { id, source: 'test', specversion: '1.0', type: 'test.event' }, + ingested_at: '2024-01-01T00:00:00Z', + stored_at: '2024-01-01T00:00:00Z', + } +} + +function jsonRoute(body: unknown) { + return { body, headers: { 'Content-Type': 'application/json' } } +} + +describe('paginatePages (page-number pagination)', () => { + it('iterates every item across 3 pages via client.meters.listAll', async () => { + // total 5 across pages of size 2, 2, 1 — the last page is short, which is + // what actually stops the iteration (see the exact-total test below for + // the other stop condition). + const pageKeys = [['a', 'b'], ['c', 'd'], ['e']] + fetchMock.route('*', (callLog) => { + const number = Number(callLog.queryParams?.get('page[number]') ?? '1') + const keys = pageKeys[number - 1] ?? [] + return jsonRoute({ + data: keys.map(meterFixture), + meta: { page: { number, size: 2, total: 5 } }, + }) + }) + + const keys: string[] = [] + for await (const meter of client().meters.listAll()) { + keys.push(meter.key) + } + + expect(keys).toEqual(['a', 'b', 'c', 'd', 'e']) + expect(fetchMock.callHistory.calls()).toHaveLength(3) + }) + + it('stops without an extra request when the final page exactly fills total', async () => { + fetchMock.route('*', (callLog) => { + const number = Number(callLog.queryParams?.get('page[number]') ?? '1') + const keys = number === 1 ? ['a', 'b'] : [] + return jsonRoute({ + data: keys.map(meterFixture), + meta: { page: { number, size: 2, total: 2 } }, + }) + }) + + const keys: string[] = [] + for await (const meter of client().meters.listAll()) { + keys.push(meter.key) + } + + expect(keys).toEqual(['a', 'b']) + expect(fetchMock.callHistory.calls()).toHaveLength(1) + }) + + it('yields nothing and fetches once for an empty first page', async () => { + fetchMock.route( + '*', + jsonRoute({ + data: [], + meta: { page: { number: 1, size: 10, total: 0 } }, + }), + ) + + const keys: string[] = [] + for await (const meter of client().meters.listAll()) { + keys.push(meter.key) + } + + expect(keys).toEqual([]) + expect(fetchMock.callHistory.calls()).toHaveLength(1) + }) + + it('fires no further requests once the consumer breaks early', async () => { + const pageKeys = [ + ['a', 'b'], + ['c', 'd'], + ] + fetchMock.route('*', (callLog) => { + const number = Number(callLog.queryParams?.get('page[number]') ?? '1') + return jsonRoute({ + data: (pageKeys[number - 1] ?? []).map(meterFixture), + meta: { page: { number, size: 2, total: 4 } }, + }) + }) + + for await (const meter of client().meters.listAll()) { + expect(meter.key).toBe('a') + break + } + + expect(fetchMock.callHistory.calls()).toHaveLength(1) + }) + + it('sends the caller-supplied filter and sort on every page, advancing only page.number', async () => { + fetchMock.route('*', (callLog) => { + const number = Number(callLog.queryParams?.get('page[number]') ?? '1') + const keys = number < 2 ? ['a', 'b'] : ['c'] + return jsonRoute({ + data: keys.map(meterFixture), + meta: { page: { number, size: 2, total: 3 } }, + }) + }) + + const items: Meter[] = [] + for await (const meter of client().meters.listAll({ + filter: { key: { eq: 'api' } }, + sort: { by: 'createdAt', order: 'desc' }, + })) { + items.push(meter) + } + + expect(items).toHaveLength(3) + const calls = fetchMock.callHistory.calls() + expect(calls).toHaveLength(2) + for (const [i, call] of calls.entries()) { + const q = new URL(call.url).searchParams + expect(q.get('filter[key][eq]')).toBe('api') + expect(q.get('sort')).toBe('created_at desc') + // The first request is whatever the caller supplied — no page.number at + // all, left to the server's own default — only later requests carry an + // explicit page.number, advanced from the previous response. + expect(q.get('page[number]')).toBe(i === 0 ? null : String(i + 1)) + } + }) + + it('propagates the AbortSignal to every page fetch, and aborting stops iteration', async () => { + const controller = new AbortController() + // ky composes the caller's signal into its own dependent AbortSignal + // before it reaches fetch (its `.aborted` state tracks the original, but + // it is not the same object), so propagation is verified behaviorally — + // a signal reaches every dispatched request, and aborting mid-iteration + // stops it — rather than by reference identity. + const signals: (AbortSignal | undefined)[] = [] + fetchMock.route('*', (callLog) => { + signals.push(callLog.signal) + const number = Number(callLog.queryParams?.get('page[number]') ?? '1') + return jsonRoute({ + data: [meterFixture(`m${number}`)], + meta: { page: { number, size: 1, total: 5 } }, + }) + }) + + const keys: string[] = [] + await expect(async () => { + for await (const meter of client().meters.listAll(undefined, { + signal: controller.signal, + })) { + keys.push(meter.key) + controller.abort() + } + }).rejects.toThrow() + + // The abort races with the second page's already-dispatched request: the + // mock still resolves a response for it (this route intercepts before + // ky/fetch's own abort check settles the promise), but the abort still + // wins — no page-2 item is yielded, and the iterable rejects instead of + // completing normally. + expect(keys).toEqual(['m1']) + expect(signals.length).toBeGreaterThanOrEqual(1) + expect(signals.every((signal) => signal !== undefined)).toBe(true) + }) + + it('throws PaginationLimitExceededError instead of looping forever on a server that never signals the end', async () => { + let calls = 0 + const fetchPage = async (): Promise< + Result<{ + data: unknown[] + meta: { page: { number: number; size: number; total: number } } + }> + > => { + calls++ + return { + ok: true, + value: { + data: [{}], + meta: { + page: { number: calls, size: 1, total: Number.MAX_SAFE_INTEGER }, + }, + }, + } + } + + await expect(async () => { + for await (const _ of paginatePages(fetchPage, {})) { + // drain + } + }).rejects.toThrow(PaginationLimitExceededError) + expect(calls).toBe(10_000) + }) +}) + +describe('paginateCursor (cursor pagination)', () => { + it('iterates every item across 3 pages via client.events.listAll', async () => { + const pages: Record = { + '': { ids: ['1', '2'], next: 'cursor-2' }, + 'cursor-2': { ids: ['3', '4'], next: 'cursor-3' }, + 'cursor-3': { ids: ['5'], next: undefined }, + } + fetchMock.route('*', (callLog) => { + const after = callLog.queryParams?.get('page[after]') ?? '' + const page = pages[after]! + return jsonRoute({ + data: page.ids.map(eventFixture), + meta: { page: { next: page.next ?? null } }, + }) + }) + + const ids: string[] = [] + for await (const event of client().events.listAll()) { + ids.push(event.event.id) + } + + expect(ids).toEqual(['1', '2', '3', '4', '5']) + expect(fetchMock.callHistory.calls()).toHaveLength(3) + }) + + it('stops after one fetch when the first page has no next cursor', async () => { + fetchMock.route( + '*', + jsonRoute({ + data: [eventFixture('1')], + meta: { page: { next: null } }, + }), + ) + + const ids: string[] = [] + for await (const event of client().events.listAll()) { + ids.push(event.event.id) + } + + expect(ids).toEqual(['1']) + expect(fetchMock.callHistory.calls()).toHaveLength(1) + }) + + it('fires no further requests once the consumer breaks early', async () => { + const pages: Record = { + '': { ids: ['1', '2'], next: 'cursor-2' }, + 'cursor-2': { ids: ['3'], next: undefined }, + } + fetchMock.route('*', (callLog) => { + const after = callLog.queryParams?.get('page[after]') ?? '' + const page = pages[after]! + return jsonRoute({ + data: page.ids.map(eventFixture), + meta: { page: { next: page.next ?? null } }, + }) + }) + + for await (const event of client().events.listAll()) { + expect(event.event.id).toBe('1') + break + } + + expect(fetchMock.callHistory.calls()).toHaveLength(1) + }) + + it('feeds meta.page.next back as page.after verbatim (an opaque token, not a URI to fetch)', async () => { + const pages: Record = { + '': { ids: ['1'], next: 'opaque-token-not-a-url' }, + 'opaque-token-not-a-url': { ids: ['2'], next: undefined }, + } + fetchMock.route('*', (callLog) => { + const after = callLog.queryParams?.get('page[after]') ?? '' + const page = pages[after]! + return jsonRoute({ + data: page.ids.map(eventFixture), + meta: { page: { next: page.next ?? null } }, + }) + }) + + const ids: string[] = [] + for await (const event of client().events.listAll()) { + ids.push(event.event.id) + } + + expect(ids).toEqual(['1', '2']) + }) + + it('sends the caller-supplied filter on every page, advancing only page.after', async () => { + const pages: Record = { + '': { ids: ['1'], next: 'cursor-2' }, + 'cursor-2': { ids: ['2'], next: undefined }, + } + fetchMock.route('*', (callLog) => { + const after = callLog.queryParams?.get('page[after]') ?? '' + const page = pages[after]! + return jsonRoute({ + data: page.ids.map(eventFixture), + meta: { page: { next: page.next ?? null } }, + }) + }) + + for await (const _ of client().events.listAll({ + filter: { subject: { eq: 'customer-1' } }, + })) { + // drain + } + + const calls = fetchMock.callHistory.calls() + expect(calls).toHaveLength(2) + for (const call of calls) { + expect(new URL(call.url).searchParams.get('filter[subject][eq]')).toBe( + 'customer-1', + ) + } + }) + + it('throws PaginationLimitExceededError instead of looping forever on a server that never stops returning a next cursor', async () => { + let calls = 0 + const fetchPage = async (): Promise< + Result<{ data: unknown[]; meta: { page: { next?: string } } }> + > => { + calls++ + return { + ok: true, + value: { data: [{}], meta: { page: { next: 'again' } } }, + } + } + + await expect(async () => { + for await (const _ of paginateCursor(fetchPage, {})) { + // drain + } + }).rejects.toThrow(PaginationLimitExceededError) + expect(calls).toBe(10_000) + }) +}) + +describe('AsyncIterable type inference', () => { + // Compile-time proof that the facade companions infer the ITEM type, not + // the page envelope — `listAll`'s return type must be exactly + // `AsyncIterable`/`AsyncIterable`, with no cast at the + // call site required to get there. + type ListAllItem = T extends AsyncIterable ? Item : never + type _MetersListAllYieldsMeter = [ + ListAllItem>, + ] extends [Meter] + ? [Meter] extends [ListAllItem>] + ? true + : { __error: 'meters.listAll yields something narrower than Meter' } + : { __error: 'meters.listAll does not yield Meter' } + const _metersListAllYieldsMeter: _MetersListAllYieldsMeter = true + + type _EventsListAllYieldsIngestedEvent = [ + ListAllItem>, + ] extends [IngestedEvent] + ? [IngestedEvent] extends [ + ListAllItem>, + ] + ? true + : { + __error: 'events.listAll yields something narrower than IngestedEvent' + } + : { __error: 'events.listAll does not yield IngestedEvent' } + const _eventsListAllYieldsIngestedEvent: _EventsListAllYieldsIngestedEvent = true + + it('type-checks the item-level inference probes above', () => { + expect(_metersListAllYieldsMeter).toBe(true) + expect(_eventsListAllYieldsIngestedEvent).toBe(true) + }) +}) diff --git a/api/spec/packages/aip-client-javascript/tests/wire-helpers.ts b/api/spec/packages/aip-client-javascript/tests/wire-helpers.ts index 9bc5967895..e0d2d13688 100644 --- a/api/spec/packages/aip-client-javascript/tests/wire-helpers.ts +++ b/api/spec/packages/aip-client-javascript/tests/wire-helpers.ts @@ -6,6 +6,7 @@ type ZodDef = { valueType?: ZodType element?: ZodType options?: ZodType[] + entries?: Record } function def(schema: ZodType | undefined): ZodDef | undefined { @@ -57,6 +58,12 @@ export function sampleCamel(schema: ZodType | undefined, depth = 0): unknown { return sampleCamel(d.options?.[0], depth) case 'literal': return (schema as { value?: unknown }).value + case 'enum': + // Without this, enum fields fall out of every sample entirely — and a + // required-with-default enum (e.g. an invoice line's `category`) then + // breaks response round-trip identity, because toWire materializes the + // default the sample never carried. + return Object.values(d.entries ?? {})[0] case 'string': return 's' case 'int': @@ -85,6 +92,10 @@ function toSnakeKeys(value: unknown): unknown { // Wire samples carry the RFC 3339 string a JSON payload would. return value.toISOString() } + if (typeof value === 'bigint') { + // Wire samples carry the JSON number an int64 payload would. + return Number(value) + } if (Array.isArray(value)) { return value.map(toSnakeKeys) } diff --git a/api/spec/packages/aip-client-javascript/tests/wire.spec.ts b/api/spec/packages/aip-client-javascript/tests/wire.spec.ts index 3ea5b07577..a72355134c 100644 --- a/api/spec/packages/aip-client-javascript/tests/wire.spec.ts +++ b/api/spec/packages/aip-client-javascript/tests/wire.spec.ts @@ -3,7 +3,12 @@ import { beforeEach, describe, expect, it } from 'vitest' import { z } from 'zod' import { Client, funcs, ValidationError } from '../src/index.js' import * as schemas from '../src/models/schemas.js' -import { DepthLimitExceededError, fromWire, toWire } from '../src/lib/wire.js' +import { + DepthLimitExceededError, + UnsafeIntegerError, + fromWire, + toWire, +} from '../src/lib/wire.js' beforeEach(() => { fetchMock.mockReset() @@ -643,3 +648,107 @@ describe('generated wire schemas (snake_case, strict)', () => { ).toBe(true) }) }) + +describe('required-with-default materialization (toWire)', () => { + const minimalEvent = { + id: 'e1', + source: 'my-app', + type: 'usage', + subject: 'customer-1', + } + + it('fills an omitted required-with-default field (event.specversion)', () => { + const wire = toWire(minimalEvent, schemas.event) as Record + expect(wire.specversion).toBe('1.0') + }) + + it('keeps a caller-provided value over the default', () => { + const wire = toWire( + { ...minimalEvent, specversion: '1.1' }, + schemas.event, + ) as Record + expect(wire.specversion).toBe('1.1') + }) + + it('replaces an explicit undefined with the default', () => { + const wire = toWire( + { ...minimalEvent, specversion: undefined }, + schemas.event, + ) as Record + expect(wire.specversion).toBe('1.0') + }) + + it('materializes into every element of a batch ingest body', () => { + const wire = toWire( + [minimalEvent, { ...minimalEvent, id: 'e2' }], + schemas.ingestMeteringEventsBody, + ) as Array> + expect(wire.map((e) => e.specversion)).toEqual(['1.0', '1.0']) + }) + + it('does not materialize spec-optional defaults (sortQuery.order)', () => { + // `.optional().default()` means the default is the server's to apply; + // writing it client-side would overwrite server state on updates. + const wire = toWire({ by: 'created_at' }, schemas.sortQuery) as Record< + string, + unknown + > + expect('order' in wire).toBe(false) + }) + + it('never fabricates fields on responses (fromWire)', () => { + const camel = fromWire(minimalEvent, schemas.event) as Record< + string, + unknown + > + expect('specversion' in camel).toBe(false) + }) + + it('sends specversion on the wire for the minimal documented ingest call', async () => { + fetchMock.route('*', 204) + const result = await funcs.ingestMeteringEvents(client(), minimalEvent) + expect(result.ok).toBe(true) + const body = JSON.parse( + fetchMock.callHistory.lastCall()!.options.body as string, + ) as Record + expect(body.specversion).toBe('1.0') + }) +}) + +describe('bigint (int64) mapping at the wire boundary', () => { + // Exercised against a hand-built schema (rather than a real int64 field like + // the checkout session's expiresAt) so this coverage survives future field + // changes in the spec. + const schema = z.object({ n: z.coerce.bigint() }) + + it('maps a bigint field to a JSON number (toWire)', () => { + const wire = toWire({ n: 1735689600n }, schema) as Record + expect(wire.n).toBe(1735689600) + // The whole point: the wire body must survive JSON serialization. + expect(() => JSON.stringify(wire)).not.toThrow() + }) + + it('throws a typed UnsafeIntegerError beyond JSON-safe integer range', () => { + for (const n of [ + BigInt(Number.MAX_SAFE_INTEGER) + 1n, + -BigInt(Number.MAX_SAFE_INTEGER) - 1n, + ]) { + expect(() => toWire({ n }, schema)).toThrow(UnsafeIntegerError) + } + }) + + it('revives a wire number into bigint at a bigint-typed node (fromWire)', () => { + const camel = fromWire({ n: 1735689600 }, schema) as Record + expect(camel.n).toBe(1735689600n) + }) + + it('passes non-integer wire values through unconverted at a bigint node', () => { + const camel = fromWire({ n: 1.5 }, schema) as Record + expect(camel.n).toBe(1.5) + }) + + it('passes a plain number through at a bigint node (untyped toWire caller)', () => { + const wire = toWire({ n: 1735689600 }, schema) as Record + expect(wire.n).toBe(1735689600) + }) +}) diff --git a/api/spec/packages/aip-client-javascript/tsconfig.json b/api/spec/packages/aip-client-javascript/tsconfig.json index 67770c35e4..adeb519165 100644 --- a/api/spec/packages/aip-client-javascript/tsconfig.json +++ b/api/spec/packages/aip-client-javascript/tsconfig.json @@ -4,8 +4,6 @@ "module": "nodenext", "strict": true, "declaration": true, - "sourceMap": true, - "declarationMap": true, "outDir": "dist" }, "include": ["src/**/*.ts"], diff --git a/api/spec/packages/aip-client-javascript/vitest.config.ts b/api/spec/packages/aip-client-javascript/vitest.config.ts index 76f5faa692..5fa61c13c0 100644 --- a/api/spec/packages/aip-client-javascript/vitest.config.ts +++ b/api/spec/packages/aip-client-javascript/vitest.config.ts @@ -1,16 +1,21 @@ import { defineConfig } from 'vitest/config' -// The boundary mapper (src/lib/wire.ts) is the one hand-written runtime module with -// no compile-time guard, so its behavior is covered entirely by tests. Hold it at -// full statement/line/function coverage. The branch threshold is below 100 because -// the residual uncovered branches are defensive nullish-coalescing arms (`?? []`, -// `?? {}`) guarding zod internals that the preceding type-guard already makes -// non-null — unreachable with any real schema, so not worth contriving inputs for. +// The boundary mapper (src/lib/wire.ts) and the pagination iterators +// (src/lib/paginate.ts) are the hand-written runtime modules with no +// compile-time guard, so their behavior is covered entirely by tests. Hold +// them at full statement/line/function coverage. The branch threshold is +// below 100 because wire.ts's residual uncovered branches are defensive +// nullish-coalescing arms (`?? []`, `?? {}`) guarding zod internals that the +// preceding type-guard already makes non-null — unreachable with any real +// schema, so not worth contriving inputs for. paginate.ts itself has no such +// residual: tests/paginate.spec.ts exercises every stop condition (empty +// page, short page, exact-total page, absent next cursor) and both +// PaginationLimitExceededError throws. export default defineConfig({ test: { coverage: { provider: 'v8', - include: ['src/lib/wire.ts'], + include: ['src/lib/wire.ts', 'src/lib/paginate.ts'], thresholds: { statements: 100, functions: 100, diff --git a/api/spec/packages/aip/tspconfig.yaml b/api/spec/packages/aip/tspconfig.yaml index 82061035a8..f51f08a6da 100644 --- a/api/spec/packages/aip/tspconfig.yaml +++ b/api/spec/packages/aip/tspconfig.yaml @@ -17,8 +17,7 @@ options: package-name: '@openmeter/client' # Markdown callout inserted after the README intro. readme-note: | - > [!IMPORTANT] - > This SDK is a work in progress. + > **Important:** This SDK is a work in progress. > > This SDK targets the [OpenMeter API v3](https://openmeter.io/docs/api/v3), > a rewrite of the OpenMeter API following AIP (API Improvement Proposal) diff --git a/api/spec/packages/typespec-typescript/scripts/gen-runtime-templates.mjs b/api/spec/packages/typespec-typescript/scripts/gen-runtime-templates.mjs deleted file mode 100644 index e5e6e64ea4..0000000000 --- a/api/spec/packages/typespec-typescript/scripts/gen-runtime-templates.mjs +++ /dev/null @@ -1,69 +0,0 @@ -// Generates src/runtime-templates.ts from the frozen baseline runtime files. -// These files are fixed boilerplate (not derived from the spec); the emitter -// copies them verbatim into the generated SDK. Run this only when the baseline -// runtime or conformance tests change. -// -// The baseline is NOT kept in the repo (it pulls vitest/vite devDeps that trip -// the workspace minimumReleaseAge constraint). Its content is already embedded -// in the committed src/runtime-templates.ts and emitted into the generated SDK, -// so `generate` does not need it. To re-run this script, restore the baseline to -// `api/spec/baseline/` first (or point BASELINE_DIR at its location). -import { readFileSync, writeFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { dirname, join } from 'node:path' - -const here = dirname(fileURLToPath(import.meta.url)) -const baseline = process.env.BASELINE_DIR - ? join(process.env.BASELINE_DIR, 'src') - : join(here, '../../../baseline/src') - -// outputPath (in the generated SDK) -> source file in the baseline. -// Runtime files are fixed boilerplate; the conformance tests are the spec the -// generated SDK must satisfy and are emitted alongside it so `test:sdk` runs -// against generated output. -const RUNTIME_FILES = { - 'src/core.ts': 'core.ts', - 'src/lib/types.ts': 'lib/types.ts', - 'src/lib/config.ts': 'lib/config.ts', - 'src/lib/encodings.ts': 'lib/encodings.ts', - 'src/lib/to-error.ts': 'lib/to-error.ts', - 'src/lib/request.ts': 'lib/request.ts', - 'src/models/errors.ts': 'models/errors.ts', - 'tests/client.spec.ts': '../tests/client.spec.ts', - 'tests/meters.spec.ts': '../tests/meters.spec.ts', - 'tests/errors.spec.ts': '../tests/errors.spec.ts', - 'tests/nesting.spec.ts': '../tests/nesting.spec.ts', -} - -function encode(content) { - // Base64 avoids any backtick/${} escaping hazards in the embedded source. - return Buffer.from(content, 'utf8').toString('base64') -} - -const entries = Object.entries(RUNTIME_FILES) - .map(([outPath, srcRel]) => { - const content = readFileSync(join(baseline, srcRel), 'utf8') - return ` ${JSON.stringify(outPath)}: '${encode(content)}',` - }) - .join('\n') - -const out = `// Code generated by scripts/gen-runtime-templates.mjs. DO NOT EDIT. -// Static SDK runtime files, copied verbatim from the baseline into the -// generated SDK. Base64-encoded to avoid escaping hazards. - -const ENCODED: Record = { -${entries} -} - -export const RUNTIME_TEMPLATES: Record = Object.fromEntries( - Object.entries(ENCODED).map(([path, b64]) => [ - path, - Buffer.from(b64, 'base64').toString('utf8'), - ]), -) -` - -writeFileSync(join(here, '../src/runtime-templates.ts'), out) -console.log( - `Wrote src/runtime-templates.ts (${entries.split('\n').length} files)`, -) diff --git a/api/spec/packages/typespec-typescript/src/ZodOperations.tsx b/api/spec/packages/typespec-typescript/src/ZodOperations.tsx index b743ac71e1..965511b4d0 100644 --- a/api/spec/packages/typespec-typescript/src/ZodOperations.tsx +++ b/api/spec/packages/typespec-typescript/src/ZodOperations.tsx @@ -13,8 +13,9 @@ import { } from '@typespec/compiler' import { $ } from '@typespec/compiler/typekit' import { getAllHttpServices } from '@typespec/http' -import { getOperationId } from '@typespec/openapi' +import { getExtensions, getOperationId } from '@typespec/openapi' import { ZodSchema } from './components/ZodSchema.jsx' +import { isSuccessStatus } from './http-status.js' import { callPart, CoerceContext, @@ -50,11 +51,37 @@ export interface OperationSchema { render: (name: string) => Children } +// Customer-visibility markers from the spec's shared/consts.tsp: x-private is +// "private and should not be exposed to customers", x-internal is "internal and +// should not be used by customers". The published SDK is exactly the customer +// surface, so operations carrying either marker are omitted from it entirely +// (they remain in the OpenAPI output, which serves internal consumers too). +// x-unstable operations stay in: most of the young v3 API carries that marker, +// and it flags maturity, not audience. +const HIDDEN_EXTENSIONS: Array<`x-${string}`> = ['x-private', 'x-internal'] + +function isHiddenFromSdk(program: Program, op: Operation): boolean { + // The walked operation is an `extends`/`op is` instance; the @extension + // decorators may live on it or on the source operation it was cloned from, + // so the whole source chain is consulted. + for ( + let current: Operation | undefined = op; + current; + current = current.sourceOperation + ) { + const extensions = getExtensions(program, current) + if (HIDDEN_EXTENSIONS.some((key) => extensions.get(key) === true)) { + return true + } + } + return false +} + /** * Collect every HTTP operation in the program, de-duplicated by the underlying * TypeSpec `Operation` (the same operation surfaces under multiple service * namespaces — OpenMeter and MeteringAndBilling — and must not be emitted - * twice). + * twice). Operations marked x-private or x-internal never enter the SDK. */ export function collectHttpOperations( program: Program, @@ -74,6 +101,9 @@ export function collectHttpOperations( const result: Operation[] = [] for (const service of included) { for (const httpOp of service.operations) { + if (isHiddenFromSdk(program, httpOp.operation)) { + continue + } const id = operationBaseName(program, httpOp.operation) if (seen.has(id)) { continue @@ -249,15 +279,3 @@ function successBodyType( } return undefined } - -function isSuccessStatus( - statusCodes: number | '*' | { start: number; end: number }, -): boolean { - if (statusCodes === '*') { - return false - } - if (typeof statusCodes === 'number') { - return statusCodes >= 200 && statusCodes < 300 - } - return statusCodes.start >= 200 && statusCodes.start < 300 -} diff --git a/api/spec/packages/typespec-typescript/src/casing-gate.ts b/api/spec/packages/typespec-typescript/src/casing-gate.ts index 69de2ccf4b..3b55df0d03 100644 --- a/api/spec/packages/typespec-typescript/src/casing-gate.ts +++ b/api/spec/packages/typespec-typescript/src/casing-gate.ts @@ -9,6 +9,7 @@ import { import { $ } from '@typespec/compiler/typekit' import '@typespec/http/experimental/typekit' import { isCasingDerivable, toCamelCase } from './casing.js' +import { isSuccessStatus } from './http-status.js' import { bodyProperties, emitsAsIntersection } from './utils.jsx' /** @@ -247,15 +248,3 @@ function mappedReachableTypes( } return { unions, models } } - -function isSuccessStatus( - statusCodes: number | '*' | { start: number; end: number }, -): boolean { - if (statusCodes === '*') { - return false - } - if (typeof statusCodes === 'number') { - return statusCodes >= 200 && statusCodes < 300 - } - return statusCodes.start >= 200 && statusCodes.start < 300 -} diff --git a/api/spec/packages/typespec-typescript/src/emitter.tsx b/api/spec/packages/typespec-typescript/src/emitter.tsx index ba83623fa8..aacd25ef7d 100644 --- a/api/spec/packages/typespec-typescript/src/emitter.tsx +++ b/api/spec/packages/typespec-typescript/src/emitter.tsx @@ -6,8 +6,10 @@ import { ListenerFlow, type Model, navigateProgram, + type Operation, type Program, type Type, + type Union, } from '@typespec/compiler' import { $ } from '@typespec/compiler/typekit' // Registers the experimental HTTP typekit ($.httpOperation, $.modelProperty @@ -29,6 +31,7 @@ import { computeResponseReachableModels, setResponseReachableModels, } from './visibility.js' +import { findPaginationTemplates, paginationInfo } from './pagination.js' import { groupOperations, jsonBodyOverrides, @@ -98,6 +101,17 @@ export async function $onEmit(context: EmitContext) { return base ? (resolved.get(base) ?? base) : undefined } const models = types.filter((t): t is Model => t.kind === 'Model') + // A union can be declared in TypeSpec — and still picked up as a zod schema, + // since `getAllDataTypes` walks the whole namespace tree — without anything + // in the SDK surface ever referencing it (`PriceUsageBased` is reserved for + // future use; `ULIDOrResourceKey`/`ULIDOrExternalResourceKey` are unused + // today). Such a union has no meaningful shape for an SDK user to import + // (often literally `string | string`), so it is excluded from the `types.ts` + // alias pass even though its zod schema is still emitted. + const reachableUnions = computeReachableUnions(context.program, operations) + const unions = types.filter( + (t): t is Union => t.kind === 'Union' && reachableUnions.has(t), + ) // Fail the build if any wire key is not recoverable from its camelCase public // form by the deterministic casing rule, before emitting anything that relies @@ -109,6 +123,7 @@ export async function $onEmit(context: EmitContext) { const interfaces = interfacesFile( context.program, models, + unions, resolveName, (name) => tsNamePolicy.getName(name, 'variable'), interfaceName, @@ -118,7 +133,7 @@ export async function $onEmit(context: EmitContext) { // matches an emitted interface (string-based, so template instantiations whose // Type identity differs from the collected model still resolve). const emittedInterfaceNames = new Set( - models + [...models, ...unions] .map((m) => resolveName(m)) .filter((n): n is string => Boolean(n)) .map(interfaceName), @@ -139,7 +154,7 @@ export async function $onEmit(context: EmitContext) { // body resolves to its `…Input` variant even when the HTTP body Type identity // differs from the collected model (same bridging as `resolveInterface`). const divergentInterfaceNames = new Set( - [...interfaces.divergentModels] + [...interfaces.divergentModels, ...interfaces.divergentUnions] .map((m) => resolveName(m)) .filter((n): n is string => Boolean(n)) .map(interfaceName), @@ -152,10 +167,18 @@ export async function $onEmit(context: EmitContext) { return divergentInterfaceNames.has(name) ? inputVariantName(name) : name } - const groups = groupOperations(operations) + const groups = groupOperations(context.program, operations) const resources = [...groups.keys()] const sdkFiles: Array<{ path: string; content: string }> = [] const readmeResources: ReadmeResource[] = [] + const paginationTemplates = findPaginationTemplates(context.program) + // Names the operations modules export (`Request`/`Response`/ + // `Query`), mirroring requestDecl/queryType/responseDecl in + // request-types.ts. indexFile must not re-export a same-named domain model: + // the explicit re-export would shadow the operation alias at the package + // root, and the two are not interchangeable (the alias wraps path params and + // AcceptDateStrings widening). + const operationTypeNames = new Set() for (const [resource, ops] of groups) { const sdkOps = ops.map((op) => sdkOperation( @@ -167,6 +190,21 @@ export async function $onEmit(context: EmitContext) { bodyOverrides, ), ) + ops.forEach((op, i) => { + sdkOps[i]!.pagination = paginationInfo( + context.program, + op, + paginationTemplates, + resolveInterface, + ) + }) + for (const op of sdkOps) { + operationTypeNames.add(`${op.base}Request`) + operationTypeNames.add(`${op.base}Response`) + if (op.queryParams.length > 0) { + operationTypeNames.add(`${op.base}Query`) + } + } readmeResources.push({ resource, ops: sdkOps }) const file = namespaceFile(resource) const requestTypes = requestTypesFor( @@ -209,7 +247,7 @@ export async function $onEmit(context: EmitContext) { sdkFiles.push({ path: 'src/sdk/sdk.ts', content: sdkRootFile(resources) }) sdkFiles.push({ path: 'src/index.ts', - content: indexFile(resources, interfaces.typeNames), + content: indexFile(resources, interfaces.typeNames, operationTypeNames), }) sdkFiles.push({ path: 'README.md', @@ -220,6 +258,12 @@ export async function $onEmit(context: EmitContext) { ), }) + // Stamped on every emitted file (repo convention for generated code): the + // four regenerated tests/ files and README.md are indistinguishable from + // hand-written files without it, inviting edits the next generate reverts. + const generatedComment = + 'Code generated by @openmeter/typespec-typescript. DO NOT EDIT.' + writeOutput( context.program, ) { namePolicy={tsNamePolicy} externals={[zod]} > - + ) { {Object.entries(RUNTIME_TEMPLATES).map(([path, content]) => ( - {content} - ))} - {sdkFiles.map(({ path, content }) => ( - {content} + + {content} + ))} + {sdkFiles.map(({ path, content }) => + path.endsWith('.md') ? ( + + {`\n\n${content}`} + + ) : ( + + {content} + + ), + )} , context.emitterOutputDir, ) @@ -348,3 +405,74 @@ function getAllDataTypes(program: Program) { return collector.types } + +/** + * Named unions transitively reachable from any operation's request body, query + * parameters, or response body (success or error) — descending through model + * properties, indexers, base/derived models, and nested union variants, the + * same shape the interface/schema walkers traverse. Used to gate which unions + * earn a `types.ts` alias: a declared-but-unreferenced union is still + * collected as a zod schema (see `getAllDataTypes`), but has nothing to alias + * to that an SDK caller could actually encounter. + */ +function computeReachableUnions( + program: Program, + operations: Operation[], +): Set { + const reachable = new Set() + const visited = new Set() + + const visit = (type: Type | undefined): void => { + if (!type || visited.has(type)) { + return + } + visited.add(type) + switch (type.kind) { + case 'Model': + if (type.indexer) { + visit(type.indexer.value) + } + if (type.baseModel) { + visit(type.baseModel) + } + for (const derived of type.derivedModels) { + visit(derived) + } + for (const prop of type.properties.values()) { + visit(prop.type) + } + break + case 'Union': + reachable.add(type) + for (const variant of type.variants.values()) { + visit(variant.type) + } + break + case 'Tuple': + for (const value of type.values) { + visit(value) + } + break + default: + break + } + } + + const tk = $(program) + for (const op of operations) { + const httpOp = tk.httpOperation.get(op) + visit(httpOp.parameters.body?.type) + for (const param of httpOp.parameters.parameters) { + if (param.type === 'query') { + visit(param.param.type) + } + } + for (const response of httpOp.responses) { + for (const content of response.responses) { + visit(content.body?.type) + } + } + } + + return reachable +} diff --git a/api/spec/packages/typespec-typescript/src/http-status.ts b/api/spec/packages/typespec-typescript/src/http-status.ts new file mode 100644 index 0000000000..fdd5392de1 --- /dev/null +++ b/api/spec/packages/typespec-typescript/src/http-status.ts @@ -0,0 +1,24 @@ +import type { HttpStatusCodesEntry } from '@typespec/http' + +/** + * Whether a response's status code(s) mark it as a success response — the ONE + * definition every walk must share (operation schemas, request/response types, + * visibility, casing gate). A divergent local copy is how an operation's + * compile-time types and its runtime mapping drift apart: a body one walk sees + * and another skips ships with unverified casing or a mismatched schema. + * + * Semantics: a single status is success in 200–299; a `{start, end}` range is + * success when it overlaps 200–299; the `"*"` default-response wildcard IS + * success — its body is mapped at runtime when no numbered success response + * exists, so every compile-time walk (including the casing gate) must examine + * it too. + */ +export function isSuccessStatus(statusCodes: HttpStatusCodesEntry): boolean { + if (statusCodes === '*') { + return true + } + if (typeof statusCodes === 'number') { + return statusCodes >= 200 && statusCodes < 300 + } + return statusCodes.start < 300 && statusCodes.end >= 200 +} diff --git a/api/spec/packages/typespec-typescript/src/input-variants.ts b/api/spec/packages/typespec-typescript/src/input-variants.ts index d1e1d8604d..6c09b1116e 100644 --- a/api/spec/packages/typespec-typescript/src/input-variants.ts +++ b/api/spec/packages/typespec-typescript/src/input-variants.ts @@ -1,4 +1,9 @@ -import { type Model, type Program, type Type } from '@typespec/compiler' +import { + type Model, + type Program, + type Type, + type Union, +} from '@typespec/compiler' import { bodyProperties } from './utils.jsx' /** @@ -85,3 +90,29 @@ export function computeDivergentModels( export function inputVariantName(outputName: string): string { return `${outputName}Input` } + +/** + * Named unions that need an `…Input` alias: at least one variant is a model + * whose own input shape diverges (see {@link computeDivergentModels}). Unions + * carry no properties of their own, so — unlike a model — a union never + * diverges on its own account; it only needs an `…Input` variant when + * substituting one of its variants for that variant's `…Input` interface + * actually changes the union's shape. Only the immediate variants are + * checked (matching the requirement that drives it: picking the right + * variant interface per branch, not a transitive default-value walk). + */ +export function computeDivergentUnions( + unions: Union[], + divergentModels: Set, +): Set { + const diverges = new Set() + for (const union of unions) { + for (const variant of union.variants.values()) { + if (variant.type.kind === 'Model' && divergentModels.has(variant.type)) { + diverges.add(union) + break + } + } + } + return diverges +} diff --git a/api/spec/packages/typespec-typescript/src/interface-types.ts b/api/spec/packages/typespec-typescript/src/interface-types.ts index f486facdfa..5ac8a83d9a 100644 --- a/api/spec/packages/typespec-typescript/src/interface-types.ts +++ b/api/spec/packages/typespec-typescript/src/interface-types.ts @@ -1,8 +1,23 @@ -import { type Model, type Program, type Type } from '@typespec/compiler' +import { + type Model, + type Program, + type Type, + type Union, +} from '@typespec/compiler' import { $ } from '@typespec/compiler/typekit' import { bodyProperties, jsdoc, publicPropertyName } from './utils.jsx' -import { type IoMode, type RefName, isOptional, tsTypeOf } from './ts-types.js' -import { computeDivergentModels, inputVariantName } from './input-variants.js' +import { + type IoMode, + type RefName, + isOptional, + tsTypeOf, + unionVariantsType, +} from './ts-types.js' +import { + computeDivergentModels, + computeDivergentUnions, + inputVariantName, +} from './input-variants.js' type ResolveName = (type: Type) => string | undefined type SchemaName = (resolvedName: string) => string @@ -75,9 +90,11 @@ export interface InterfacesResult { typeNames: string[] /** Models whose input shape diverges from their output interface. */ divergentModels: Set + /** Named unions whose input shape diverges from their output alias. */ + divergentUnions: Set /** - * Input-mode ref resolver: the `…Input` variant for a divergent model, else - * the model's interface. Used to build request/query types. + * Input-mode ref resolver: the `…Input` variant for a divergent model or + * union, else the type's plain alias. Used to build request/query types. */ refNameInput: RefName } @@ -85,12 +102,13 @@ export interface InterfacesResult { export function interfacesFile( program: Program, models: Model[], + unions: Union[], resolveName: ResolveName, schemaName: SchemaName, interfaceName: (resolvedName: string) => string, ): InterfacesResult { const tk = $(program) - const emitted = new Set(models) + const emitted = new Set([...models, ...unions]) const refName = (type: Type): string | undefined => { if (!emitted.has(type)) { return undefined @@ -100,6 +118,7 @@ export function interfacesFile( } const divergent = computeDivergentModels(program, models) + const divergentUnions = computeDivergentUnions(unions, divergent) // An input-mode ref points at a child's `…Input` variant when that child also // diverges, so relaxed optionality propagates through the subtree. const refNameInput: RefName = (type) => { @@ -107,9 +126,13 @@ export function interfacesFile( if (!name) { return undefined } - return type.kind === 'Model' && divergent.has(type) - ? inputVariantName(name) - : name + if (type.kind === 'Model' && divergent.has(type)) { + return inputVariantName(name) + } + if (type.kind === 'Union' && divergentUnions.has(type)) { + return inputVariantName(name) + } + return name } const blocks: string[] = [] @@ -173,9 +196,60 @@ export function interfacesFile( ? ` extends ${inputVariantName(refName(model.baseModel)!)}` : extendsClause const inputBody = interfaceBody(program, model, refNameInput, 'input') - inputBlocks.push( + const inputParts: string[] = [] + if (doc) { + inputParts.push(doc) + } + inputParts.push( `export interface ${inputNameStr}${inputExtends} {\n${inputBody}\n}`, ) + inputBlocks.push(inputParts.join('\n')) + asserts.push( + inputConformanceGuard( + inputNameStr, + `z.input`, + ), + ) + } + } + + for (const union of unions) { + const resolved = resolveName(union) + if (!resolved) { + continue + } + const name = interfaceName(resolved) + const schemaRef = `z.output` + const doc = jsdoc(tk.type.getDoc(union), '') + + // Exclude the union from its own ref resolution so the alias expands its + // variants (`PriceFree | PriceFlat | …`) instead of aliasing to itself. + const structural: RefName = (type) => + type === union ? undefined : refName(type) + const parts: string[] = [] + if (doc) { + parts.push(doc) + } + parts.push( + `export type ${name} = ${unionVariantsType(program, union, structural, 'output')}`, + ) + blocks.push(parts.join('\n')) + asserts.push(conformanceGuard(name, schemaRef)) + + // Mirrors the model input variant above: emitted only when substituting a + // variant's own `…Input` interface actually changes the union's shape. + if (divergentUnions.has(union)) { + const inputNameStr = inputVariantName(name) + const structuralInput: RefName = (type) => + type === union ? undefined : refNameInput(type) + const inputParts: string[] = [] + if (doc) { + inputParts.push(doc) + } + inputParts.push( + `export type ${inputNameStr} = ${unionVariantsType(program, union, structuralInput, 'input')}`, + ) + inputBlocks.push(inputParts.join('\n')) asserts.push( inputConformanceGuard( inputNameStr, @@ -200,6 +274,7 @@ export function interfacesFile( asserts: assertsFile, typeNames, divergentModels: divergent, + divergentUnions, refNameInput, } } diff --git a/api/spec/packages/typespec-typescript/src/lib.ts b/api/spec/packages/typespec-typescript/src/lib.ts index 8fa4e50141..84ca127865 100644 --- a/api/spec/packages/typespec-typescript/src/lib.ts +++ b/api/spec/packages/typespec-typescript/src/lib.ts @@ -1,4 +1,8 @@ -import { createTypeSpecLibrary, JSONSchemaType } from '@typespec/compiler' +import { + createTypeSpecLibrary, + JSONSchemaType, + paramMessage, +} from '@typespec/compiler' export interface ZodEmitterOptions { 'package-name': string @@ -44,7 +48,25 @@ export const $lib = createTypeSpecLibrary({ emitter: { options: EmitterOptionsSchema, }, - diagnostics: {}, + // Every silent-degradation path in the emitter must report one of these. + // Nothing in the current spec triggers them — they exist so future spec + // shapes the emitter cannot faithfully map surface in the generate output + // instead of quietly eroding the SDK (a z.any() schema, or an endpoint + // missing from the published client). + diagnostics: { + 'unsupported-type': { + severity: 'warning', + messages: { + default: paramMessage`${'kind'} has no schema mapping and degrades to any/unknown in the generated SDK`, + }, + }, + 'ungrouped-operation': { + severity: 'warning', + messages: { + default: paramMessage`operation '${'operation'}' has no resolvable source interface (not authored via the extends/op-is pattern) and was omitted from the generated SDK`, + }, + }, + }, }) export const { reportDiagnostic, createDiagnostic } = $lib diff --git a/api/spec/packages/typespec-typescript/src/pagination.ts b/api/spec/packages/typespec-typescript/src/pagination.ts new file mode 100644 index 0000000000..3dfa7a67ff --- /dev/null +++ b/api/spec/packages/typespec-typescript/src/pagination.ts @@ -0,0 +1,90 @@ +import type { + IndeterminateEntity, + Model, + Operation, + Program, + Type, + Value, +} from '@typespec/compiler' +import { + successResponseEnvelope, + type ResolveInterface, +} from './sdk-operations.js' + +export interface PaginationInfo { + style: 'page' | 'cursor' + /** + * The documented interface name of a single list item (`Meter`, not + * `MeterPagePaginatedResponse`) — what the emitted `All` companion + * yields. + */ + itemInterface: string +} + +export interface PaginationTemplates { + page?: Model + cursor?: Model +} + +/** + * The two shared response envelope templates pagination detection matches + * against (`Shared.PagePaginatedResponse` and + * `Shared.CursorPaginatedResponse` in `shared/responses.tsp`), resolved + * once per emit. `Shared` is looked up by name because TypeSpec has no other + * way to name "the two templates this emitter builds pagination around" — + * but the per-operation match itself ({@link paginationInfo}) is by AST node + * identity, not by name, so a `@friendlyName` rename or an interpolated name + * that happens to collide can never produce a false match. + */ +export function findPaginationTemplates(program: Program): PaginationTemplates { + const shared = program.getGlobalNamespaceType().namespaces.get('Shared') + return { + page: shared?.models.get('PagePaginatedResponse'), + cursor: shared?.models.get('CursorPaginatedResponse'), + } +} + +function asType( + entity: Type | Value | IndeterminateEntity | undefined, +): Type | undefined { + return entity?.entityKind === 'Type' ? entity : undefined +} + +/** + * Whether `op`'s success response is page- or cursor-paginated and, when it + * is, the item type a caller iterates. A response matches a style when its + * envelope Model was instantiated from the corresponding template — every + * instantiation of a TypeSpec generic model shares the declaration's syntax + * node, so comparing `.node` identity is exact regardless of the + * instantiation's own (possibly `@friendlyName`-interpolated) name. + * + * Returns undefined for a non-paginated response, or for a paginated one + * whose item type has no documented interface to name in a companion's + * `AsyncIterable<…>` — those operations get no `All` companion (see + * `sdk-files.ts`), per the emitter's "no name-string heuristics" contract: + * an item type the SDK cannot already reference by name is not something a + * companion can safely promise to yield. + */ +export function paginationInfo( + program: Program, + op: Operation, + templates: PaginationTemplates, + resolveInterface: ResolveInterface, +): PaginationInfo | undefined { + const envelope = successResponseEnvelope(program, op) + if (!envelope || envelope.kind !== 'Model') { + return undefined + } + const style = + templates.page && envelope.node === templates.page.node + ? 'page' + : templates.cursor && envelope.node === templates.cursor.node + ? 'cursor' + : undefined + if (!style) { + return undefined + } + const itemType = asType(envelope.templateMapper?.args?.[0]) + const itemInterface = resolveInterface(itemType) + return itemInterface ? { style, itemInterface } : undefined +} diff --git a/api/spec/packages/typespec-typescript/src/readme.ts b/api/spec/packages/typespec-typescript/src/readme.ts index a825038795..eac20a6390 100644 --- a/api/spec/packages/typespec-typescript/src/readme.ts +++ b/api/spec/packages/typespec-typescript/src/readme.ts @@ -30,10 +30,13 @@ function callPath(getter: string, op: SdkOperation): string { } function summaryCell(op: SdkOperation): string { - if (!op.summary) { + // `@doc` is the longer description; operations that only carry a + // `@summary` (no `@doc`) would otherwise render a blank Description cell. + const text = op.doc ?? op.summary + if (!text) { return '' } - return op.summary + return text .trim() .replace(/\s+/g, ' ') .replace(/\\/g, '\\\\') @@ -54,8 +57,12 @@ const HEADINGS = { toc: 'Table of Contents', install: 'Installation', init: 'Initialization', + config: 'Configuration', usage: 'Usage', + pagination: 'Pagination', resources: 'Available Resources and Operations', + validate: 'Runtime Validation (validate option)', + zod: 'Zod Schemas (./zod export)', errors: 'Error Handling', functions: 'Standalone Functions', } as const @@ -78,12 +85,16 @@ function tableOfContents(resources: ReadmeResource[]): string { const lines = [`## ${HEADINGS.toc}`, ''] lines.push(tocEntry(HEADINGS.install, 0)) lines.push(tocEntry(HEADINGS.init, 0)) + lines.push(tocEntry(HEADINGS.config, 0)) lines.push(tocEntry(HEADINGS.usage, 0)) + lines.push(tocEntry(HEADINGS.pagination, 0)) lines.push(tocEntry(HEADINGS.resources, 0)) for (const { resource } of resources) { const { class: cls } = namespaceNames(resource) lines.push(tocEntry(cls, 1)) } + lines.push(tocEntry(HEADINGS.validate, 0)) + lines.push(tocEntry(HEADINGS.zod, 0)) lines.push(tocEntry(HEADINGS.errors, 0)) lines.push(tocEntry(HEADINGS.functions, 0)) return lines.join('\n') @@ -140,6 +151,80 @@ function initialization(packageName: string): string { ].join('\n') } +function configuration(packageName: string): string { + return [ + `## ${HEADINGS.config}`, + '', + "`SDKOptions` extends [ky](https://github.com/sindresorhus/ky)'s", + '`Options`, so every transport setting ky supports is a top-level client', + 'option: retry policy, per-attempt timeout (ky defaults to 10 seconds),', + 'lifecycle hooks, a custom `fetch`, and so on.', + '', + '```typescript', + `import { OpenMeter } from '${packageName}'`, + '', + 'const client = new OpenMeter({', + " baseUrl: 'https://openmeter.cloud/api/v3',", + ' apiKey: process.env.OPENMETER_API_KEY,', + ' timeout: 30_000,', + ' hooks: {', + ' beforeRequest: [', + ' ({ request }) => {', + ' console.log(`-> ${request.method} ${request.url}`)', + ' },', + ' ],', + ' },', + ' fetch: async (input, init) => {', + ' const start = Date.now()', + ' const response = await fetch(input, init)', + ' console.log(`${response.status} in ${Date.now() - start}ms`)', + ' return response', + ' },', + '})', + '```', + '', + 'ky only retries the idempotent methods by default — `get`, `put`,', + '`head`, `delete`, `options`, `trace` — never `post`. That means a', + 'dropped `client.events.ingest` call is not retried on a network error', + 'or a 5xx response unless you opt in explicitly:', + '', + '```typescript', + `import { OpenMeter } from '${packageName}'`, + '', + 'const client = new OpenMeter({', + " baseUrl: 'https://openmeter.cloud/api/v3',", + ' apiKey: process.env.OPENMETER_API_KEY,', + " retry: { limit: 3, methods: ['get', 'put', 'head', 'delete', 'post'] },", + '})', + '```', + '', + 'This is safe specifically for event ingestion: the event `id` is its', + 'deduplication key server-side, so resending the same event on retry is', + 'a no-op rather than a duplicate.', + '', + 'Every method also takes a per-request `RequestOptions` as its second', + "argument — a curated subset of ky's options (`signal`, `headers`,", + '`timeout`, `retry`) applied to that call only:', + '', + '```typescript', + `import { OpenMeter } from '${packageName}'`, + '', + 'const client = new OpenMeter({', + " baseUrl: 'https://openmeter.cloud/api/v3',", + ' apiKey: process.env.OPENMETER_API_KEY,', + '})', + '', + 'const controller = new AbortController()', + 'setTimeout(() => controller.abort(), 5_000)', + '', + 'const meters = await client.meters.list(undefined, {', + ' signal: controller.signal,', + " headers: { 'X-Request-Id': 'batch-42' },", + '})', + '```', + ].join('\n') +} + function usage(packageName: string): string { return [ `## ${HEADINGS.usage}`, @@ -176,6 +261,74 @@ function usage(packageName: string): string { ].join('\n') } +function pagination(packageName: string): string { + return [ + `## ${HEADINGS.pagination}`, + '', + 'Every list operation that returns pages also has an `…All` companion —', + '`client.meters.listAll(request?)` alongside `client.meters.list(request?)` —', + 'that returns an `AsyncIterable` of items instead of one page. It fetches', + 'each following page lazily, only when the previous page is exhausted, so a', + '`break` (or a `return`) partway through never fires a request for a page', + 'nothing consumes:', + '', + '```typescript', + `import { OpenMeter } from '${packageName}'`, + '', + 'const client = new OpenMeter({', + " baseUrl: 'https://openmeter.cloud/api/v3',", + ' apiKey: process.env.OPENMETER_API_KEY,', + '})', + '', + 'for await (const meter of client.meters.listAll()) {', + ' console.log(meter.key)', + '}', + '```', + '', + 'The request object accepts the same filters, sort, and page size as the', + 'single-page method — only the page cursor/number itself advances between', + 'requests:', + '', + '```typescript', + "for await (const meter of client.meters.listAll({ filter: { key: 'api' } })) {", + ' if (meter.key === "api-requests") {', + ' break // stops iterating; no further pages are fetched', + ' }', + '}', + '```', + '', + 'Cursor-paginated resources (`events`, credit transactions) work the same', + 'way:', + '', + '```typescript', + 'for await (const event of client.events.listAll()) {', + ' console.log(event.event.id)', + '}', + '```', + '', + 'Auto-pagination takes the same optional `RequestOptions` as every other', + 'method, so one `AbortSignal` cancels the whole iteration, not just the page', + 'in flight:', + '', + '```typescript', + 'const controller = new AbortController()', + 'setTimeout(() => controller.abort(), 30_000)', + '', + 'for await (const meter of client.meters.listAll(undefined, {', + ' signal: controller.signal,', + '})) {', + ' console.log(meter.key)', + '}', + '```', + '', + 'Iteration stops after the server’s last page — an empty or short-of-`size`', + 'page for a page-number resource, or a response with no next cursor for a', + 'cursor resource. A misbehaving server that never signals the end of the', + 'list fails fast instead of looping forever: after 10,000 pages the', + 'iterable throws `PaginationLimitExceededError`.', + ].join('\n') +} + function resourcesSection(resources: ReadmeResource[]): string { const blocks = [ `## ${HEADINGS.resources}`, @@ -190,12 +343,90 @@ function resourcesSection(resources: ReadmeResource[]): string { return blocks.join('\n') } +function runtimeValidation(packageName: string): string { + return [ + `## ${HEADINGS.validate}`, + '', + "`validate` is off by default. The SDK's normal request/response mapping", + 'only renames keys and converts dates — it never rejects a payload — so', + 'an additive server-side change (a new response field, a new enum value)', + 'never breaks a client running an older SDK version.', + '', + 'Set `validate: true` to additionally check the wire payload — the', + 'request body after mapping to snake_case, and the raw response before', + 'mapping back — against the generated `…Wire` schemas. Those schemas are', + 'strict: an unknown field or an unrecognized enum value fails validation', + 'instead of being silently accepted. That makes `validate` a tool for', + 'catching SDK/server contract drift in development or CI, not something', + 'to run in production, where forward compatibility with additive server', + 'changes matters more than strict rejection.', + '', + '```typescript', + `import { OpenMeter, ValidationError } from '${packageName}'`, + '', + 'const client = new OpenMeter({', + " baseUrl: 'https://openmeter.cloud/api/v3',", + ' apiKey: process.env.OPENMETER_API_KEY,', + ' validate: true,', + '})', + '', + 'try {', + " await client.meters.get({ meterId: 'meter_123' })", + '} catch (error) {', + ' if (error instanceof ValidationError) {', + ' console.error(error.issues)', + ' }', + '}', + '```', + '', + 'The standalone functions surface the same failure as a `Result` instead', + 'of throwing — check `result.error instanceof ValidationError`.', + ].join('\n') +} + +function zodSchemas(packageName: string): string { + return [ + `## ${HEADINGS.zod}`, + '', + `\`${packageName}/zod\` exports every model twice: once shaped like the`, + "SDK's public surface (camelCase keys, `Date` for date-time fields — the", + 'same shape `meter.eventType`/`meter.createdAt` have in TypeScript) and', + 'once as a strict `…Wire` schema shaped like the literal JSON the server', + 'sends and accepts (snake_case keys, RFC 3339 date-time strings, unknown', + 'fields rejected) — the same `…Wire` schemas the `validate` option checks', + 'internally.', + '', + 'Use the public schemas to validate a payload the SDK did not produce —', + 'a webhook body, a cached record, a test fixture — before trusting its', + 'shape:', + '', + '```typescript', + `import * as schemas from '${packageName}/zod'`, + '', + 'const parsed = schemas.meter.safeParse({', + " id: '01HZY3W6VXQK6H3NPC6DFA0PJT',", + " name: 'Tokens',", + " key: 'tokens',", + " aggregation: 'sum',", + " eventType: 'request',", + ' createdAt: new Date(),', + ' updatedAt: new Date(),', + '})', + '', + 'if (parsed.success) {', + ' console.log(parsed.data.eventType)', + '}', + '```', + ].join('\n') +} + function errorHandling(packageName: string): string { return [ `## ${HEADINGS.errors}`, '', - 'A non-2xx response rejects with an `HTTPError` carrying the problem-details', - 'fields (`status`, `type`, `title`, `url`) from the response.', + "A non-2xx response rejects with an `HTTPError` (`error.name === 'HTTPError'`)", + 'carrying the problem-details fields (`status`, `type`, `title`, `url`)', + 'from the response.', '', '```typescript', `import { OpenMeter, HTTPError } from '${packageName}'`, @@ -213,6 +444,46 @@ function errorHandling(packageName: string): string { ' }', '}', '```', + '', + '`error.retryAfter` is the delta-seconds form of a numeric `Retry-After`', + 'header (the common case on 429 and 503 responses) and `undefined`', + 'otherwise. A 400 Bad Request additionally carries a typed', + '`error.invalidParameters` array describing which fields failed', + 'validation; `error.getField(key)` is an untyped escape hatch for any', + 'other problem-details extension member:', + '', + '```typescript', + `import { OpenMeter, HTTPError } from '${packageName}'`, + '', + 'const client = new OpenMeter({', + " baseUrl: 'https://openmeter.cloud/api/v3',", + ' apiKey: process.env.OPENMETER_API_KEY,', + '})', + '', + 'try {', + ' await client.meters.create({', + " name: 'Tokens',", + " key: 'tokens',", + " aggregation: 'sum',", + " eventType: 'request',", + ' })', + '} catch (error) {', + ' if (error instanceof HTTPError && error.status === 400) {', + ' for (const param of error.invalidParameters ?? []) {', + ' console.error(param)', + ' }', + ' }', + '}', + '```', + '', + "The SDK's other typed errors are `ValidationError` (see", + `[${HEADINGS.validate}](#${slug(HEADINGS.validate)})), \`UnsafeIntegerError\``, + '(an `int64`/`uint64` value exceeds what JSON can represent without', + 'precision loss), `DepthLimitExceededError` (response data nested deeper', + "than the mapper's safety limit), and `PaginationLimitExceededError` (an", + `[${HEADINGS.pagination}](#${slug(HEADINGS.pagination)}) iterable exceeded`, + 'its page-count safety limit) — each distinguished the same way, by', + '`instanceof` or by `.name`.', ].join('\n') } @@ -238,6 +509,21 @@ function standaloneFunctions(packageName: string): string { ' console.error(result.error)', '}', '```', + '', + '`ok`, `err`, and `unwrap` — the helpers `Result` is built from — are', + 'exported too, so a func call can be unwrapped back into a throwing call', + 'where that is more convenient:', + '', + '```typescript', + `import { Client, funcs, unwrap } from '${packageName}'`, + '', + 'const client = new Client({', + " baseUrl: 'https://openmeter.cloud/api/v3',", + ' apiKey: process.env.OPENMETER_API_KEY,', + '})', + '', + 'const meters = unwrap(await funcs.listMeters(client))', + '```', ].join('\n') } @@ -255,8 +541,12 @@ export function readmeFile( tableOfContents(resources), installation(packageName), initialization(packageName), + configuration(packageName), usage(packageName), + pagination(packageName), resourcesSection(resources), + runtimeValidation(packageName), + zodSchemas(packageName), errorHandling(packageName), standaloneFunctions(packageName), ].join('\n\n') + '\n' diff --git a/api/spec/packages/typespec-typescript/src/runtime-templates.ts b/api/spec/packages/typespec-typescript/src/runtime-templates.ts index b967ac1fe5..da5bb5d0e2 100644 --- a/api/spec/packages/typespec-typescript/src/runtime-templates.ts +++ b/api/spec/packages/typespec-typescript/src/runtime-templates.ts @@ -1,35 +1,40 @@ -// Code generated by scripts/gen-runtime-templates.mjs. DO NOT EDIT. -// Static SDK runtime files, copied verbatim from the baseline into the -// generated SDK. Base64-encoded to avoid escaping hazards. +// Static SDK runtime files and conformance tests, copied verbatim into the +// generated SDK. The source of truth is the committed files under +// `templates/` — real, reviewable `.ts` files, not embedded blobs. They are +// excluded from this package's own typecheck (see tsconfig.json's `include`) +// because they are checked where they actually run: as part of the generated +// `aip-client-javascript` package's own typecheck/test suite. +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' -const ENCODED: Record = { - 'src/core.ts': - 'aW1wb3J0IGt5LCB7IHR5cGUgS3lJbnN0YW5jZSBhcyBIVFRQQ2xpZW50IH0gZnJvbSAna3knCmltcG9ydCB7IHR5cGUgU0RLT3B0aW9ucyB9IGZyb20gJy4vbGliL2NvbmZpZy5qcycKaW1wb3J0IHsgZW5jb2RlUGF0aCB9IGZyb20gJy4vbGliL2VuY29kaW5ncy5qcycKCmV4cG9ydCBjbGFzcyBDbGllbnQgewogIHJlYWRvbmx5IF9vcHRpb25zOiBTREtPcHRpb25zCiAgcmVhZG9ubHkgX2h0dHA6IEhUVFBDbGllbnQKCiAgY29uc3RydWN0b3Iob3B0aW9uczogU0RLT3B0aW9ucykgewogICAgdGhpcy5fb3B0aW9ucyA9IG9wdGlvbnMKCiAgICBsZXQgYmFzZVVybCA9CiAgICAgIHR5cGVvZiBvcHRpb25zLmJhc2VVcmwgPT09ICdzdHJpbmcnCiAgICAgICAgPyBlbmNvZGVQYXRoKG9wdGlvbnMuYmFzZVVybCwgb3B0aW9ucy5zZXJ2ZXJWYXJpYWJsZXMgPz8ge30pCiAgICAgICAgOiBTdHJpbmcob3B0aW9ucy5iYXNlVXJsKQogICAgaWYgKCFiYXNlVXJsLmVuZHNXaXRoKCcvJykpIHsKICAgICAgYmFzZVVybCA9IGAke2Jhc2VVcmx9L2AKICAgIH0KCiAgICB0aGlzLl9odHRwID0ga3kuY3JlYXRlKHsKICAgICAgLi4ub3B0aW9ucywKICAgICAgYmFzZVVybCwKICAgICAgcHJlZml4OiB1bmRlZmluZWQsCiAgICAgIGhvb2tzOiB7CiAgICAgICAgLi4ub3B0aW9ucy5ob29rcywKICAgICAgICBiZWZvcmVSZXF1ZXN0OiBbCiAgICAgICAgICAuLi4ob3B0aW9ucy5ob29rcz8uYmVmb3JlUmVxdWVzdCA/PyBbXSksCiAgICAgICAgICBhc3luYyAoeyByZXF1ZXN0IH0pID0+IHsKICAgICAgICAgICAgY29uc3QgdG9rZW4gPQogICAgICAgICAgICAgIHR5cGVvZiBvcHRpb25zLmFwaUtleSA9PT0gJ2Z1bmN0aW9uJwogICAgICAgICAgICAgICAgPyBhd2FpdCBvcHRpb25zLmFwaUtleSgpCiAgICAgICAgICAgICAgICA6IG9wdGlvbnMuYXBpS2V5CiAgICAgICAgICAgIGlmICh0b2tlbikgewogICAgICAgICAgICAgIHJlcXVlc3QuaGVhZGVycy5zZXQoJ0F1dGhvcml6YXRpb24nLCBgQmVhcmVyICR7dG9rZW59YCkKICAgICAgICAgICAgfQogICAgICAgICAgfSwKICAgICAgICBdLAogICAgICB9LAogICAgfSkKICB9Cn0KCmV4cG9ydCBmdW5jdGlvbiBodHRwKGNsaWVudDogQ2xpZW50KTogSFRUUENsaWVudCB7CiAgcmV0dXJuIGNsaWVudC5faHR0cAp9CgpleHBvcnQgdHlwZSB7IEhUVFBDbGllbnQgfQo=', - 'src/lib/types.ts': - 'aW1wb3J0IHR5cGUgeyBPcHRpb25zIH0gZnJvbSAna3knCgpleHBvcnQgdHlwZSBSZXF1ZXN0T3B0aW9ucyA9IFBpY2s8CiAgT3B0aW9ucywKICAnc2lnbmFsJyB8ICdoZWFkZXJzJyB8ICd0aW1lb3V0JyB8ICdyZXRyeScKPgoKZXhwb3J0IHR5cGUgUmVzdWx0PFQsIEUgPSBFcnJvcj4gPQogIHwgeyBvazogdHJ1ZTsgdmFsdWU6IFQ7IGVycm9yPzogbmV2ZXIgfQogIHwgeyBvazogZmFsc2U7IHZhbHVlPzogbmV2ZXI7IGVycm9yOiBFIH0KCmV4cG9ydCBmdW5jdGlvbiBvazxUPih2YWx1ZTogVCk6IFJlc3VsdDxULCBuZXZlcj4gewogIHJldHVybiB7IG9rOiB0cnVlLCB2YWx1ZSB9Cn0KCmV4cG9ydCBmdW5jdGlvbiBlcnI8RT4oZXJyb3I6IEUpOiBSZXN1bHQ8bmV2ZXIsIEU+IHsKICByZXR1cm4geyBvazogZmFsc2UsIGVycm9yIH0KfQoKZXhwb3J0IGZ1bmN0aW9uIHVud3JhcDxULCBFPihyZXN1bHQ6IFJlc3VsdDxULCBFPik6IFQgewogIGlmIChyZXN1bHQub2spIHsKICAgIHJldHVybiByZXN1bHQudmFsdWUKICB9CiAgdGhyb3cgcmVzdWx0LmVycm9yCn0K', - 'src/lib/config.ts': - 'aW1wb3J0IHsgdHlwZSBPcHRpb25zIH0gZnJvbSAna3knCgpleHBvcnQgY29uc3QgU2VydmVyTGlzdCA9IFsKICAnaHR0cHM6Ly97cmVnaW9ufS5hcGkua29uZ2hxLmNvbS92MycsCiAgJ2h0dHA6Ly9sb2NhbGhvc3Q6e3BvcnR9L2FwaS92MycsCiAgJ2h0dHBzOi8vb3Blbm1ldGVyLmNsb3VkL2FwaS92MycsCl0gYXMgY29uc3QKCmV4cG9ydCBjb25zdCBSZWdpb25zID0gWwogICdpbicsCiAgJ21lJywKICAnYXUnLAogICdldScsCiAgJ3VzJywKXSBhcyBjb25zdAoKZXhwb3J0IHR5cGUgUmVnaW9uID0gKHR5cGVvZiBSZWdpb25zKVtudW1iZXJdCgpleHBvcnQgdHlwZSBTZXJ2ZXJWYXJpYWJsZXMgPSB7CiAgcmVnaW9uPzogUmVnaW9uCiAgcG9ydD86IHN0cmluZyB8IG51bWJlcgp9CgpleHBvcnQgaW50ZXJmYWNlIFNES09wdGlvbnMgZXh0ZW5kcyBPbWl0PE9wdGlvbnMsICdtZXRob2QnPiB7CiAgYmFzZVVybDogKHR5cGVvZiBTZXJ2ZXJMaXN0KVtudW1iZXJdIHwgVVJMIHwgc3RyaW5nCiAgc2VydmVyVmFyaWFibGVzPzogU2VydmVyVmFyaWFibGVzCiAgYXBpS2V5Pzogc3RyaW5nIHwgKCgpID0+IHN0cmluZyB8IFByb21pc2U8c3RyaW5nPikKICAvKioKICAgKiBWYWxpZGF0ZSByZXF1ZXN0IGJvZGllcyBhbmQgcmVzcG9uc2UgcGF5bG9hZHMgYWdhaW5zdCB0aGVpciBzY2hlbWFzLiBPZmYgYnkKICAgKiBkZWZhdWx0OiB0aGUgU0RLIG1hcHMgY2FzaW5nIGJ1dCBkb2VzIG5vdCB2YWxpZGF0ZSwgc28gYWRkaXRpdmUgc2VydmVyIGZpZWxkcwogICAqIG5ldmVyIGJyZWFrIGNsaWVudHMuIFdoZW4gb24sIGEgcmVxdWVzdCBib2R5IG9yIHJlc3BvbnNlIHRoYXQgZmFpbHMgaXRzIHNjaGVtYQogICAqIChtaXNzaW5nL3dyb25nLXR5cGVkIGZpZWxkLCB1bmtub3duIGVudW0gdmFsdWUpIHJldHVybnMgYSBmYWlsZWQgUmVzdWx0IHdob3NlCiAgICogYGVycm9yYCBpcyBhIFZhbGlkYXRpb25FcnJvciAodmFsaWRhdGlvbiBydW5zIGluc2lkZSB0aGUgU0RLJ3MgcmVxdWVzdAogICAqIGhhbmRsaW5nLCBzbyBpdCBuZXZlciByZWplY3RzL3Rocm93cykuCiAgICovCiAgdmFsaWRhdGU/OiBib29sZWFuCn0K', - 'src/lib/encodings.ts': - 'ZXhwb3J0IGZ1bmN0aW9uIGVuY29kZVBhdGgoCiAgdGVtcGxhdGU6IHN0cmluZywKICBwYXJhbXM6IFJlY29yZDxzdHJpbmcsIHN0cmluZyB8IG51bWJlcj4sCik6IHN0cmluZyB7CiAgcmV0dXJuIHRlbXBsYXRlLnJlcGxhY2UoL1x7KFx3KylcfS9nLCAoXywga2V5OiBzdHJpbmcpID0+IHsKICAgIGNvbnN0IHZhbHVlID0gcGFyYW1zW2tleV0KICAgIGlmICh2YWx1ZSA9PT0gdW5kZWZpbmVkKSB7CiAgICAgIHRocm93IG5ldyBFcnJvcihgbWlzc2luZyBwYXRoIHBhcmFtZXRlcjogJHtrZXl9YCkKICAgIH0KICAgIHJldHVybiBlbmNvZGVVUklDb21wb25lbnQoU3RyaW5nKHZhbHVlKSkKICB9KQp9CgpmdW5jdGlvbiBzZXJpYWxpemVEZWVwT2JqZWN0KAogIHByZWZpeDogc3RyaW5nLAogIHZhbHVlOiB1bmtub3duLAogIHBhcnRzOiBBcnJheTxbc3RyaW5nLCBzdHJpbmddPiwKKTogdm9pZCB7CiAgaWYgKHZhbHVlID09PSBudWxsIHx8IHZhbHVlID09PSB1bmRlZmluZWQpIHsKICAgIHJldHVybgogIH0KICBpZiAoQXJyYXkuaXNBcnJheSh2YWx1ZSkpIHsKICAgIHBhcnRzLnB1c2goW3ByZWZpeCwgdmFsdWUubWFwKFN0cmluZykuam9pbignLCcpXSkKICB9IGVsc2UgaWYgKHR5cGVvZiB2YWx1ZSA9PT0gJ29iamVjdCcpIHsKICAgIGZvciAoY29uc3QgW2ssIHZdIG9mIE9iamVjdC5lbnRyaWVzKHZhbHVlKSkgewogICAgICBzZXJpYWxpemVEZWVwT2JqZWN0KGAke3ByZWZpeH1bJHtrfV1gLCB2LCBwYXJ0cykKICAgIH0KICB9IGVsc2UgewogICAgcGFydHMucHVzaChbcHJlZml4LCBTdHJpbmcodmFsdWUpXSkKICB9Cn0KCmZ1bmN0aW9uIHNlcmlhbGl6ZVBhcmFtcygKICBwYXJhbXM6IFJlY29yZDxzdHJpbmcsIHVua25vd24+LAopOiBBcnJheTxbc3RyaW5nLCBzdHJpbmddPiB7CiAgY29uc3QgcGFydHM6IEFycmF5PFtzdHJpbmcsIHN0cmluZ10+ID0gW10KICBmb3IgKGNvbnN0IFtrZXksIHZhbHVlXSBvZiBPYmplY3QuZW50cmllcyhwYXJhbXMpKSB7CiAgICBzZXJpYWxpemVEZWVwT2JqZWN0KGtleSwgdmFsdWUsIHBhcnRzKQogIH0KICByZXR1cm4gcGFydHMKfQoKZXhwb3J0IGZ1bmN0aW9uIHF1ZXJ5U2VyaWFsaXplcihwYXJhbXM6IFJlY29yZDxzdHJpbmcsIHVua25vd24+KTogc3RyaW5nIHsKICBjb25zdCBwYXJ0cyA9IHNlcmlhbGl6ZVBhcmFtcyhwYXJhbXMpLm1hcCgKICAgIChbaywgdl0pID0+IGAke2VuY29kZVVSSUNvbXBvbmVudChrKX09JHtlbmNvZGVVUklDb21wb25lbnQodil9YCwKICApCiAgcmV0dXJuIHBhcnRzLmxlbmd0aCA/IGA/JHtwYXJ0cy5qb2luKCcmJyl9YCA6ICcnCn0KCmV4cG9ydCBmdW5jdGlvbiB0b1VSTFNlYXJjaFBhcmFtcygKICBwYXJhbXM6IFJlY29yZDxzdHJpbmcsIHVua25vd24+LAopOiBVUkxTZWFyY2hQYXJhbXMgewogIGNvbnN0IHNlYXJjaCA9IG5ldyBVUkxTZWFyY2hQYXJhbXMoKQogIGZvciAoY29uc3QgW2tleSwgdmFsdWVdIG9mIHNlcmlhbGl6ZVBhcmFtcyhwYXJhbXMpKSB7CiAgICBzZWFyY2guYXBwZW5kKGtleSwgdmFsdWUpCiAgfQogIHJldHVybiBzZWFyY2gKfQoKZXhwb3J0IGZ1bmN0aW9uIGVuY29kZVNvcnQoCiAgc29ydDogeyBieT86IHN0cmluZzsgb3JkZXI/OiAnYXNjJyB8ICdkZXNjJyB9IHwgdW5kZWZpbmVkLAogIGVuY29kZUZpZWxkOiAoZmllbGQ6IHN0cmluZykgPT4gc3RyaW5nID0gKGZpZWxkKSA9PiBmaWVsZCwKKTogc3RyaW5nIHwgdW5kZWZpbmVkIHsKICBpZiAoIXNvcnQ/LmJ5KSB7CiAgICByZXR1cm4gdW5kZWZpbmVkCiAgfQogIGNvbnN0IGJ5ID0gZW5jb2RlRmllbGQoc29ydC5ieSkKICBpZiAoc29ydC5vcmRlciA9PT0gJ2Rlc2MnKSB7CiAgICByZXR1cm4gYCR7Ynl9IGRlc2NgCiAgfQogIGlmIChzb3J0Lm9yZGVyID09PSAnYXNjJykgewogICAgcmV0dXJuIGAke2J5fSBhc2NgCiAgfQogIHJldHVybiBieQp9Cg==', - 'src/lib/to-error.ts': - 'aW1wb3J0IHsgSFRUUEVycm9yIGFzIEt5SFRUUEVycm9yIH0gZnJvbSAna3knCmltcG9ydCAqIGFzIHNjaGVtYXMgZnJvbSAnLi4vbW9kZWxzL3NjaGVtYXMuanMnCmltcG9ydCB7IEhUVFBFcnJvciB9IGZyb20gJy4uL21vZGVscy9lcnJvcnMuanMnCgpleHBvcnQgYXN5bmMgZnVuY3Rpb24gdG9FcnJvcihlOiB1bmtub3duKTogUHJvbWlzZTxFcnJvcj4gewogIGlmIChlIGluc3RhbmNlb2YgS3lIVFRQRXJyb3IpIHsKICAgIC8vIGt5IGNvbnN1bWVzIHRoZSBib2R5IGludG8gZS5kYXRhOyBlLnJlc3BvbnNlLmpzb24oKSB3b3VsZCB0aHJvdy4KICAgIGNvbnN0IHBhcnNlZCA9IHNjaGVtYXMuYmFzZUVycm9yLnNhZmVQYXJzZShlLmRhdGEpCiAgICBjb25zdCBlcnJvciA9IHBhcnNlZC5zdWNjZXNzID8gcGFyc2VkLmRhdGEgOiB1bmRlZmluZWQKICAgIHJldHVybiBIVFRQRXJyb3IuZnJvbVJlc3BvbnNlKHsgcmVzcG9uc2U6IGUucmVzcG9uc2UsIGVycm9yIH0pCiAgfQogIGlmIChlIGluc3RhbmNlb2YgRXJyb3IpIHsKICAgIHJldHVybiBlCiAgfQogIHJldHVybiBuZXcgRXJyb3IoU3RyaW5nKGUpKQp9Cg==', - 'src/lib/request.ts': - 'aW1wb3J0IHsgdHlwZSBSZXN1bHQsIG9rLCBlcnIgfSBmcm9tICcuL3R5cGVzLmpzJwppbXBvcnQgeyB0b0Vycm9yIH0gZnJvbSAnLi90by1lcnJvci5qcycKCmV4cG9ydCBhc3luYyBmdW5jdGlvbiByZXF1ZXN0PFQ+KGZuOiAoKSA9PiBQcm9taXNlPFQ+KTogUHJvbWlzZTxSZXN1bHQ8VD4+IHsKICB0cnkgewogICAgcmV0dXJuIG9rKGF3YWl0IGZuKCkpCiAgfSBjYXRjaCAoZSkgewogICAgcmV0dXJuIGVycihhd2FpdCB0b0Vycm9yKGUpKQogIH0KfQo=', - 'src/models/errors.ts': - 'aW1wb3J0IHsgeiB9IGZyb20gJ3pvZCcKaW1wb3J0ICogYXMgc2NoZW1hcyBmcm9tICcuL3NjaGVtYXMuanMnCgpleHBvcnQgY2xhc3MgSFRUUEVycm9yIGV4dGVuZHMgRXJyb3IgewogIGNvbnN0cnVjdG9yKAogICAgbWVzc2FnZTogc3RyaW5nLAogICAgcHVibGljIHR5cGU6IHN0cmluZywKICAgIHB1YmxpYyB0aXRsZTogc3RyaW5nLAogICAgcHVibGljIHN0YXR1czogbnVtYmVyLAogICAgcHVibGljIHVybDogc3RyaW5nLAogICAgcHJvdGVjdGVkIF9fcmF3PzogUmVjb3JkPHN0cmluZywgdW5rbm93bj4sCiAgKSB7CiAgICBzdXBlcihtZXNzYWdlKQogIH0KCiAgc3RhdGljIGZyb21SZXNwb25zZShyZXNwOiB7CiAgICByZXNwb25zZTogUmVzcG9uc2UKICAgIGVycm9yPzogei5pbmZlcjx0eXBlb2Ygc2NoZW1hcy5iYXNlRXJyb3I+CiAgfSkgewogICAgaWYgKAogICAgICByZXNwLnJlc3BvbnNlLmhlYWRlcnMKICAgICAgICAuZ2V0KCdDb250ZW50LVR5cGUnKQogICAgICAgID8uaW5jbHVkZXMoJ2FwcGxpY2F0aW9uL3Byb2JsZW0ranNvbicpICYmCiAgICAgIHJlc3AuZXJyb3IKICAgICkgewogICAgICByZXR1cm4gbmV3IEhUVFBFcnJvcigKICAgICAgICByZXNwLmVycm9yLmRldGFpbCwKICAgICAgICByZXNwLmVycm9yLnR5cGUgPz8gcmVzcC5lcnJvci50aXRsZSwKICAgICAgICByZXNwLmVycm9yLnRpdGxlLAogICAgICAgIHJlc3AuZXJyb3Iuc3RhdHVzID8/IHJlc3AucmVzcG9uc2Uuc3RhdHVzLAogICAgICAgIHJlc3AucmVzcG9uc2UudXJsLAogICAgICAgIHJlc3AuZXJyb3IsCiAgICAgICkKICAgIH0KCiAgICByZXR1cm4gbmV3IEhUVFBFcnJvcigKICAgICAgYFJlcXVlc3QgZmFpbGVkOiAke3Jlc3AucmVzcG9uc2Uuc3RhdHVzVGV4dH1gLAogICAgICByZXNwLnJlc3BvbnNlLnN0YXR1c1RleHQsCiAgICAgIHJlc3AucmVzcG9uc2Uuc3RhdHVzVGV4dCwKICAgICAgcmVzcC5yZXNwb25zZS5zdGF0dXMsCiAgICAgIHJlc3AucmVzcG9uc2UudXJsLAogICAgKQogIH0KCiAgZ2V0RmllbGQoa2V5OiBzdHJpbmcpIHsKICAgIHJldHVybiB0aGlzLl9fcmF3Py5ba2V5XQogIH0KfQo=', - 'tests/client.spec.ts': - 'aW1wb3J0IGZldGNoTW9jayBmcm9tICdAZmV0Y2gtbW9jay92aXRlc3QnCmltcG9ydCB7IGJlZm9yZUVhY2gsIGRlc2NyaWJlLCBleHBlY3QsIGl0IH0gZnJvbSAndml0ZXN0JwppbXBvcnQgeyBPcGVuTWV0ZXIsIFNlcnZlckxpc3QgfSBmcm9tICcuLi9zcmMvaW5kZXguanMnCgpjb25zdCBtZXRlciA9IHsKICBpZDogJzAxQVJaM05ERUtUU1Y0UlJGRlE2OUc1RkFWJywKICBuYW1lOiAnQVBJIGNhbGxzJywKICBrZXk6ICdhcGlfY2FsbHMnLAogIGFnZ3JlZ2F0aW9uOiAnY291bnQnLAogIGV2ZW50X3R5cGU6ICdhcGktcmVxdWVzdCcsCiAgY3JlYXRlZF9hdDogJzIwMjQtMDEtMDFUMDA6MDA6MDBaJywKICB1cGRhdGVkX2F0OiAnMjAyNC0wMS0wMVQwMDowMDowMFonLAp9CgpiZWZvcmVFYWNoKCgpID0+IHsKICBmZXRjaE1vY2subW9ja1Jlc2V0KCkKfSkKCmZ1bmN0aW9uIG1vY2tNZXRlcigpIHsKICBmZXRjaE1vY2sucm91dGUoJyonLCB7IGJvZHk6IG1ldGVyLCBoZWFkZXJzOiB7ICdDb250ZW50LVR5cGUnOiAnYXBwbGljYXRpb24vanNvbicgfSB9KQp9CgpmdW5jdGlvbiBsYXN0VXJsKCk6IHN0cmluZyB7CiAgcmV0dXJuIGZldGNoTW9jay5jYWxsSGlzdG9yeS5sYXN0Q2FsbCgpIS51cmwKfQoKZnVuY3Rpb24gbGFzdEF1dGgoKTogc3RyaW5nIHwgbnVsbCB7CiAgY29uc3QgaGVhZGVycyA9IGZldGNoTW9jay5jYWxsSGlzdG9yeS5sYXN0Q2FsbCgpIS5vcHRpb25zLmhlYWRlcnMgYXMgSGVhZGVycwogIHJldHVybiBuZXcgSGVhZGVycyhoZWFkZXJzKS5nZXQoJ2F1dGhvcml6YXRpb24nKQp9Cgpjb25zdCBmZXRjaCA9IGZldGNoTW9jay5mZXRjaEhhbmRsZXIKCmRlc2NyaWJlKCdiYXNlIFVSTCBjb25zdHJ1Y3Rpb24nLCAoKSA9PiB7CiAgaXQoJ3ByZXNlcnZlcyB0aGUgYmFzZSBwYXRoIHNlZ21lbnQgKC92MyknLCBhc3luYyAoKSA9PiB7CiAgICBtb2NrTWV0ZXIoKQogICAgY29uc3Qgc2RrID0gbmV3IE9wZW5NZXRlcih7IGJhc2VVcmw6ICdodHRwczovL2V1LmFwaS5rb25naHEuY29tL3YzJywgYXBpS2V5OiAnaycsIGZldGNoIH0pCiAgICBhd2FpdCBzZGsubWV0ZXJzLmdldCh7IG1ldGVySWQ6ICdtJyB9KQogICAgZXhwZWN0KGxhc3RVcmwoKSkudG9CZSgnaHR0cHM6Ly9ldS5hcGkua29uZ2hxLmNvbS92My9vcGVubWV0ZXIvbWV0ZXJzL20nKQogIH0pCgogIGl0KCdhY2NlcHRzIGEgVVJMIG9iamVjdCBhcyBiYXNlVXJsJywgYXN5bmMgKCkgPT4gewogICAgbW9ja01ldGVyKCkKICAgIGNvbnN0IHNkayA9IG5ldyBPcGVuTWV0ZXIoeyBiYXNlVXJsOiBuZXcgVVJMKCdodHRwczovL3VzLmFwaS5rb25naHEuY29tL3YzJyksIGFwaUtleTogJ2snLCBmZXRjaCB9KQogICAgYXdhaXQgc2RrLm1ldGVycy5nZXQoeyBtZXRlcklkOiAnbScgfSkKICAgIGV4cGVjdChsYXN0VXJsKCkpLnRvQmUoJ2h0dHBzOi8vdXMuYXBpLmtvbmdocS5jb20vdjMvb3Blbm1ldGVyL21ldGVycy9tJykKICB9KQoKICBpdCgnc2V0cyB0aGUgYmVhcmVyIGF1dGggaGVhZGVyJywgYXN5bmMgKCkgPT4gewogICAgbW9ja01ldGVyKCkKICAgIGNvbnN0IHNkayA9IG5ldyBPcGVuTWV0ZXIoeyBiYXNlVXJsOiAnaHR0cHM6Ly9ldS5hcGkua29uZ2hxLmNvbS92MycsIGFwaUtleTogJ2snLCBmZXRjaCB9KQogICAgYXdhaXQgc2RrLm1ldGVycy5nZXQoeyBtZXRlcklkOiAnbScgfSkKICAgIGV4cGVjdChsYXN0QXV0aCgpKS50b0JlKCdCZWFyZXIgaycpCiAgfSkKfSkKCmRlc2NyaWJlKCdzZXJ2ZXItdmFyaWFibGUgdGVtcGxhdGluZycsICgpID0+IHsKICBpdCgnc3Vic3RpdHV0ZXMgYSByZWdpb24gdmFyaWFibGUnLCBhc3luYyAoKSA9PiB7CiAgICBtb2NrTWV0ZXIoKQogICAgY29uc3Qgc2RrID0gbmV3IE9wZW5NZXRlcih7CiAgICAgIGJhc2VVcmw6IFNlcnZlckxpc3RbMF0sCiAgICAgIHNlcnZlclZhcmlhYmxlczogeyByZWdpb246ICdldScgfSwKICAgICAgYXBpS2V5OiAnaycsCiAgICAgIGZldGNoLAogICAgfSkKICAgIGF3YWl0IHNkay5tZXRlcnMuZ2V0KHsgbWV0ZXJJZDogJ20nIH0pCiAgICBleHBlY3QobGFzdFVybCgpKS50b0JlKCdodHRwczovL2V1LmFwaS5rb25naHEuY29tL3YzL29wZW5tZXRlci9tZXRlcnMvbScpCiAgfSkKCiAgaXQoJ3Rocm93cyB3aGVuIGEgcmVxdWlyZWQgdGVtcGxhdGUgdmFyaWFibGUgaXMgbWlzc2luZycsICgpID0+IHsKICAgIGV4cGVjdCgoKSA9PiBuZXcgT3Blbk1ldGVyKHsgYmFzZVVybDogU2VydmVyTGlzdFswXSwgYXBpS2V5OiAnaycsIGZldGNoIH0pKS50b1Rocm93KCkKICB9KQp9KQoKZGVzY3JpYmUoJ29wdGlvbiBjbG9iYmVyaW5nIGlzIHByZXZlbnRlZCcsICgpID0+IHsKICBpdCgnaWdub3JlcyBhIHVzZXItc3VwcGxpZWQgcHJlZml4IHRoYXQgd291bGQgcmVkaXJlY3QgcmVxdWVzdHMnLCBhc3luYyAoKSA9PiB7CiAgICBtb2NrTWV0ZXIoKQogICAgY29uc3Qgc2RrID0gbmV3IE9wZW5NZXRlcih7CiAgICAgIGJhc2VVcmw6ICdodHRwczovL2V1LmFwaS5rb25naHEuY29tL3YzJywKICAgICAgYXBpS2V5OiAnaycsCiAgICAgIHByZWZpeDogJ2h0dHA6Ly9ldmlsLnRlc3QvJywKICAgICAgZmV0Y2gsCiAgICB9KQogICAgYXdhaXQgc2RrLm1ldGVycy5nZXQoeyBtZXRlcklkOiAnbScgfSkKICAgIGV4cGVjdChsYXN0VXJsKCkpLnRvQmUoJ2h0dHBzOi8vZXUuYXBpLmtvbmdocS5jb20vdjMvb3Blbm1ldGVyL21ldGVycy9tJykKICB9KQoKICBpdCgnYXBwbGllcyBTREsgYXV0aCBhZnRlciB1c2VyIGJlZm9yZVJlcXVlc3QgaG9va3MnLCBhc3luYyAoKSA9PiB7CiAgICBtb2NrTWV0ZXIoKQogICAgY29uc3Qgc2RrID0gbmV3IE9wZW5NZXRlcih7CiAgICAgIGJhc2VVcmw6ICdodHRwczovL2V1LmFwaS5rb25naHEuY29tL3YzJywKICAgICAgYXBpS2V5OiAncmVhbC1rZXknLAogICAgICBmZXRjaCwKICAgICAgaG9va3M6IHsKICAgICAgICBiZWZvcmVSZXF1ZXN0OiBbCiAgICAgICAgICAoeyByZXF1ZXN0IH0pID0+IHsKICAgICAgICAgICAgcmVxdWVzdC5oZWFkZXJzLnNldCgnQXV0aG9yaXphdGlvbicsICdCZWFyZXIgQVRUQUNLRVInKQogICAgICAgICAgfSwKICAgICAgICBdLAogICAgICB9LAogICAgfSkKICAgIGF3YWl0IHNkay5tZXRlcnMuZ2V0KHsgbWV0ZXJJZDogJ20nIH0pCiAgICBleHBlY3QobGFzdEF1dGgoKSkudG9CZSgnQmVhcmVyIHJlYWwta2V5JykKICB9KQp9KQoKZGVzY3JpYmUoJ25hbWVzcGFjZSBjb21wb3NpdGlvbicsICgpID0+IHsKICBpdCgnbWVtb2l6ZXMgbmFtZXNwYWNlIGFjY2Vzc29ycycsICgpID0+IHsKICAgIGNvbnN0IHNkayA9IG5ldyBPcGVuTWV0ZXIoeyBiYXNlVXJsOiAnaHR0cHM6Ly9ldS5hcGkua29uZ2hxLmNvbS92MycsIGFwaUtleTogJ2snLCBmZXRjaCB9KQogICAgZXhwZWN0KHNkay5tZXRlcnMpLnRvQmUoc2RrLm1ldGVycykKICB9KQoKICBpdCgncm91dGVzIG5hbWVzcGFjZSBjYWxscyB0aHJvdWdoIHRoZSByb290IHRyYW5zcG9ydCcsIGFzeW5jICgpID0+IHsKICAgIG1vY2tNZXRlcigpCiAgICBjb25zdCBzZGsgPSBuZXcgT3Blbk1ldGVyKHsgYmFzZVVybDogJ2h0dHBzOi8vZXUuYXBpLmtvbmdocS5jb20vdjMnLCBhcGlLZXk6ICdrJywgZmV0Y2ggfSkKICAgIGF3YWl0IHNkay5tZXRlcnMuZ2V0KHsgbWV0ZXJJZDogJ20nIH0pCiAgICBleHBlY3QobGFzdFVybCgpKS50b0JlKCdodHRwczovL2V1LmFwaS5rb25naHEuY29tL3YzL29wZW5tZXRlci9tZXRlcnMvbScpCiAgICBleHBlY3QobGFzdEF1dGgoKSkudG9CZSgnQmVhcmVyIGsnKQogIH0pCn0pCg==', - 'tests/meters.spec.ts': - 'aW1wb3J0IGZldGNoTW9jayBmcm9tICdAZmV0Y2gtbW9jay92aXRlc3QnCmltcG9ydCB7IGJlZm9yZUVhY2gsIGRlc2NyaWJlLCBleHBlY3QsIGl0IH0gZnJvbSAndml0ZXN0JwppbXBvcnQgeyBDbGllbnQsIGZ1bmNzIH0gZnJvbSAnLi4vc3JjL2luZGV4LmpzJwoKY29uc3QgbWV0ZXIgPSB7CiAgaWQ6ICcwMUFSWjNOREVLVFNWNFJSRkZRNjlHNUZBVicsCiAgbmFtZTogJ0FQSSBjYWxscycsCiAga2V5OiAnYXBpX2NhbGxzJywKICBhZ2dyZWdhdGlvbjogJ2NvdW50JywKICBldmVudF90eXBlOiAnYXBpLXJlcXVlc3QnLAogIGNyZWF0ZWRfYXQ6ICcyMDI0LTAxLTAxVDAwOjAwOjAwWicsCiAgdXBkYXRlZF9hdDogJzIwMjQtMDEtMDFUMDA6MDA6MDBaJywKfQoKYmVmb3JlRWFjaCgoKSA9PiB7CiAgZmV0Y2hNb2NrLm1vY2tSZXNldCgpCn0pCgpmdW5jdGlvbiBjbGllbnQoKSB7CiAgcmV0dXJuIG5ldyBDbGllbnQoewogICAgYmFzZVVybDogJ2h0dHBzOi8vZXUuYXBpLmtvbmdocS5jb20vdjMnLAogICAgYXBpS2V5OiAnaycsCiAgICBmZXRjaDogZmV0Y2hNb2NrLmZldGNoSGFuZGxlciwKICB9KQp9CgpmdW5jdGlvbiBtb2NrTGlzdCgpIHsKICBmZXRjaE1vY2sucm91dGUoJyonLCB7CiAgICBib2R5OiB7IGRhdGE6IFttZXRlcl0sIG1ldGE6IHsgcGFnZTogeyBudW1iZXI6IDEsIHNpemU6IDEwLCB0b3RhbDogMSB9IH0gfSwKICAgIGhlYWRlcnM6IHsgJ0NvbnRlbnQtVHlwZSc6ICdhcHBsaWNhdGlvbi9qc29uJyB9LAogIH0pCn0KCmZ1bmN0aW9uIGxhc3RRdWVyeSgpOiBVUkxTZWFyY2hQYXJhbXMgewogIHJldHVybiBuZXcgVVJMKGZldGNoTW9jay5jYWxsSGlzdG9yeS5sYXN0Q2FsbCgpIS51cmwpLnNlYXJjaFBhcmFtcwp9CgpkZXNjcmliZSgnbGlzdE1ldGVycyBxdWVyeSBzZXJpYWxpemF0aW9uJywgKCkgPT4gewogIGl0KCdlbmNvZGVzIHBhZ2UgYXMgYSBkZWVwIG9iamVjdCcsIGFzeW5jICgpID0+IHsKICAgIG1vY2tMaXN0KCkKICAgIGF3YWl0IGZ1bmNzLmxpc3RNZXRlcnMoY2xpZW50KCksIHsgcGFnZTogeyBzaXplOiAxMCwgbnVtYmVyOiAyIH0gfSkKICAgIGNvbnN0IHEgPSBsYXN0UXVlcnkoKQogICAgZXhwZWN0KHEuZ2V0KCdwYWdlW3NpemVdJykpLnRvQmUoJzEwJykKICAgIGV4cGVjdChxLmdldCgncGFnZVtudW1iZXJdJykpLnRvQmUoJzInKQogIH0pCgogIGl0KCdlbmNvZGVzIGEgc2NhbGFyIGZpbHRlcicsIGFzeW5jICgpID0+IHsKICAgIG1vY2tMaXN0KCkKICAgIGF3YWl0IGZ1bmNzLmxpc3RNZXRlcnMoY2xpZW50KCksIHsgZmlsdGVyOiB7IGtleTogJ20nIH0gfSkKICAgIGV4cGVjdChsYXN0UXVlcnkoKS5nZXQoJ2ZpbHRlcltrZXldJykpLnRvQmUoJ20nKQogIH0pCgogIGl0KCdlbmNvZGVzIGEgbmVzdGVkIGZpbHRlciBvcGVyYW5kIGFzIGEgZGVlcCBvYmplY3QnLCBhc3luYyAoKSA9PiB7CiAgICBtb2NrTGlzdCgpCiAgICBhd2FpdCBmdW5jcy5saXN0TWV0ZXJzKGNsaWVudCgpLCB7IGZpbHRlcjogeyBrZXk6IHsgZXE6ICdmb28nIH0gfSB9KQogICAgZXhwZWN0KGxhc3RRdWVyeSgpLmdldCgnZmlsdGVyW2tleV1bZXFdJykpLnRvQmUoJ2ZvbycpCiAgfSkKCiAgaXQoJ2NvbW1hLWpvaW5zIGFycmF5IG9wZXJhbmRzIGludG8gYSBzaW5nbGUgcGFyYW1ldGVyJywgYXN5bmMgKCkgPT4gewogICAgbW9ja0xpc3QoKQogICAgYXdhaXQgZnVuY3MubGlzdE1ldGVycyhjbGllbnQoKSwgeyBmaWx0ZXI6IHsga2V5OiB7IG9lcTogWydhJywgJ2InLCAnYyddIH0gfSB9KQogICAgY29uc3QgcSA9IGxhc3RRdWVyeSgpCiAgICBleHBlY3QocS5nZXRBbGwoJ2ZpbHRlcltrZXldW29lcV0nKSkudG9IYXZlTGVuZ3RoKDEpCiAgICBleHBlY3QocS5nZXQoJ2ZpbHRlcltrZXldW29lcV0nKSkudG9CZSgnYSxiLGMnKQogIH0pCgogIGl0KCdzZXJpYWxpemVzIHNvcnQgYXMgYSBwbGFpbiBzdHJpbmcsIHNuYWtlLWlmeWluZyB0aGUgZmllbGQgbmFtZScsIGFzeW5jICgpID0+IHsKICAgIG1vY2tMaXN0KCkKICAgIGF3YWl0IGZ1bmNzLmxpc3RNZXRlcnMoY2xpZW50KCksIHsgc29ydDogeyBieTogJ2NyZWF0ZWRBdCcsIG9yZGVyOiAnZGVzYycgfSB9KQogICAgY29uc3QgcSA9IGxhc3RRdWVyeSgpCiAgICBleHBlY3QocS5nZXQoJ3NvcnQnKSkudG9CZSgnY3JlYXRlZF9hdCBkZXNjJykKICAgIGV4cGVjdChxLmdldCgnc29ydFtieV0nKSkudG9CZU51bGwoKQogIH0pCgogIGl0KCdvbWl0cyBzb3J0IG9yZGVyIHdoZW4gYXNjZW5kaW5nIGlzIGltcGxpZWQnLCBhc3luYyAoKSA9PiB7CiAgICBtb2NrTGlzdCgpCiAgICBhd2FpdCBmdW5jcy5saXN0TWV0ZXJzKGNsaWVudCgpLCB7IHNvcnQ6IHsgYnk6ICduYW1lJyB9IH0pCiAgICBleHBlY3QobGFzdFF1ZXJ5KCkuZ2V0KCdzb3J0JykpLnRvQmUoJ25hbWUnKQogIH0pCn0pCgpkZXNjcmliZSgncmVzcG9uc2VzIGFyZSBtYXBwZWQgdG8gdGhlIGNhbWVsQ2FzZSBzaGFwZScsICgpID0+IHsKICBpdCgnY2FtZWxpemVzIGtub3duIGZpZWxkcyBhbmQgZHJvcHMgZmllbGRzIG5vdCBpbiB0aGUgc2NoZW1hJywgYXN5bmMgKCkgPT4gewogICAgZmV0Y2hNb2NrLnJvdXRlKCcqJywgewogICAgICBib2R5OiB7IC4uLm1ldGVyLCBicmFuZF9uZXdfZmllbGQ6ICdkcm9wcGVkJyB9LAogICAgICBoZWFkZXJzOiB7ICdDb250ZW50LVR5cGUnOiAnYXBwbGljYXRpb24vanNvbicgfSwKICAgIH0pCiAgICBjb25zdCByZXN1bHQgPSBhd2FpdCBmdW5jcy5nZXRNZXRlcihjbGllbnQoKSwgeyBtZXRlcklkOiAnbScgfSkKICAgIGV4cGVjdChyZXN1bHQub2spLnRvQmUodHJ1ZSkKICAgIGV4cGVjdChyZXN1bHQudmFsdWUpLnRvTWF0Y2hPYmplY3QoewogICAgICBpZDogbWV0ZXIuaWQsCiAgICAgIGV2ZW50VHlwZTogbWV0ZXIuZXZlbnRfdHlwZSwKICAgICAgY3JlYXRlZEF0OiBuZXcgRGF0ZShtZXRlci5jcmVhdGVkX2F0KSwKICAgIH0pCiAgICBleHBlY3QoJ2V2ZW50X3R5cGUnIGluIHJlc3VsdC52YWx1ZSEpLnRvQmUoZmFsc2UpCiAgICBleHBlY3QoJ2JyYW5kX25ld19maWVsZCcgaW4gcmVzdWx0LnZhbHVlISkudG9CZShmYWxzZSkKICB9KQp9KQo=', - 'tests/errors.spec.ts': - 'aW1wb3J0IGZldGNoTW9jayBmcm9tICdAZmV0Y2gtbW9jay92aXRlc3QnCmltcG9ydCB7IGJlZm9yZUVhY2gsIGRlc2NyaWJlLCBleHBlY3QsIGl0IH0gZnJvbSAndml0ZXN0JwppbXBvcnQgeyBDbGllbnQsIEhUVFBFcnJvciwgZnVuY3MgfSBmcm9tICcuLi9zcmMvaW5kZXguanMnCgpiZWZvcmVFYWNoKCgpID0+IHsKICBmZXRjaE1vY2subW9ja1Jlc2V0KCkKfSkKCmZ1bmN0aW9uIGNsaWVudCgpIHsKICByZXR1cm4gbmV3IENsaWVudCh7CiAgICBiYXNlVXJsOiAnaHR0cHM6Ly9ldS5hcGkua29uZ2hxLmNvbS92MycsCiAgICBhcGlLZXk6ICdrJywKICAgIGZldGNoOiBmZXRjaE1vY2suZmV0Y2hIYW5kbGVyLAogIH0pCn0KCmZ1bmN0aW9uIG1vY2tQcm9ibGVtKHN0YXR1czogbnVtYmVyLCBib2R5OiBSZWNvcmQ8c3RyaW5nLCB1bmtub3duPikgewogIGZldGNoTW9jay5yb3V0ZSgnKicsIHsKICAgIHN0YXR1cywKICAgIGJvZHksCiAgICBoZWFkZXJzOiB7ICdDb250ZW50LVR5cGUnOiAnYXBwbGljYXRpb24vcHJvYmxlbStqc29uOyBjaGFyc2V0PXV0Zi04JyB9LAogIH0pCn0KCmRlc2NyaWJlKCdlcnJvciBtYXBwaW5nJywgKCkgPT4gewogIGl0KCdtYXBzIHByb2JsZW0ranNvbiB0byBhIHR5cGVkIEhUVFBFcnJvciB3aXRoIHBhcnNlZCBmaWVsZHMnLCBhc3luYyAoKSA9PiB7CiAgICBtb2NrUHJvYmxlbSg0MDQsIHsKICAgICAgdHlwZTogJ3QnLAogICAgICB0aXRsZTogJ05vdCBGb3VuZCcsCiAgICAgIHN0YXR1czogNDA0LAogICAgICBkZXRhaWw6ICdub3BlJywKICAgICAgaW5zdGFuY2U6ICcveCcsCiAgICB9KQogICAgY29uc3QgcmVzdWx0ID0gYXdhaXQgZnVuY3MuZ2V0TWV0ZXIoY2xpZW50KCksIHsgbWV0ZXJJZDogJ3gnIH0pCiAgICBleHBlY3QocmVzdWx0Lm9rKS50b0JlKGZhbHNlKQogICAgZXhwZWN0KHJlc3VsdC5lcnJvcikudG9CZUluc3RhbmNlT2YoSFRUUEVycm9yKQogICAgY29uc3QgaHR0cEVycm9yID0gcmVzdWx0LmVycm9yIGFzIEhUVFBFcnJvcgogICAgZXhwZWN0KGh0dHBFcnJvci5zdGF0dXMpLnRvQmUoNDA0KQogICAgZXhwZWN0KGh0dHBFcnJvci50aXRsZSkudG9CZSgnTm90IEZvdW5kJykKICAgIGV4cGVjdChodHRwRXJyb3IubWVzc2FnZSkudG9CZSgnbm9wZScpCiAgfSkKCiAgaXQoJ2V4cG9zZXMgaW52YWxpZF9wYXJhbWV0ZXJzIHZpYSBnZXRGaWVsZCcsIGFzeW5jICgpID0+IHsKICAgIG1vY2tQcm9ibGVtKDQwMCwgewogICAgICB0eXBlOiAndCcsCiAgICAgIHRpdGxlOiAnQmFkIFJlcXVlc3QnLAogICAgICBzdGF0dXM6IDQwMCwKICAgICAgZGV0YWlsOiAndmFsaWRhdGlvbiBmYWlsZWQnLAogICAgICBpbnN0YW5jZTogJy94JywKICAgICAgaW52YWxpZF9wYXJhbWV0ZXJzOiBbeyBmaWVsZDogJ25hbWUnLCByZWFzb246ICdpcyByZXF1aXJlZCcgfV0sCiAgICB9KQogICAgY29uc3QgcmVzdWx0ID0gYXdhaXQgZnVuY3MuZ2V0TWV0ZXIoY2xpZW50KCksIHsgbWV0ZXJJZDogJ3gnIH0pCiAgICBjb25zdCBodHRwRXJyb3IgPSByZXN1bHQuZXJyb3IgYXMgSFRUUEVycm9yCiAgICBleHBlY3QoaHR0cEVycm9yLmdldEZpZWxkKCdpbnZhbGlkX3BhcmFtZXRlcnMnKSkudG9FcXVhbChbCiAgICAgIHsgZmllbGQ6ICduYW1lJywgcmVhc29uOiAnaXMgcmVxdWlyZWQnIH0sCiAgICBdKQogIH0pCgogIGl0KCdmYWxscyBiYWNrIHRvIGEgc3RhdHVzLW9ubHkgZXJyb3IgZm9yIG5vbi1wcm9ibGVtIHJlc3BvbnNlcycsIGFzeW5jICgpID0+IHsKICAgIGZldGNoTW9jay5yb3V0ZSgnKicsIHsgc3RhdHVzOiA1MDAsIGJvZHk6ICdvb3BzJywgaGVhZGVyczogeyAnQ29udGVudC1UeXBlJzogJ3RleHQvcGxhaW4nIH0gfSkKICAgIGNvbnN0IHJlc3VsdCA9IGF3YWl0IGZ1bmNzLmdldE1ldGVyKGNsaWVudCgpLCB7IG1ldGVySWQ6ICd4JyB9KQogICAgZXhwZWN0KHJlc3VsdC5lcnJvcikudG9CZUluc3RhbmNlT2YoSFRUUEVycm9yKQogICAgY29uc3QgaHR0cEVycm9yID0gcmVzdWx0LmVycm9yIGFzIEhUVFBFcnJvcgogICAgZXhwZWN0KGh0dHBFcnJvci5zdGF0dXMpLnRvQmUoNTAwKQogIH0pCn0pCg==', - 'tests/nesting.spec.ts': - 'aW1wb3J0IGZldGNoTW9jayBmcm9tICdAZmV0Y2gtbW9jay92aXRlc3QnCmltcG9ydCB7IGJlZm9yZUVhY2gsIGRlc2NyaWJlLCBleHBlY3QsIGl0IH0gZnJvbSAndml0ZXN0JwppbXBvcnQgeyBPcGVuTWV0ZXIgfSBmcm9tICcuLi9zcmMvaW5kZXguanMnCgpiZWZvcmVFYWNoKCgpID0+IHsKICBmZXRjaE1vY2subW9ja1Jlc2V0KCkKfSkKCmNvbnN0IGZldGNoID0gZmV0Y2hNb2NrLmZldGNoSGFuZGxlcgoKZnVuY3Rpb24gbGFzdFVybCgpOiBzdHJpbmcgewogIHJldHVybiBmZXRjaE1vY2suY2FsbEhpc3RvcnkubGFzdENhbGwoKSEudXJsCn0KCmRlc2NyaWJlKCduZXN0ZWQgc3ViLWNsaWVudHMnLCAoKSA9PiB7CiAgaXQoJ3JvdXRlcyBjdXN0b21lcnMuY2hhcmdlcy5saXN0KCkgdG8gdGhlIGNoYXJnZXMgc3ViLXJlc291cmNlJywgYXN5bmMgKCkgPT4gewogICAgZmV0Y2hNb2NrLnJvdXRlKCcqJywgewogICAgICBib2R5OiB7IGRhdGE6IFtdLCBtZXRhOiB7IHBhZ2U6IHsgbnVtYmVyOiAxLCBzaXplOiAxMCwgdG90YWw6IDAgfSB9IH0sCiAgICAgIGhlYWRlcnM6IHsgJ0NvbnRlbnQtVHlwZSc6ICdhcHBsaWNhdGlvbi9qc29uJyB9LAogICAgfSkKICAgIGNvbnN0IHNkayA9IG5ldyBPcGVuTWV0ZXIoewogICAgICBiYXNlVXJsOiAnaHR0cHM6Ly9ldS5hcGkua29uZ2hxLmNvbS92MycsCiAgICAgIGFwaUtleTogJ2snLAogICAgICBmZXRjaCwKICAgIH0pCiAgICBhd2FpdCBzZGsuY3VzdG9tZXJzLmNoYXJnZXMubGlzdCh7IGN1c3RvbWVySWQ6ICcwMUFSWjNOREVLVFNWNFJSRkZRNjlHNUZBVicgfSkKICAgIGV4cGVjdChsYXN0VXJsKCkpLnRvQmUoCiAgICAgICdodHRwczovL2V1LmFwaS5rb25naHEuY29tL3YzL29wZW5tZXRlci9jdXN0b21lcnMvMDFBUlozTkRFS1RTVjRSUkZGUTY5RzVGQVYvY2hhcmdlcycsCiAgICApCiAgfSkKfSkK', +// outputPath (in the generated SDK) -> template file under `templates/`. +// Runtime files are fixed boilerplate; the conformance tests are the spec the +// generated SDK must satisfy and are emitted alongside it so `test:sdk` runs +// against generated output. +const RUNTIME_FILES: Record = { + 'src/core.ts': 'runtime/core.ts', + 'src/lib/types.ts': 'runtime/types.ts', + 'src/lib/config.ts': 'runtime/config.ts', + 'src/lib/version.ts': 'runtime/version.ts', + 'src/lib/encodings.ts': 'runtime/encodings.ts', + 'src/lib/to-error.ts': 'runtime/to-error.ts', + 'src/lib/request.ts': 'runtime/request.ts', + 'src/lib/paginate.ts': 'runtime/paginate.ts', + 'src/models/errors.ts': 'runtime/errors.ts', + 'tests/client.spec.ts': 'tests/client.spec.ts', + 'tests/meters.spec.ts': 'tests/meters.spec.ts', + 'tests/errors.spec.ts': 'tests/errors.spec.ts', + 'tests/nesting.spec.ts': 'tests/nesting.spec.ts', } export const RUNTIME_TEMPLATES: Record = Object.fromEntries( - Object.entries(ENCODED).map(([path, b64]) => [ - path, - Buffer.from(b64, 'base64').toString('utf8'), + Object.entries(RUNTIME_FILES).map(([outPath, templateRelPath]) => [ + outPath, + readFileSync( + fileURLToPath( + new URL(`../templates/${templateRelPath}`, import.meta.url), + ), + 'utf8', + ), ]), ) diff --git a/api/spec/packages/typespec-typescript/src/runtime/wire.ts b/api/spec/packages/typespec-typescript/src/runtime/wire.ts index 11786b449a..73310fb56f 100644 --- a/api/spec/packages/typespec-typescript/src/runtime/wire.ts +++ b/api/spec/packages/typespec-typescript/src/runtime/wire.ts @@ -16,6 +16,7 @@ type ZodDef = { discriminator?: string options?: ZodType[] entries?: Record + defaultValue?: unknown } function def(schema: ZodType | undefined): ZodDef | undefined { @@ -45,6 +46,40 @@ function unwrap(schema: ZodType | undefined): ZodType | undefined { return current } +// The default to write for a field the caller omitted, present only when the +// field is required-with-default in the spec — emitted as `.default(x)` with no +// `.optional()` in the wrapper chain (TypeSpec `field: T = x`, not +// `field?: T = x`). Such fields must be present on the wire (e.g. a CloudEvents +// envelope's `specversion`, which the server's parser rejects when absent), yet +// the generated request type allows omitting them so callers get the documented +// default for free — toWire is the layer that has to reconcile the two. +// Spec-optional fields (`.optional().default(x)`) return undefined and stay off +// the wire: their default is the server's to apply, and materializing them +// client-side would silently overwrite server state on update requests. +function requiredDefault( + schema: ZodType | undefined, +): { value: unknown } | undefined { + let current = schema + let found: { value: unknown } | undefined + for (let i = 0; i < 100 && current; i++) { + const d = def(current) + /* v8 ignore next 3 -- every zod schema carries a def; guards the type only */ + if (!d) { + break + } + if (d.type === 'optional') { + return undefined + } + if (d.type === 'default') { + found ??= { value: d.defaultValue } + } else if (d.type !== 'nullable') { + break + } + current = d.innerType + } + return found +} + function shapeOf( schema: ZodType | undefined, ): Record | undefined { @@ -82,7 +117,12 @@ function needsWalk(schema: ZodType | undefined): boolean { if (!d) { return false } - if (d.type === 'object' || d.type === 'record' || d.type === 'date') { + if ( + d.type === 'object' || + d.type === 'record' || + d.type === 'date' || + d.type === 'bigint' + ) { return true } if (d.type === 'array') { @@ -94,6 +134,21 @@ function needsWalk(schema: ZodType | undefined): boolean { return false } +// Thrown by toWire when a bigint value (an int64/uint64 field) is outside +// JSON's exactly-representable integer range. The wire carries int64 as a JSON +// number (the server decodes it into a Go int64), so a value beyond 2^53-1 +// cannot be sent without silent precision corruption — a typed, immediate +// failure is the only honest option. request() catches it like any Error and +// surfaces it as Result.error. +export class UnsafeIntegerError extends Error { + constructor(value: bigint) { + super( + `bigint value ${value} exceeds JSON's safe integer range and cannot be sent without precision loss`, + ) + this.name = 'UnsafeIntegerError' + } +} + // An RFC 3339 string standing in for a `Date` on request input. The // `Record` intersection keeps a plain string assignable while // stopping the union simplifier from absorbing sibling string literals — a @@ -138,6 +193,17 @@ type Direction = { // string, wire string → `Date`. Values already in the target form (or not // convertible) pass through unchanged. mapDate: (value: unknown) => unknown + // The value mapping at a bigint-typed (int64/uint64) node: public `bigint` → + // JSON number (throwing UnsafeIntegerError beyond 2^53-1, where JSON numbers + // lose integer precision), wire number → `bigint`. Without the public→wire + // mapping, JSON.stringify throws an opaque TypeError on any bigint. Values + // already in the target form (or not convertible) pass through unchanged. + mapBigInt: (value: unknown) => unknown + // Whether absent required-with-default fields are materialized (see + // requiredDefault). True only public→wire: requests must satisfy the wire + // contract, while responses are reported as the server sent them — fromWire + // fabricating fields would mask genuine contract violations. + applyDefaults: boolean } // A handful of schemas are genuinely self-referential (e.g. the `and`/`or` @@ -169,10 +235,15 @@ function walk( // A Date can only ever mean its wire serialization, wherever it sits — a // typed date field, a record value, or an unknown-schema position. Wire→ // public data never contains Date instances (it comes from JSON.parse), so - // this only rewrites public→wire. + // this only rewrites public→wire. The same holds for bigint: JSON.parse + // never produces one, and public→wire it must become a JSON number wherever + // it sits. if (data instanceof Date) { return dir.mapDate(data) } + if (typeof data === 'bigint') { + return dir.mapBigInt(data) + } if (depth > MAX_WALK_DEPTH) { throw new DepthLimitExceededError() } @@ -184,6 +255,12 @@ function walk( if (d?.type === 'date') { return dir.mapDate(data) } + // A bigint-typed node revives the wire's JSON number into the public + // `bigint` (public→wire bigints were already mapped by the value check + // above, so only fromWire reaches a number here). + if (d?.type === 'bigint') { + return dir.mapBigInt(data) + } if (Array.isArray(data)) { // The schema may be the array itself or a union with an array variant // (e.g. a single-or-batch body `T | T[]`); resolve the element schema from @@ -251,6 +328,20 @@ function walk( } out[dir.rename(key)] = walk(value, fieldSchema, dir, depth + 1) } + if (dir.applyDefaults) { + // Shape keys are generated camelCase identifiers (never data-controlled), + // so direct indexing into `record` is safe here. Runs after the data loop + // so an explicit `key: undefined` entry is also replaced by the default. + for (const [key, fieldSchema] of Object.entries(shape)) { + if (record[key] !== undefined) { + continue + } + const dflt = requiredDefault(fieldSchema) + if (dflt !== undefined) { + out[dir.rename(key)] = walk(dflt.value, fieldSchema, dir, depth + 1) + } + } + } Object.setPrototypeOf(out, Object.prototype) return out } @@ -358,17 +449,37 @@ const toWireDirection: Direction = { rename: toSnakeCase, discriminatorKey: (camelKey) => camelKey, mapDate: (value) => (value instanceof Date ? value.toISOString() : value), + mapBigInt: (value) => { + if (typeof value !== 'bigint') { + return value + } + if ( + value > BigInt(Number.MAX_SAFE_INTEGER) || + value < -BigInt(Number.MAX_SAFE_INTEGER) + ) { + throw new UnsafeIntegerError(value) + } + return Number(value) + }, + applyDefaults: true, } const fromWireDirection: Direction = { rename: toCamelCase, discriminatorKey: (camelKey) => toSnakeCase(camelKey), mapDate: (value) => (typeof value === 'string' ? new Date(value) : value), + mapBigInt: (value) => + typeof value === 'number' && Number.isInteger(value) + ? BigInt(value) + : value, + applyDefaults: false, } // Rewrite a request body or query object from the camelCase public shape to the // snake_case wire shape, driven by its schema. Record keys (label/dimension names) -// are preserved; `Date` values serialize to RFC 3339 strings. The return is typed +// are preserved; `Date` values serialize to RFC 3339 strings; `bigint` values +// (int64 fields) become JSON numbers; omitted required-with-default fields are +// filled with their declared default (see requiredDefault). The return is typed // as the input `T` so call sites stay cast-free (the runtime object has snake keys // and wire-encoded dates, but the value is write-only — it flows straight into // `json:`/`toURLSearchParams`, both of which accept any object). diff --git a/api/spec/packages/typespec-typescript/src/sdk-files.ts b/api/spec/packages/typespec-typescript/src/sdk-files.ts index b32139fff1..f0bc7bce37 100644 --- a/api/spec/packages/typespec-typescript/src/sdk-files.ts +++ b/api/spec/packages/typespec-typescript/src/sdk-files.ts @@ -1,5 +1,10 @@ +import type { PaginationInfo } from './pagination.js' import type { SdkOperation } from './sdk-operations.js' -import { namespaceNames } from './sdk-operations.js' +import { + namespaceNames, + operationJsDoc, + paginationJsDoc, +} from './sdk-operations.js' import type { RequestTypes } from './request-types.js' import { toCamelCase } from './casing.js' @@ -51,6 +56,10 @@ function funcBody(op: SdkOperation): string { kyOpts.length > 0 ? `{ ...options, ${kyOpts.join(', ')} }` : 'options' const lines: string[] = [] + const doc = operationJsDoc(op, '') + if (doc) { + lines.push(doc) + } lines.push( `export function ${op.funcName}(`, ` client: Client,`, @@ -272,13 +281,18 @@ interface FacadeNode { children: Map } +function reqOptionalFor(op: SdkOperation): boolean { + return op.queryParams.length > 0 && !op.hasBody && op.pathParams.length === 0 +} + function emitMethod(op: SdkOperation): string { - const reqOptional = - op.queryParams.length > 0 && !op.hasBody && op.pathParams.length === 0 + const reqOptional = reqOptionalFor(op) const reqParam = reqOptional ? `request?: ${op.base}Request` : `request: ${op.base}Request` + const doc = operationJsDoc(op, ' ') return [ + ...(doc ? [doc] : []), ` async ${op.methodName}(`, ` ${reqParam},`, ` options?: RequestOptions,`, @@ -288,6 +302,35 @@ function emitMethod(op: SdkOperation): string { ].join('\n') } +/** + * The `All` companion for a paginated list operation: wires the + * matching runtime pagination helper (`paginatePages`/`paginateCursor`) to + * this operation's func, so it only has to bind `this._client` — the page- + * advancing loop itself lives in `lib/paginate.ts`, not here. + */ +function emitPaginationMethod(op: SdkOperation, info: PaginationInfo): string { + const reqOptional = reqOptionalFor(op) + const reqParam = reqOptional + ? `request?: ${op.base}Request` + : `request: ${op.base}Request` + const requestArg = reqOptional ? 'request ?? {}' : 'request' + const helper = info.style === 'page' ? 'paginatePages' : 'paginateCursor' + const doc = paginationJsDoc(op, ' ') + return [ + ...(doc ? [doc] : []), + ` ${op.methodName}All(`, + ` ${reqParam},`, + ` options?: RequestOptions,`, + ` ): AsyncIterable<${info.itemInterface}> {`, + ` return ${helper}(`, + ` (req, opts) => ${op.funcName}(this._client, req, opts),`, + ` ${requestArg},`, + ` options,`, + ` )`, + ` }`, + ].join('\n') +} + function emitNode(node: FacadeNode): string[] { const getters = [...node.children.entries()].map(([name, child]) => { const field = `_${name}` @@ -298,7 +341,12 @@ function emitNode(node: FacadeNode): string[] { ` }`, ].join('\n') }) - const members = [...node.ops.map(emitMethod), ...getters].join('\n\n') + const methods = node.ops.flatMap((op) => + op.pagination + ? [emitMethod(op), emitPaginationMethod(op, op.pagination)] + : [emitMethod(op)], + ) + const members = [...methods, ...getters].join('\n\n') const klass = [ `export class ${node.className} {`, @@ -337,15 +385,49 @@ export function facadeFile(tag: string, ops: SdkOperation[]): string { .flatMap((op) => [` ${op.base}Request,`, ` ${op.base}Response,`]) .join('\n') + // Pagination helper imports: only the styles this resource actually uses, + // named deterministically (not by Set iteration order) so a second + // `generate` run byte-matches the first. + const paginationHelpers = [ + ...new Set(ops.map((op) => op.pagination?.style).filter(Boolean)), + ].sort() + const paginationImport = + paginationHelpers.length > 0 + ? [ + `import {`, + ...paginationHelpers.map((style) => + style === 'page' ? ` paginatePages,` : ` paginateCursor,`, + ), + `} from '../lib/paginate.js'`, + ] + : [] + const itemInterfaces = [ + ...new Set( + ops + .map((op) => op.pagination?.itemInterface) + .filter((name): name is string => Boolean(name)), + ), + ].sort() + const itemInterfaceImport = + itemInterfaces.length > 0 + ? [ + `import type {`, + ...itemInterfaces.map((name) => ` ${name},`), + `} from '../models/types.js'`, + ] + : [] + return [ `import { type Client } from '../core.js'`, `import { unwrap, type RequestOptions } from '../lib/types.js'`, + ...paginationImport, `import {`, funcImports, `} from '../funcs/${file}.js'`, `import type {`, typeImports, `} from '../models/operations/${file}.js'`, + ...itemInterfaceImport, ``, classes, ``, @@ -390,7 +472,11 @@ export function funcsIndexFile(tags: string[]): string { ) } -export function indexFile(tags: string[], modelTypeNames: string[]): string { +export function indexFile( + tags: string[], + modelTypeNames: string[], + operationTypeNames: Set, +): string { const namespaceExports = tags .map((tag) => { const { class: cls } = namespaceNames(tag) @@ -404,13 +490,17 @@ export function indexFile(tags: string[], modelTypeNames: string[]): string { ) .join('\n') - // Domain model types are exported by name rather than via `export type *`. Some - // request models (`CreateMeterRequest`, …) are also re-exported as operation - // aliases, so a second star would make those names ambiguous (TS2308). An - // explicit named re-export shadows the operation stars and resolves to the - // identical underlying type, while making every domain type (`Meter`, - // `RateCard`, …) importable by name. - const uniqueTypeNames = [...new Set(modelTypeNames)] + // Domain model types are exported by name rather than via `export type *`, + // which would make every operation-alias name ambiguous (TS2308). Model names + // an operations module also exports (`CreateMeterRequest`, …) are excluded: + // an explicit re-export would silently shadow the operation alias at the + // package root, and the two are NOT the same type — the alias adds path + // params and AcceptDateStrings widening, and it is what the sdk/funcs + // signatures actually accept. Operation names must win; the raw body model + // stays reachable through the operations module's own imports and ./zod. + const uniqueTypeNames = [...new Set(modelTypeNames)].filter( + (name) => !operationTypeNames.has(name), + ) const modelTypeExports = [ `export type {`, ...uniqueTypeNames.map((name) => ` ${name},`), @@ -422,11 +512,16 @@ export function indexFile(tags: string[], modelTypeNames: string[]): string { namespaceExports, `export { Client } from './core.js'`, `export { HTTPError } from './models/errors.js'`, - `export { ValidationError, DepthLimitExceededError } from './lib/wire.js'`, + `export { ValidationError, DepthLimitExceededError, UnsafeIntegerError } from './lib/wire.js'`, `export type { AcceptDateStrings, DateString } from './lib/wire.js'`, + `export { PaginationLimitExceededError } from './lib/paginate.js'`, ``, `export { ServerList, Regions } from './lib/config.js'`, `export type { SDKOptions, Region, ServerVariables } from './lib/config.js'`, + // unwrap/ok/err ship as values: the documented standalone-funcs surface + // returns Result, and without unwrap every caller who wants throw-on-error + // for one call has to re-implement it. + `export { unwrap, ok, err } from './lib/types.js'`, `export type { Result, RequestOptions } from './lib/types.js'`, ``, `export {`, diff --git a/api/spec/packages/typespec-typescript/src/sdk-operations.ts b/api/spec/packages/typespec-typescript/src/sdk-operations.ts index d223e12017..7ce037c2e6 100644 --- a/api/spec/packages/typespec-typescript/src/sdk-operations.ts +++ b/api/spec/packages/typespec-typescript/src/sdk-operations.ts @@ -5,10 +5,13 @@ import { type Type, } from '@typespec/compiler' import { $ } from '@typespec/compiler/typekit' -import { getAllHttpServices, type HttpStatusCodesEntry } from '@typespec/http' +import { getAllHttpServices } from '@typespec/http' import { getOperationId } from '@typespec/openapi' +import { isSuccessStatus } from './http-status.js' +import { reportDiagnostic } from './lib.js' +import type { PaginationInfo } from './pagination.js' import { operationBaseName } from './ZodOperations.jsx' -import { shouldReference } from './utils.jsx' +import { jsdoc, shouldReference } from './utils.jsx' export interface SdkOperation { funcName: string @@ -23,8 +26,11 @@ export interface SdkOperation { hasResponse: boolean /** Documented interface name for the success body, when it is a named model. */ responseInterface?: string - /** The operation's `@doc`, for the README operations listing. */ + /** The operation's `@summary` decorator text — a short, one-line label. */ summary?: string + /** The operation's `@doc` text — the longer description, for the README + * operations listing and the generated JSDoc description body. */ + doc?: string /** * Documented interface name for the request body — the model's `…Input` * variant when its input shape diverges from its output, else the model @@ -38,6 +44,12 @@ export interface SdkOperation { */ textResponseContentType?: string nestPath: string[] + /** + * Set when the response is page- or cursor-paginated: the facade emits a + * companion `All` method that iterates every item across pages via + * the matching runtime helper (see `pagination.ts`, `sdk-files.ts`). + */ + pagination?: PaginationInfo } /** Resolves a type to its documented interface name (for response wiring). */ @@ -170,16 +182,51 @@ export function sdkOperation( textResponseContentType: responseBody?.contentType?.startsWith('text/') ? responseBody.contentType : undefined, - summary: tk.type.getDoc(op), + summary: tk.type.getSummary(op), + doc: tk.type.getDoc(op), } } -function is2xx(status: HttpStatusCodesEntry): boolean { - return ( - status === '*' || - (typeof status === 'number' && status >= 200 && status < 300) || - (typeof status === 'object' && status.start >= 200 && status.start < 300) - ) +/** + * The JSDoc comment for a generated method or standalone function: the + * `@summary` decorator text (if present) followed by the `@doc` description + * body (if present and distinct from the summary), and always a final line + * naming the HTTP route. The route line is unconditional so every operation + * gets a useful hover — including the ones with no TypeSpec doc at all — while + * never emitting a hollow `/** *\/` block. + */ +export function operationJsDoc( + op: SdkOperation, + indent: string, +): string | undefined { + const summary = op.summary?.trim() + const doc = op.doc?.trim() + const parts = [ + ...(summary ? [summary] : []), + ...(doc && doc !== summary ? [doc] : []), + `${op.verb.toUpperCase()} ${op.path}`, + ] + return jsdoc(parts.join('\n\n'), indent) +} + +/** + * The JSDoc comment for a paginated operation's `All` companion: + * the same summary/description as {@link operationJsDoc}, with an added line + * documenting the auto-pagination behavior before the route line. + */ +export function paginationJsDoc( + op: SdkOperation, + indent: string, +): string | undefined { + const summary = op.summary?.trim() + const doc = op.doc?.trim() + const parts = [ + ...(summary ? [summary] : []), + ...(doc && doc !== summary ? [doc] : []), + 'Iterates every item across all pages, fetching more as the returned iterable is consumed.', + `${op.verb.toUpperCase()} ${op.path}`, + ] + return jsdoc(parts.join('\n\n'), indent) } function successBody( @@ -188,7 +235,7 @@ function successBody( ): { type: Type; contentType?: string } | undefined { const httpOp = $(program).httpOperation.get(op) for (const response of httpOp.responses) { - if (!is2xx(response.statusCodes)) { + if (!isSuccessStatus(response.statusCodes)) { continue } for (const r of response.responses) { @@ -203,13 +250,15 @@ function successBody( // The success response envelope retains its declared identity (and // `@friendlyName`) where the extracted body does not, so it recovers a // documented interface for responses whose body is anonymous after extraction. -function successResponseEnvelope( +// Exported for pagination.ts, which matches this same envelope Type against +// the shared pagination templates by node identity. +export function successResponseEnvelope( program: Program, op: Operation, ): Type | undefined { const httpOp = $(program).httpOperation.get(op) for (const response of httpOp.responses) { - if (is2xx(response.statusCodes)) { + if (isSuccessStatus(response.statusCodes)) { return response.type } } @@ -242,6 +291,7 @@ function sourceOf(op: Operation): { } export function groupOperations( + program: Program, operations: Operation[], ): Map { const groups = new Map() @@ -249,6 +299,15 @@ export function groupOperations( const { chain, interface: iface } = sourceOf(op) const top = chain[0] if (!top) { + // An operation with no resolvable source chain cannot be grouped into a + // client, so it is dropped from the SDK — an endpoint silently missing + // from the published client is exactly the failure the diagnostic exists + // to surface. + reportDiagnostic(program, { + code: 'ungrouped-operation', + target: op, + format: { operation: operationBaseName(program, op) }, + }) continue } const key = diff --git a/api/spec/packages/typespec-typescript/src/ts-types.ts b/api/spec/packages/typespec-typescript/src/ts-types.ts index 7690305913..000e3e40d1 100644 --- a/api/spec/packages/typespec-typescript/src/ts-types.ts +++ b/api/spec/packages/typespec-typescript/src/ts-types.ts @@ -11,6 +11,7 @@ import { } from '@typespec/compiler' import { $ } from '@typespec/compiler/typekit' import type { Typekit } from '@typespec/compiler/typekit' +import { reportDiagnostic } from './lib.js' import { bodyProperties, isRecord, publicPropertyName } from './utils.jsx' /** @@ -75,6 +76,11 @@ export function tsTypeOf( case 'Intrinsic': return intrinsicType(type) default: + reportDiagnostic(program, { + code: 'unsupported-type', + target: type, + format: { kind: type.kind }, + }) return 'unknown' } } @@ -103,6 +109,9 @@ function intrinsicType(type: IntrinsicType): string { } function scalarType(tk: Typekit, type: Scalar): string { + // Captured before the extendsX predicate chain narrows `type` (see + // scalarBaseType in zodBaseSchema.tsx). + const scalarName = type.name if (tk.scalar.extendsBoolean(type)) { return 'boolean' } @@ -139,6 +148,11 @@ function scalarType(tk: Typekit, type: Scalar): string { ) { return dateScalarType(tk, type) } + reportDiagnostic(tk.program, { + code: 'unsupported-type', + target: type, + format: { kind: `scalar '${scalarName}'` }, + }) return 'unknown' } @@ -183,6 +197,25 @@ function unionType( type: Union, refName: RefName, io: IoMode, +): string { + const named = refName(type) + if (named) { + return named + } + return unionVariantsType(program, type, refName, io) +} + +/** + * The structural expansion of a union's variants (`Variant1 | Variant2 | …`). + * Split out from {@link unionType} so a named union's `types.ts` alias + * declaration can compute its own right-hand side without `refName` resolving + * the union back to its own name (which would just alias `X = X`). + */ +export function unionVariantsType( + program: Program, + type: Union, + refName: RefName, + io: IoMode, ): string { const variants = [...type.variants.values()].map((variant) => tsTypeOf(program, variant.type, refName, io), diff --git a/api/spec/packages/typespec-typescript/src/utils.tsx b/api/spec/packages/typespec-typescript/src/utils.tsx index 693622ebed..00ee29f200 100644 --- a/api/spec/packages/typespec-typescript/src/utils.tsx +++ b/api/spec/packages/typespec-typescript/src/utils.tsx @@ -581,6 +581,13 @@ export function callPart(target: string | Refkey, ...args: Children[]) { * source layout — paragraph breaks and bullet lists — survives into the emitted * SDK. Lines are split on `\n` only; intra-line whitespace is preserved so * nested bullets keep their indentation, and blank lines become bare ` *`. + * + * A literal `*\/` inside the doc text would otherwise close the comment early, + * leaking the remainder of `doc` out as emitted source — so every occurrence is + * escaped to `*\/` (still renders as the original two characters to a reader, + * but no longer matches the block-comment terminator). Backticks need no such + * handling: this is a real comment, not a template literal, so they carry no + * syntactic meaning here. */ export function jsdoc( doc: string | undefined, @@ -589,7 +596,7 @@ export function jsdoc( if (!doc) { return undefined } - const trimmed = doc.trim() + const trimmed = doc.trim().replace(/\*\//g, '*\\/') if (!trimmed.includes('\n')) { return `${indent}/** ${trimmed} */` } diff --git a/api/spec/packages/typespec-typescript/src/visibility.ts b/api/spec/packages/typespec-typescript/src/visibility.ts index 7702de7554..c7f68c1093 100644 --- a/api/spec/packages/typespec-typescript/src/visibility.ts +++ b/api/spec/packages/typespec-typescript/src/visibility.ts @@ -7,6 +7,7 @@ import { } from '@typespec/compiler' import { $ } from '@typespec/compiler/typekit' import { isVisible, Visibility } from '@typespec/http' +import { isSuccessStatus } from './http-status.js' /** * Models reachable from an operation response body, keyed by program. A model in @@ -128,23 +129,3 @@ export function computeResponseReachableModels( return reachable } - -/** - * Whether a response's status code(s) fall in the 2xx success range. Handles the - * three shapes `statusCodes` can take: a single number, a `{start, end}` range - * (success when it overlaps 200–299), and the `"*"` default-response wildcard - * (treated as success so a body declared only on the catch-all response still - * contributes its read shape). Without this, a ranged or wildcard success body - * would be skipped and its create-only fields would leak back into read types. - */ -function isSuccessStatus( - statusCodes: number | { start: number; end: number } | '*', -): boolean { - if (statusCodes === '*') { - return true - } - if (typeof statusCodes === 'number') { - return statusCodes >= 200 && statusCodes < 300 - } - return statusCodes.start < 300 && statusCodes.end >= 200 -} diff --git a/api/spec/packages/typespec-typescript/src/zodBaseSchema.tsx b/api/spec/packages/typespec-typescript/src/zodBaseSchema.tsx index 87fe8457c9..a0835a34d0 100644 --- a/api/spec/packages/typespec-typescript/src/zodBaseSchema.tsx +++ b/api/spec/packages/typespec-typescript/src/zodBaseSchema.tsx @@ -18,6 +18,7 @@ import type { Typekit } from '@typespec/compiler/typekit' import { useTsp } from '@typespec/emitter-framework' import { ZodCustomTypeComponent } from './components/ZodCustomTypeComponent.jsx' import { ZodSchema } from './components/ZodSchema.jsx' +import { reportDiagnostic } from './lib.js' import { activeRefkeySym, bodyProperties, @@ -78,6 +79,11 @@ export function zodBaseSchemaParts(type: Type) { case 'Tuple': return tupleBaseType(type) default: + reportDiagnostic($.program, { + code: 'unsupported-type', + target: type, + format: { kind: type.kind }, + }) return zodMemberExpr(callPart('any')) } } @@ -117,6 +123,10 @@ function literalBaseType(type: LiteralType) { } function scalarBaseType($: Typekit, type: Scalar) { + // Captured before the extendsX predicate chain: each failed predicate + // narrows `type`, so by the fallback arms the compiler no longer lets the + // Scalar's own members through. + const scalarName = type.name if (type.baseScalar && shouldReference($.program, type.baseScalar)) { return ( { + baseUrl: (typeof ServerList)[number] | URL | string + serverVariables?: ServerVariables + apiKey?: string | (() => string | Promise) + /** + * Validate request bodies and response payloads against their schemas. Off by + * default: the SDK maps casing but does not validate, so additive server fields + * never break clients. When on, a request body or response that fails its schema + * (missing/wrong-typed field, unknown enum value) returns a failed Result whose + * `error` is a ValidationError (validation runs inside the SDK's request + * handling, so it never rejects/throws). + */ + validate?: boolean +} diff --git a/api/spec/packages/typespec-typescript/templates/runtime/core.ts b/api/spec/packages/typespec-typescript/templates/runtime/core.ts new file mode 100644 index 0000000000..b02882640f --- /dev/null +++ b/api/spec/packages/typespec-typescript/templates/runtime/core.ts @@ -0,0 +1,55 @@ +import ky, { type KyInstance as HTTPClient } from 'ky' +import { type SDKOptions } from './lib/config.js' +import { encodePath } from './lib/encodings.js' +import { SDK_VERSION } from './lib/version.js' + +export class Client { + readonly _options: SDKOptions + readonly _http: HTTPClient + + constructor(options: SDKOptions) { + this._options = options + + let baseUrl = + typeof options.baseUrl === 'string' + ? encodePath(options.baseUrl, options.serverVariables ?? {}) + : String(options.baseUrl) + if (!baseUrl.endsWith('/')) { + baseUrl = `${baseUrl}/` + } + + this._http = ky.create({ + ...options, + baseUrl, + prefix: undefined, + hooks: { + ...options.hooks, + beforeRequest: [ + ...(options.hooks?.beforeRequest ?? []), + async ({ request }) => { + // Browsers treat User-Agent as a forbidden header and silently drop + // it; that's fine here, this is only observable server-side (e.g. + // request logs) and Node/server callers get it. + if (!request.headers.has('User-Agent')) { + request.headers.set('User-Agent', `openmeter-node/${SDK_VERSION}`) + } + + const token = + typeof options.apiKey === 'function' + ? await options.apiKey() + : options.apiKey + if (token) { + request.headers.set('Authorization', `Bearer ${token}`) + } + }, + ], + }, + }) + } +} + +export function http(client: Client): HTTPClient { + return client._http +} + +export type { HTTPClient } diff --git a/api/spec/packages/typespec-typescript/templates/runtime/encodings.ts b/api/spec/packages/typespec-typescript/templates/runtime/encodings.ts new file mode 100644 index 0000000000..0c86120e27 --- /dev/null +++ b/api/spec/packages/typespec-typescript/templates/runtime/encodings.ts @@ -0,0 +1,75 @@ +export function encodePath( + template: string, + params: Record, +): string { + return template.replace(/\{(\w+)\}/g, (_, key: string) => { + const value = params[key] + if (value === undefined) { + throw new Error(`missing path parameter: ${key}`) + } + return encodeURIComponent(String(value)) + }) +} + +function serializeDeepObject( + prefix: string, + value: unknown, + parts: Array<[string, string]>, +): void { + if (value === null || value === undefined) { + return + } + if (Array.isArray(value)) { + parts.push([prefix, value.map(String).join(',')]) + } else if (typeof value === 'object') { + for (const [k, v] of Object.entries(value)) { + serializeDeepObject(`${prefix}[${k}]`, v, parts) + } + } else { + parts.push([prefix, String(value)]) + } +} + +function serializeParams( + params: Record, +): Array<[string, string]> { + const parts: Array<[string, string]> = [] + for (const [key, value] of Object.entries(params)) { + serializeDeepObject(key, value, parts) + } + return parts +} + +export function querySerializer(params: Record): string { + const parts = serializeParams(params).map( + ([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`, + ) + return parts.length ? `?${parts.join('&')}` : '' +} + +export function toURLSearchParams( + params: Record, +): URLSearchParams { + const search = new URLSearchParams() + for (const [key, value] of serializeParams(params)) { + search.append(key, value) + } + return search +} + +export function encodeSort( + sort: { by?: string; order?: 'asc' | 'desc' } | undefined, + encodeField: (field: string) => string = (field) => field, +): string | undefined { + if (!sort?.by) { + return undefined + } + const by = encodeField(sort.by) + if (sort.order === 'desc') { + return `${by} desc` + } + if (sort.order === 'asc') { + return `${by} asc` + } + return by +} diff --git a/api/spec/packages/typespec-typescript/templates/runtime/errors.ts b/api/spec/packages/typespec-typescript/templates/runtime/errors.ts new file mode 100644 index 0000000000..74d6a52d00 --- /dev/null +++ b/api/spec/packages/typespec-typescript/templates/runtime/errors.ts @@ -0,0 +1,83 @@ +import { z } from 'zod' +import * as schemas from './schemas.js' + +// Retry-After is either delta-seconds (an integer) or an HTTP-date. Only the +// delta-seconds form is parsed; HTTP-date values yield undefined rather than +// a parsed Date, keeping the public type a plain number. +function parseRetryAfter(header: string | null): number | undefined { + if (header === null) { + return undefined + } + const trimmed = header.trim() + return /^\d+$/.test(trimmed) ? Number(trimmed) : undefined +} + +export class HTTPError extends Error { + constructor( + message: string, + public type: string, + public title: string, + public status: number, + public url: string, + public retryAfter: number | undefined, + protected __raw?: Record, + ) { + super(message) + this.name = 'HTTPError' + } + + static fromResponse(resp: { + response: Response + error?: z.infer + }) { + const retryAfter = parseRetryAfter(resp.response.headers.get('Retry-After')) + + if ( + resp.response.headers + .get('Content-Type') + ?.includes('application/problem+json') && + resp.error + ) { + return new HTTPError( + resp.error.detail, + resp.error.type ?? resp.error.title, + resp.error.title, + resp.error.status ?? resp.response.status, + resp.response.url, + retryAfter, + resp.error, + ) + } + + return new HTTPError( + `Request failed: ${resp.response.statusText}`, + resp.response.statusText, + resp.response.statusText, + resp.response.status, + resp.response.url, + retryAfter, + ) + } + + /** + * The `invalid_parameters` problem-detail extension member, present on Bad + * Request (400) responses. Typed from the schema, not runtime-validated + * (like the rest of the SDK, this trusts the server rather than rejecting + * additive fields) — `undefined` when the response didn't carry one. + */ + get invalidParameters(): + | z.infer + | undefined { + return this.__raw?.invalid_parameters as + | z.infer + | undefined + } + + /** + * Escape hatch for problem-detail extension members without a typed + * accessor above. + */ + getField(key: string) { + return this.__raw?.[key] + } +} diff --git a/api/spec/packages/typespec-typescript/templates/runtime/paginate.ts b/api/spec/packages/typespec-typescript/templates/runtime/paginate.ts new file mode 100644 index 0000000000..aa2e635bad --- /dev/null +++ b/api/spec/packages/typespec-typescript/templates/runtime/paginate.ts @@ -0,0 +1,97 @@ +import { unwrap, type RequestOptions, type Result } from './types.js' + +// A misbehaving server — inconsistent `meta.page.total`/`size`, or a +// `meta.page.next` cursor that never resolves to an empty page — would +// otherwise make these generators loop forever. 10,000 pages is far beyond +// any real OpenMeter list, so hitting it means the server's pagination +// contract broke, not that the caller has a legitimately large list. +const MAX_PAGINATION_PAGES = 10_000 + +export class PaginationLimitExceededError extends Error { + constructor(limit: number) { + super( + `pagination exceeded the maximum of ${limit} pages; the server may be returning inconsistent pagination metadata`, + ) + this.name = 'PaginationLimitExceededError' + } +} + +interface PageNumberEnvelope { + data: TItem[] + meta: { page: { number: number; size: number; total: number } } +} + +interface PageNumberRequest { + page?: { number?: number; size?: number } +} + +/** + * Iterates every item of a page-number-paginated list operation, advancing + * `page.number` from wherever the caller's own request starts. Every other + * field on `request` — filters, sort, page.size — is sent unchanged on every + * page fetch; only the page number advances. Stops on a page shorter than + * the server's own reported `size` (including an empty page) or once the + * running item count reaches the server's reported `total`, whichever comes + * first — so a final page that exactly fills `total` does not trigger one + * more, empty request. + */ +export async function* paginatePages( + fetchPage: ( + req: TReq, + options?: RequestOptions, + ) => Promise>>, + request: TReq, + options?: RequestOptions, +): AsyncGenerator { + let req = request + let seen = 0 + for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const res = unwrap(await fetchPage(req, options)) + yield* res.data + seen += res.data.length + const { number, size, total } = res.meta.page + if (res.data.length === 0 || res.data.length < size || seen >= total) { + return + } + req = { ...req, page: { ...req.page, number: number + 1 } } as TReq + } + throw new PaginationLimitExceededError(MAX_PAGINATION_PAGES) +} + +interface CursorEnvelope { + data: TItem[] + meta: { page: { next?: string } } +} + +interface CursorRequest { + page?: { after?: string; before?: string; size?: number } +} + +/** + * Iterates every item of a cursor-paginated list operation, following + * `meta.page.next` until the server stops returning one. Despite carrying a + * `format: uri` annotation in the spec, `next` is the opaque cursor token the + * API expects back verbatim in `page.after` on the following request — not a + * URI to fetch directly. Every other field on `request` is sent unchanged on + * every page fetch; only `page.after` advances. + */ +export async function* paginateCursor( + fetchPage: ( + req: TReq, + options?: RequestOptions, + ) => Promise>>, + request: TReq, + options?: RequestOptions, +): AsyncGenerator { + let req = request + for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const res = unwrap(await fetchPage(req, options)) + yield* res.data + const next = res.meta.page.next + if (!next) { + return + } + req = { ...req, page: { ...req.page, after: next } } as TReq + } + throw new PaginationLimitExceededError(MAX_PAGINATION_PAGES) +} diff --git a/api/spec/packages/typespec-typescript/templates/runtime/request.ts b/api/spec/packages/typespec-typescript/templates/runtime/request.ts new file mode 100644 index 0000000000..f21e299d98 --- /dev/null +++ b/api/spec/packages/typespec-typescript/templates/runtime/request.ts @@ -0,0 +1,10 @@ +import { type Result, ok, err } from './types.js' +import { toError } from './to-error.js' + +export async function request(fn: () => Promise): Promise> { + try { + return ok(await fn()) + } catch (e) { + return err(await toError(e)) + } +} diff --git a/api/spec/packages/typespec-typescript/templates/runtime/to-error.ts b/api/spec/packages/typespec-typescript/templates/runtime/to-error.ts new file mode 100644 index 0000000000..eace195d19 --- /dev/null +++ b/api/spec/packages/typespec-typescript/templates/runtime/to-error.ts @@ -0,0 +1,16 @@ +import { HTTPError as KyHTTPError } from 'ky' +import * as schemas from '../models/schemas.js' +import { HTTPError } from '../models/errors.js' + +export async function toError(e: unknown): Promise { + if (e instanceof KyHTTPError) { + // ky consumes the body into e.data; e.response.json() would throw. + const parsed = schemas.baseError.safeParse(e.data) + const error = parsed.success ? parsed.data : undefined + return HTTPError.fromResponse({ response: e.response, error }) + } + if (e instanceof Error) { + return e + } + return new Error(String(e)) +} diff --git a/api/spec/packages/typespec-typescript/templates/runtime/types.ts b/api/spec/packages/typespec-typescript/templates/runtime/types.ts new file mode 100644 index 0000000000..c397259ec0 --- /dev/null +++ b/api/spec/packages/typespec-typescript/templates/runtime/types.ts @@ -0,0 +1,25 @@ +import type { Options } from 'ky' + +export type RequestOptions = Pick< + Options, + 'signal' | 'headers' | 'timeout' | 'retry' +> + +export type Result = + | { ok: true; value: T; error?: never } + | { ok: false; value?: never; error: E } + +export function ok(value: T): Result { + return { ok: true, value } +} + +export function err(error: E): Result { + return { ok: false, error } +} + +export function unwrap(result: Result): T { + if (result.ok) { + return result.value + } + throw result.error +} diff --git a/api/spec/packages/typespec-typescript/templates/runtime/version.ts b/api/spec/packages/typespec-typescript/templates/runtime/version.ts new file mode 100644 index 0000000000..6fe6719bbf --- /dev/null +++ b/api/spec/packages/typespec-typescript/templates/runtime/version.ts @@ -0,0 +1,4 @@ +// The committed value is a dev placeholder. The publish flow +// (`make -C api/spec publish-aip-sdk`) stamps the real release version here +// before `pnpm publish`, after `pnpm version` updates package.json. +export const SDK_VERSION = '0.0.0' diff --git a/api/spec/packages/typespec-typescript/templates/tests/client.spec.ts b/api/spec/packages/typespec-typescript/templates/tests/client.spec.ts new file mode 100644 index 0000000000..8047bd35a7 --- /dev/null +++ b/api/spec/packages/typespec-typescript/templates/tests/client.spec.ts @@ -0,0 +1,176 @@ +import fetchMock from '@fetch-mock/vitest' +import { beforeEach, describe, expect, it } from 'vitest' +import { OpenMeter, ServerList } from '../src/index.js' +import { SDK_VERSION } from '../src/lib/version.js' + +const meter = { + id: '01ARZ3NDEKTSV4RRFFQ69G5FAV', + name: 'API calls', + key: 'api_calls', + aggregation: 'count', + event_type: 'api-request', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', +} + +beforeEach(() => { + fetchMock.mockReset() +}) + +function mockMeter() { + fetchMock.route('*', { + body: meter, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function lastUrl(): string { + return fetchMock.callHistory.lastCall()!.url +} + +function lastAuth(): string | null { + const headers = fetchMock.callHistory.lastCall()!.options.headers as Headers + return new Headers(headers).get('authorization') +} + +function lastUserAgent(): string | null { + const headers = fetchMock.callHistory.lastCall()!.options.headers as Headers + return new Headers(headers).get('user-agent') +} + +const fetch = fetchMock.fetchHandler + +describe('base URL construction', () => { + it('preserves the base path segment (/v3)', async () => { + mockMeter() + const sdk = new OpenMeter({ + baseUrl: 'https://eu.api.konghq.com/v3', + apiKey: 'k', + fetch, + }) + await sdk.meters.get({ meterId: 'm' }) + expect(lastUrl()).toBe('https://eu.api.konghq.com/v3/openmeter/meters/m') + }) + + it('accepts a URL object as baseUrl', async () => { + mockMeter() + const sdk = new OpenMeter({ + baseUrl: new URL('https://us.api.konghq.com/v3'), + apiKey: 'k', + fetch, + }) + await sdk.meters.get({ meterId: 'm' }) + expect(lastUrl()).toBe('https://us.api.konghq.com/v3/openmeter/meters/m') + }) + + it('sets the bearer auth header', async () => { + mockMeter() + const sdk = new OpenMeter({ + baseUrl: 'https://eu.api.konghq.com/v3', + apiKey: 'k', + fetch, + }) + await sdk.meters.get({ meterId: 'm' }) + expect(lastAuth()).toBe('Bearer k') + }) +}) + +describe('server-variable templating', () => { + it('substitutes a region variable', async () => { + mockMeter() + const sdk = new OpenMeter({ + baseUrl: ServerList[0], + serverVariables: { region: 'eu' }, + apiKey: 'k', + fetch, + }) + await sdk.meters.get({ meterId: 'm' }) + expect(lastUrl()).toBe('https://eu.api.konghq.com/v3/openmeter/meters/m') + }) + + it('throws when a required template variable is missing', () => { + expect( + () => new OpenMeter({ baseUrl: ServerList[0], apiKey: 'k', fetch }), + ).toThrow() + }) +}) + +describe('option clobbering is prevented', () => { + it('ignores a user-supplied prefix that would redirect requests', async () => { + mockMeter() + const sdk = new OpenMeter({ + baseUrl: 'https://eu.api.konghq.com/v3', + apiKey: 'k', + prefix: 'http://evil.test/', + fetch, + }) + await sdk.meters.get({ meterId: 'm' }) + expect(lastUrl()).toBe('https://eu.api.konghq.com/v3/openmeter/meters/m') + }) + + it('applies SDK auth after user beforeRequest hooks', async () => { + mockMeter() + const sdk = new OpenMeter({ + baseUrl: 'https://eu.api.konghq.com/v3', + apiKey: 'real-key', + fetch, + hooks: { + beforeRequest: [ + ({ request }) => { + request.headers.set('Authorization', 'Bearer ATTACKER') + }, + ], + }, + }) + await sdk.meters.get({ meterId: 'm' }) + expect(lastAuth()).toBe('Bearer real-key') + }) +}) + +describe('SDK telemetry headers', () => { + it('sets a default User-Agent header', async () => { + mockMeter() + const sdk = new OpenMeter({ + baseUrl: 'https://eu.api.konghq.com/v3', + apiKey: 'k', + fetch, + }) + await sdk.meters.get({ meterId: 'm' }) + expect(lastUserAgent()).toBe(`openmeter-node/${SDK_VERSION}`) + }) + + it('does not overwrite a caller-provided User-Agent', async () => { + mockMeter() + const sdk = new OpenMeter({ + baseUrl: 'https://eu.api.konghq.com/v3', + apiKey: 'k', + headers: { 'User-Agent': 'custom-agent/1.0' }, + fetch, + }) + await sdk.meters.get({ meterId: 'm' }) + expect(lastUserAgent()).toBe('custom-agent/1.0') + }) +}) + +describe('namespace composition', () => { + it('memoizes namespace accessors', () => { + const sdk = new OpenMeter({ + baseUrl: 'https://eu.api.konghq.com/v3', + apiKey: 'k', + fetch, + }) + expect(sdk.meters).toBe(sdk.meters) + }) + + it('routes namespace calls through the root transport', async () => { + mockMeter() + const sdk = new OpenMeter({ + baseUrl: 'https://eu.api.konghq.com/v3', + apiKey: 'k', + fetch, + }) + await sdk.meters.get({ meterId: 'm' }) + expect(lastUrl()).toBe('https://eu.api.konghq.com/v3/openmeter/meters/m') + expect(lastAuth()).toBe('Bearer k') + }) +}) diff --git a/api/spec/packages/typespec-typescript/templates/tests/errors.spec.ts b/api/spec/packages/typespec-typescript/templates/tests/errors.spec.ts new file mode 100644 index 0000000000..d0a4d76a1f --- /dev/null +++ b/api/spec/packages/typespec-typescript/templates/tests/errors.spec.ts @@ -0,0 +1,173 @@ +import fetchMock from '@fetch-mock/vitest' +import { beforeEach, describe, expect, it } from 'vitest' +import { Client, HTTPError, funcs } from '../src/index.js' + +beforeEach(() => { + fetchMock.mockReset() +}) + +function client() { + return new Client({ + baseUrl: 'https://eu.api.konghq.com/v3', + apiKey: 'k', + fetch: fetchMock.fetchHandler, + // Several of these tests mock retryable statuses (429, 500); disable ky's + // built-in retry so each test hits the mock exactly once and isn't slowed + // down (or, for a 30s Retry-After, timed out) by ky's own backoff. + retry: 0, + }) +} + +function mockProblem(status: number, body: Record) { + fetchMock.route('*', { + status, + body, + headers: { 'Content-Type': 'application/problem+json; charset=utf-8' }, + }) +} + +describe('error mapping', () => { + it('maps problem+json to a typed HTTPError with parsed fields', async () => { + mockProblem(404, { + type: 't', + title: 'Not Found', + status: 404, + detail: 'nope', + instance: '/x', + }) + const result = await funcs.getMeter(client(), { meterId: 'x' }) + expect(result.ok).toBe(false) + expect(result.error).toBeInstanceOf(HTTPError) + const httpError = result.error as HTTPError + expect(httpError.status).toBe(404) + expect(httpError.title).toBe('Not Found') + expect(httpError.message).toBe('nope') + }) + + it('sets the error name so it reads correctly in logs', async () => { + mockProblem(404, { + type: 't', + title: 'Not Found', + status: 404, + detail: 'nope', + instance: '/x', + }) + const result = await funcs.getMeter(client(), { meterId: 'x' }) + const httpError = result.error as HTTPError + expect(httpError.name).toBe('HTTPError') + }) + + it('exposes invalid_parameters via getField', async () => { + mockProblem(400, { + type: 't', + title: 'Bad Request', + status: 400, + detail: 'validation failed', + instance: '/x', + invalid_parameters: [{ field: 'name', reason: 'is required' }], + }) + const result = await funcs.getMeter(client(), { meterId: 'x' }) + const httpError = result.error as HTTPError + expect(httpError.getField('invalid_parameters')).toEqual([ + { field: 'name', reason: 'is required' }, + ]) + }) + + it('exposes invalid_parameters via the typed invalidParameters accessor', async () => { + mockProblem(400, { + type: 't', + title: 'Bad Request', + status: 400, + detail: 'validation failed', + instance: '/x', + invalid_parameters: [ + { field: 'name', reason: 'is required' }, + { + field: 'quantity', + rule: 'min', + minimum: 1, + reason: 'must be at least 1', + }, + ], + }) + const result = await funcs.getMeter(client(), { meterId: 'x' }) + const httpError = result.error as HTTPError + expect(httpError.invalidParameters).toEqual([ + { field: 'name', reason: 'is required' }, + { + field: 'quantity', + rule: 'min', + minimum: 1, + reason: 'must be at least 1', + }, + ]) + }) + + it('falls back to a status-only error for non-problem responses', async () => { + fetchMock.route('*', { + status: 500, + body: 'oops', + headers: { 'Content-Type': 'text/plain' }, + }) + const result = await funcs.getMeter(client(), { meterId: 'x' }) + expect(result.error).toBeInstanceOf(HTTPError) + const httpError = result.error as HTTPError + expect(httpError.status).toBe(500) + expect(httpError.invalidParameters).toBeUndefined() + }) +}) + +describe('retryAfter', () => { + it('parses a delta-seconds Retry-After header', async () => { + fetchMock.route('*', { + status: 429, + body: { + type: 't', + title: 'Too Many Requests', + status: 429, + detail: 'slow down', + instance: '/x', + }, + headers: { + 'Content-Type': 'application/problem+json; charset=utf-8', + 'Retry-After': '30', + }, + }) + const result = await funcs.getMeter(client(), { meterId: 'x' }) + const httpError = result.error as HTTPError + expect(httpError.retryAfter).toBe(30) + }) + + it('is undefined when the header is absent', async () => { + mockProblem(500, { + type: 't', + title: 'Internal Server Error', + status: 500, + detail: 'boom', + instance: '/x', + }) + const result = await funcs.getMeter(client(), { meterId: 'x' }) + const httpError = result.error as HTTPError + expect(httpError.retryAfter).toBeUndefined() + }) + + it('is undefined for an HTTP-date Retry-After header', async () => { + fetchMock.route('*', { + status: 429, + body: { + type: 't', + title: 'Too Many Requests', + status: 429, + detail: 'slow down', + instance: '/x', + }, + headers: { + 'Content-Type': 'application/problem+json; charset=utf-8', + 'Retry-After': 'Wed, 21 Oct 2015 07:28:00 GMT', + }, + }) + const result = await funcs.getMeter(client(), { meterId: 'x' }) + const httpError = result.error as HTTPError + expect(httpError.retryAfter).toBeUndefined() + }) +}) diff --git a/api/spec/packages/typespec-typescript/templates/tests/meters.spec.ts b/api/spec/packages/typespec-typescript/templates/tests/meters.spec.ts new file mode 100644 index 0000000000..3ca7af8a6f --- /dev/null +++ b/api/spec/packages/typespec-typescript/templates/tests/meters.spec.ts @@ -0,0 +1,102 @@ +import fetchMock from '@fetch-mock/vitest' +import { beforeEach, describe, expect, it } from 'vitest' +import { Client, funcs } from '../src/index.js' + +const meter = { + id: '01ARZ3NDEKTSV4RRFFQ69G5FAV', + name: 'API calls', + key: 'api_calls', + aggregation: 'count', + event_type: 'api-request', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', +} + +beforeEach(() => { + fetchMock.mockReset() +}) + +function client() { + return new Client({ + baseUrl: 'https://eu.api.konghq.com/v3', + apiKey: 'k', + fetch: fetchMock.fetchHandler, + }) +} + +function mockList() { + fetchMock.route('*', { + body: { data: [meter], meta: { page: { number: 1, size: 10, total: 1 } } }, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function lastQuery(): URLSearchParams { + return new URL(fetchMock.callHistory.lastCall()!.url).searchParams +} + +describe('listMeters query serialization', () => { + it('encodes page as a deep object', async () => { + mockList() + await funcs.listMeters(client(), { page: { size: 10, number: 2 } }) + const q = lastQuery() + expect(q.get('page[size]')).toBe('10') + expect(q.get('page[number]')).toBe('2') + }) + + it('encodes a scalar filter', async () => { + mockList() + await funcs.listMeters(client(), { filter: { key: 'm' } }) + expect(lastQuery().get('filter[key]')).toBe('m') + }) + + it('encodes a nested filter operand as a deep object', async () => { + mockList() + await funcs.listMeters(client(), { filter: { key: { eq: 'foo' } } }) + expect(lastQuery().get('filter[key][eq]')).toBe('foo') + }) + + it('comma-joins array operands into a single parameter', async () => { + mockList() + await funcs.listMeters(client(), { + filter: { key: { oeq: ['a', 'b', 'c'] } }, + }) + const q = lastQuery() + expect(q.getAll('filter[key][oeq]')).toHaveLength(1) + expect(q.get('filter[key][oeq]')).toBe('a,b,c') + }) + + it('serializes sort as a plain string, snake-ifying the field name', async () => { + mockList() + await funcs.listMeters(client(), { + sort: { by: 'createdAt', order: 'desc' }, + }) + const q = lastQuery() + expect(q.get('sort')).toBe('created_at desc') + expect(q.get('sort[by]')).toBeNull() + }) + + it('omits sort order when ascending is implied', async () => { + mockList() + await funcs.listMeters(client(), { sort: { by: 'name' } }) + expect(lastQuery().get('sort')).toBe('name') + }) +}) + +describe('responses are mapped to the camelCase shape', () => { + it('camelizes known fields and drops fields not in the schema', async () => { + fetchMock.route('*', { + body: { ...meter, brand_new_field: 'dropped' }, + headers: { 'Content-Type': 'application/json' }, + }) + const result = await funcs.getMeter(client(), { meterId: 'm' }) + expect(result.ok).toBe(true) + expect(result.value).toMatchObject({ + id: meter.id, + eventType: meter.event_type, + createdAt: new Date(meter.created_at), + }) + expect('event_type' in result.value!).toBe(false) + expect('brand_new_field' in result.value!).toBe(false) + }) +}) diff --git a/api/spec/packages/typespec-typescript/templates/tests/nesting.spec.ts b/api/spec/packages/typespec-typescript/templates/tests/nesting.spec.ts new file mode 100644 index 0000000000..feb73019f3 --- /dev/null +++ b/api/spec/packages/typespec-typescript/templates/tests/nesting.spec.ts @@ -0,0 +1,33 @@ +import fetchMock from '@fetch-mock/vitest' +import { beforeEach, describe, expect, it } from 'vitest' +import { OpenMeter } from '../src/index.js' + +beforeEach(() => { + fetchMock.mockReset() +}) + +const fetch = fetchMock.fetchHandler + +function lastUrl(): string { + return fetchMock.callHistory.lastCall()!.url +} + +describe('nested sub-clients', () => { + it('routes customers.charges.list() to the charges sub-resource', async () => { + fetchMock.route('*', { + body: { data: [], meta: { page: { number: 1, size: 10, total: 0 } } }, + headers: { 'Content-Type': 'application/json' }, + }) + const sdk = new OpenMeter({ + baseUrl: 'https://eu.api.konghq.com/v3', + apiKey: 'k', + fetch, + }) + await sdk.customers.charges.list({ + customerId: '01ARZ3NDEKTSV4RRFFQ69G5FAV', + }) + expect(lastUrl()).toBe( + 'https://eu.api.konghq.com/v3/openmeter/customers/01ARZ3NDEKTSV4RRFFQ69G5FAV/charges', + ) + }) +}) diff --git a/api/spec/packages/typespec-typescript/vitest.config.ts b/api/spec/packages/typespec-typescript/vitest.config.ts new file mode 100644 index 0000000000..1fae6e573b --- /dev/null +++ b/api/spec/packages/typespec-typescript/vitest.config.ts @@ -0,0 +1,14 @@ +import { configDefaults, defineConfig } from 'vitest/config' + +// `templates/` holds real, committed copies of the SDK runtime files and +// conformance tests that `runtime-templates.ts` reads via readFileSync and +// emits into the generated SDK (see api/spec/AGENTS.md). Vitest's default +// include glob (`**/*.spec.ts`) would otherwise collect `templates/tests/*.spec.ts` +// as this package's own tests — they import `../src/index.js`, which only +// exists in the generated `aip-client-javascript` output where these files +// are actually emitted and run (via `pnpm run test:sdk`). +export default defineConfig({ + test: { + exclude: [...configDefaults.exclude, 'templates/**'], + }, +}) diff --git a/api/spec/pnpm-lock.yaml b/api/spec/pnpm-lock.yaml index fafb733f3f..8e486fa882 100644 --- a/api/spec/pnpm-lock.yaml +++ b/api/spec/pnpm-lock.yaml @@ -81,10 +81,10 @@ importers: packages/aip-client-javascript: dependencies: ky: - specifier: 2.0.2 + specifier: ^2.0.2 version: 2.0.2 zod: - specifier: 4.4.3 + specifier: ^4.4.3 version: 4.4.3 devDependencies: '@types/node': From 367380a4cf313c44726b629810c84cdd4234f348 Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:32:25 +0200 Subject: [PATCH 2/5] fix(api): fail loudly when SDK version stamping does not match Review follow-up: the publish-time SDK_VERSION rewrite silently no-opped if the version.ts format drifted, publishing a stale version in the User-Agent header. Also clarify in AGENTS.md that a missing JSDoc description on a generated method is a spec-authoring gap, not an emitter bug. --- api/spec/AGENTS.md | 5 ++++- api/spec/Makefile | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/api/spec/AGENTS.md b/api/spec/AGENTS.md index 5eefeb804e..788b4be520 100644 --- a/api/spec/AGENTS.md +++ b/api/spec/AGENTS.md @@ -569,7 +569,10 @@ 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. `*Input` variant +`@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 diff --git a/api/spec/Makefile b/api/spec/Makefile index 8fc6e057cc..166d5a3bbc 100644 --- a/api/spec/Makefile +++ b/api/spec/Makefile @@ -53,7 +53,7 @@ publish-aip-sdk: ## Publish the AIP TypeScript SDK (@openmeter/client) to npm cd packages/aip-client-javascript && \ pnpm version "$${AIP_SDK_RELEASE_VERSION}" --no-git-tag-version && \ - node -e "const fs=require('node:fs');const p='src/lib/version.ts';const v=process.env.AIP_SDK_RELEASE_VERSION;fs.writeFileSync(p, fs.readFileSync(p,'utf8').replace(/SDK_VERSION = '[^']*'/, \"SDK_VERSION = '\" + v + \"'\"))" && \ + node -e "const fs=require('node:fs');const p='src/lib/version.ts';const v=process.env.AIP_SDK_RELEASE_VERSION;const src=fs.readFileSync(p,'utf8');const out=src.replace(/SDK_VERSION = '[^']*'/, \"SDK_VERSION = '\" + v + \"'\");if(out===src){throw new Error('SDK_VERSION pattern not found in '+p)}fs.writeFileSync(p, out)" && \ CACHE_BUSTER="$$(date --rfc-3339=seconds)" pnpm publish --no-git-checks --access public --tag "$${AIP_SDK_RELEASE_TAG}" @echo "✅ Published $${AIP_SDK_RELEASE_TAG} AIP TypeScript SDK (@openmeter/client) version $${AIP_SDK_RELEASE_VERSION} with tag $${AIP_SDK_RELEASE_TAG}" From f322312f2a4b1166dceef69826175a756ce5f62e Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:40:36 +0200 Subject: [PATCH 3/5] feat(api): emit x-internal operations under client.internal in the TS SDK Operations marked x-internal (without x-private) in the TypeSpec source were omitted from the generated TypeScript SDK entirely. They are now emitted but quarantined under client.internal..: a new src/sdk/internal.ts holds an Internal aggregate with Internal facade classes, while internal operations share their group's funcs/ and models/operations/ modules with the public surface. A group whose operations are all internal (currencies) gets no public facade or getter, and the Internal class is not re-exported at the package root (the name belongs to the Internal Server Error model). x-private operations remain fully omitted. The README gains an Internal Operations section, and a fifth generated conformance test (tests/internal.spec.ts) pins the internal routing and the public-surface quarantine. Newly surfaced operations: create-subscription-addon, list-currencies, create-custom-currency, list-cost-bases, create-cost-basis. --- AGENTS.md | 2 +- api/spec/AGENTS.md | 27 +-- .../packages/aip-client-javascript/README.md | 25 +++ .../src/funcs/currencies.ts | 163 ++++++++++++++ .../aip-client-javascript/src/funcs/index.ts | 1 + .../src/funcs/subscriptions.ts | 37 ++++ .../aip-client-javascript/src/index.ts | 4 +- .../src/models/operations/currencies.ts | 67 ++++++ .../src/models/operations/subscriptions.ts | 7 + .../src/models/schemas.ts | 131 ++++++++++++ .../aip-client-javascript/src/models/types.ts | 5 +- .../aip-client-javascript/src/sdk/internal.ts | 167 +++++++++++++++ .../aip-client-javascript/src/sdk/sdk.ts | 12 ++ .../tests/internal.spec.ts | 71 ++++++ .../typespec-typescript/src/ZodOperations.tsx | 41 +++- .../typespec-typescript/src/emitter.tsx | 53 ++++- .../typespec-typescript/src/readme.ts | 45 +++- .../src/runtime-templates.ts | 1 + .../typespec-typescript/src/sdk-files.ts | 202 +++++++++++++----- .../templates/tests/internal.spec.ts | 69 ++++++ 20 files changed, 1046 insertions(+), 84 deletions(-) create mode 100644 api/spec/packages/aip-client-javascript/src/funcs/currencies.ts create mode 100644 api/spec/packages/aip-client-javascript/src/models/operations/currencies.ts create mode 100644 api/spec/packages/aip-client-javascript/src/sdk/internal.ts create mode 100644 api/spec/packages/aip-client-javascript/tests/internal.spec.ts create mode 100644 api/spec/packages/typespec-typescript/templates/tests/internal.spec.ts diff --git a/AGENTS.md b/AGENTS.md index 12af4b775e..ce3a9a37e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,7 @@ All generated files have `// Code generated by X, DO NOT EDIT.` headers — neve 2. Run `make gen-api` to regenerate OpenAPI spec and SDKs 3. Run `make generate` to regenerate Go server/client code -The TypeSpec JS client emitted from `api/spec/packages/aip` now lands in `api/spec/packages/aip-client-javascript/`. The emitter regenerates `src/`, `README.md`, and four conformance test files (`tests/client.spec.ts`, `tests/meters.spec.ts`, `tests/errors.spec.ts`, `tests/nesting.spec.ts`); every regenerated file carries a `Code generated by @openmeter/typespec-typescript. DO NOT EDIT.` header — treat files without that header as hand-written. `package.json` is **stable, hand-maintained, and committed** (the emitter's `writeOutput` only writes the paths it lists, so the manifest survives regeneration). The test suite is vitest + `@fetch-mock/vitest`; keep hand-written tests and helpers in `tests/` (never `src/`), and put test-runner dependencies/scripts in the `api/spec/package.json` workspace root rather than the client package manifest. Static publish metadata (`name`, `license`, `homepage`, `repository`) lives directly in the client `package.json`; only the per-release `version` is injected at publish time. Operations marked `x-private` or `x-internal` in the TypeSpec source are omitted from the generated SDK (they stay in the OpenAPI output); removing an operation from the spec (or marking it private/internal) leaves stale generated files behind — the emitter never deletes outputs, so `git rm` them. +The TypeSpec JS client emitted from `api/spec/packages/aip` now lands in `api/spec/packages/aip-client-javascript/`. The emitter regenerates `src/`, `README.md`, and five conformance test files (`tests/client.spec.ts`, `tests/meters.spec.ts`, `tests/errors.spec.ts`, `tests/nesting.spec.ts`, `tests/internal.spec.ts`); every regenerated file carries a `Code generated by @openmeter/typespec-typescript. DO NOT EDIT.` header — treat files without that header as hand-written. `package.json` is **stable, hand-maintained, and committed** (the emitter's `writeOutput` only writes the paths it lists, so the manifest survives regeneration). The test suite is vitest + `@fetch-mock/vitest`; keep hand-written tests and helpers in `tests/` (never `src/`), and put test-runner dependencies/scripts in the `api/spec/package.json` workspace root rather than the client package manifest. Static publish metadata (`name`, `license`, `homepage`, `repository`) lives directly in the client `package.json`; only the per-release `version` is injected at publish time. Operations marked `x-private` in the TypeSpec source are omitted from the generated SDK (they stay in the OpenAPI output); operations marked `x-internal` (without `x-private`) are emitted but quarantined under `client.internal..` (`src/sdk/internal.ts`, an `Internal` aggregate with `Internal` facade classes; internal ops share their group's `funcs/` and `models/operations/` modules, a group whose ops are all internal gets no public facade, and the `Internal` class is deliberately not re-exported from the package root — the name belongs to the Internal Server Error model). Removing an operation from the spec (or marking it private) leaves stale generated files behind — the emitter never deletes outputs, so `git rm` them. The emitted `api/spec/packages/aip-client-javascript/src/sdk/sdk.ts` exposes aggregated sub-client getters on the `OpenMeter` class (e.g. `events`, `meters`, `customers`, `entitlements`, `subscriptions`, `billing`, `features`, `plans`, `addons`, `planAddons`, `tax`, `defaults`). Access operations through those getters, e.g. `sdk.meters.list()`, `sdk.customers.create(...)`, `sdk.plans.create(...)`. These grouped-client methods throw `HTTPError` on failure; the same operations are also available as tree-shakeable standalone functions under `src/funcs/` that return a `Result` instead of throwing. diff --git a/api/spec/AGENTS.md b/api/spec/AGENTS.md index 788b4be520..8df8fecc44 100644 --- a/api/spec/AGENTS.md +++ b/api/spec/AGENTS.md @@ -313,7 +313,7 @@ Structural rules the interface emitter follows: 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 a - non-internal/non-private operation on an included service gets its own + non-`x-private` operation on an included service gets its own `export type = | | …` 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 @@ -326,19 +326,22 @@ Structural rules the interface emitter follows: **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 included, non-internal/private operation's request - body, query parameters, and response body (success and error) and only aliases - unions it reaches. `PriceUsageBased`, `ULIDOrResourceKey`, and + `emitter.tsx` walks every collected operation's request body, query + parameters, and response body (success and error) and only aliases unions it + reaches. Collected means non-`x-private`: `x-internal` operations count as + reachability roots because they are emitted under the `client.internal.*` + surface. `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`); `Invoice`/`InvoiceLine`/`UpdateInvoiceRequest`/`Currency` are - reachable only through invoice/currency operations marked - `x-internal`/`x-unstable`, which are excluded from the generated client - entirely, so their unions are excluded too even though the underlying models - (e.g. `InvoiceStandard`) still get interfaces (models are never - reachability-gated — only the new 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. + `string | string`); `Invoice`/`InvoiceLine`/`UpdateInvoiceRequest` are + reachable only through the invoice operations marked `x-private`, which are + excluded from the generated client entirely, so their unions are excluded too + even though the underlying models (e.g. `InvoiceStandard`) still get + interfaces (models are never reachability-gated — only the new union alias + pass is). `Currency` is reachable through the `x-internal` currency + operations, so its alias is emitted. 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 diff --git a/api/spec/packages/aip-client-javascript/README.md b/api/spec/packages/aip-client-javascript/README.md index 5065e61920..d7d989e765 100644 --- a/api/spec/packages/aip-client-javascript/README.md +++ b/api/spec/packages/aip-client-javascript/README.md @@ -34,6 +34,9 @@ TypeSpec definitions and ships fully-typed request and response models. - [Addons](#addons) - [PlanAddons](#planaddons) - [Defaults](#defaults) +- [Internal Operations](#internal-operations) + - [Internal Subscriptions](#internal-subscriptions) + - [Internal Currencies](#internal-currencies) - [Runtime Validation (validate option)](#runtime-validation-validate-option) - [Zod Schemas (./zod export)](#zod-schemas-zod-export) - [Error Handling](#error-handling) @@ -404,6 +407,28 @@ The full call path, HTTP route, and a short description are listed below. | `client.defaults.getOrganizationTaxCodes` | `GET /openmeter/defaults/tax-codes` | Get organization default tax codes | | `client.defaults.updateOrganizationTaxCodes` | `PUT /openmeter/defaults/tax-codes` | Update organization default tax codes | +## Internal Operations + +Operations marked internal in the API definition are exposed under +`client.internal.*`, quarantined from the customer surface. They are not +intended for customer use: they may require additional permissions, and +they can change or be removed without notice or semver consideration. + +### Internal Subscriptions + +| Method | HTTP | Description | +| ------------------------------------------- | ------------------------------------------------------- | ----------------------------- | +| `client.internal.subscriptions.createAddon` | `POST /openmeter/subscriptions/{subscriptionId}/addons` | Add add-on to a subscription. | + +### Internal Currencies + +| Method | HTTP | Description | +| ------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `client.internal.currencies.list` | `GET /openmeter/currencies` | List currencies supported by the billing system. | +| `client.internal.currencies.createCustomCurrency` | `POST /openmeter/currencies/custom` | Create a custom currency. This operation allows defining your own custom currency for billing purposes. | +| `client.internal.currencies.listCostBases` | `GET /openmeter/currencies/custom/{currencyId}/cost-bases` | List cost bases for a currency. For custom currencies, there can be multiple cost bases with different `effective_from` dates. | +| `client.internal.currencies.createCostBasis` | `POST /openmeter/currencies/custom/{currencyId}/cost-bases` | Create a cost basis for a currency. | + ## Runtime Validation (validate option) `validate` is off by default. The SDK's normal request/response mapping diff --git a/api/spec/packages/aip-client-javascript/src/funcs/currencies.ts b/api/spec/packages/aip-client-javascript/src/funcs/currencies.ts new file mode 100644 index 0000000000..dfedd354fc --- /dev/null +++ b/api/spec/packages/aip-client-javascript/src/funcs/currencies.ts @@ -0,0 +1,163 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + +import { type Client, http } from '../core.js' +import { type Result, type RequestOptions } from '../lib/types.js' +import { request } from '../lib/request.js' +import { toURLSearchParams, encodeSort } from '../lib/encodings.js' +import { toWire, fromWire, assertValid, toSnakeCase } from '../lib/wire.js' +import * as schemas from '../models/schemas.js' +import type { + ListCurrenciesRequest, + ListCurrenciesResponse, + CreateCustomCurrencyRequest, + CreateCustomCurrencyResponse, + ListCostBasesRequest, + ListCostBasesResponse, + CreateCostBasisRequest, + CreateCostBasisResponse, +} from '../models/operations/currencies.js' + +/** + * List currencies + * + * List currencies supported by the billing system. + * + * GET /openmeter/currencies + */ +export function listCurrencies( + client: Client, + req: ListCurrenciesRequest = {}, + options?: RequestOptions, +): Promise> { + return request(() => { + const query = toWire( + { + page: req.page, + sort: encodeSort(req.sort, toSnakeCase), + filter: req.filter, + }, + schemas.listCurrenciesQueryParams, + ) + if (client._options.validate) { + assertValid(schemas.listCurrenciesQueryParamsWire, query) + } + const searchParams = toURLSearchParams(query) + return http(client) + .get('openmeter/currencies', { ...options, searchParams }) + .json() + .then((data) => { + if (client._options.validate) { + assertValid(schemas.listCurrenciesResponseWire, data) + } + return fromWire(data, schemas.listCurrenciesResponse) + }) + }) +} + +/** + * Create custom currency + * + * Create a custom currency. This operation allows defining your own custom + * currency for billing purposes. + * + * POST /openmeter/currencies/custom + */ +export function createCustomCurrency( + client: Client, + req: CreateCustomCurrencyRequest, + options?: RequestOptions, +): Promise> { + return request(() => { + const body = toWire(req, schemas.createCustomCurrencyBody) + if (client._options.validate) { + assertValid(schemas.createCustomCurrencyBodyWire, body) + } + return http(client) + .post('openmeter/currencies/custom', { ...options, json: body }) + .json() + .then((data) => { + if (client._options.validate) { + assertValid(schemas.createCustomCurrencyResponseWire, data) + } + return fromWire(data, schemas.createCustomCurrencyResponse) + }) + }) +} + +/** + * List cost bases + * + * List cost bases for a currency. For custom currencies, there can be multiple + * cost bases with different `effective_from` dates. + * + * GET /openmeter/currencies/custom/{currencyId}/cost-bases + */ +export function listCostBases( + client: Client, + req: ListCostBasesRequest, + options?: RequestOptions, +): Promise> { + return request(() => { + const path = `openmeter/currencies/custom/${(() => { + if (req.currencyId === undefined) { + throw new Error('missing path parameter: currencyId') + } + return encodeURIComponent(String(req.currencyId)) + })()}/cost-bases` + const query = toWire( + { + filter: req.filter, + page: req.page, + }, + schemas.listCostBasesQueryParams, + ) + if (client._options.validate) { + assertValid(schemas.listCostBasesQueryParamsWire, query) + } + const searchParams = toURLSearchParams(query) + return http(client) + .get(path, { ...options, searchParams }) + .json() + .then((data) => { + if (client._options.validate) { + assertValid(schemas.listCostBasesResponseWire, data) + } + return fromWire(data, schemas.listCostBasesResponse) + }) + }) +} + +/** + * Create cost basis + * + * Create a cost basis for a currency. + * + * POST /openmeter/currencies/custom/{currencyId}/cost-bases + */ +export function createCostBasis( + client: Client, + req: CreateCostBasisRequest, + options?: RequestOptions, +): Promise> { + return request(() => { + const path = `openmeter/currencies/custom/${(() => { + if (req.currencyId === undefined) { + throw new Error('missing path parameter: currencyId') + } + return encodeURIComponent(String(req.currencyId)) + })()}/cost-bases` + const body = toWire(req.body, schemas.createCostBasisBody) + if (client._options.validate) { + assertValid(schemas.createCostBasisBodyWire, body) + } + return http(client) + .post(path, { ...options, json: body }) + .json() + .then((data) => { + if (client._options.validate) { + assertValid(schemas.createCostBasisResponseWire, data) + } + return fromWire(data, schemas.createCostBasisResponse) + }) + }) +} diff --git a/api/spec/packages/aip-client-javascript/src/funcs/index.ts b/api/spec/packages/aip-client-javascript/src/funcs/index.ts index 0de4ce8a97..11b36454ff 100644 --- a/api/spec/packages/aip-client-javascript/src/funcs/index.ts +++ b/api/spec/packages/aip-client-javascript/src/funcs/index.ts @@ -8,6 +8,7 @@ export * from './subscriptions.js' export * from './apps.js' export * from './billing.js' export * from './tax.js' +export * from './currencies.js' export * from './features.js' export * from './llmCost.js' export * from './plans.js' diff --git a/api/spec/packages/aip-client-javascript/src/funcs/subscriptions.ts b/api/spec/packages/aip-client-javascript/src/funcs/subscriptions.ts index 2f04071c47..458f91e923 100644 --- a/api/spec/packages/aip-client-javascript/src/funcs/subscriptions.ts +++ b/api/spec/packages/aip-client-javascript/src/funcs/subscriptions.ts @@ -19,6 +19,8 @@ import type { UnscheduleCancelationResponse, ChangeSubscriptionRequest, ChangeSubscriptionResponse, + CreateSubscriptionAddonRequest, + CreateSubscriptionAddonResponse, ListSubscriptionAddonsRequest, ListSubscriptionAddonsResponse, GetSubscriptionAddonRequest, @@ -219,6 +221,41 @@ export function changeSubscription( }) } +/** + * Create a new subscription add-on + * + * Add add-on to a subscription. + * + * POST /openmeter/subscriptions/{subscriptionId}/addons + */ +export function createSubscriptionAddon( + client: Client, + req: CreateSubscriptionAddonRequest, + options?: RequestOptions, +): Promise> { + return request(() => { + const path = `openmeter/subscriptions/${(() => { + if (req.subscriptionId === undefined) { + throw new Error('missing path parameter: subscriptionId') + } + return encodeURIComponent(String(req.subscriptionId)) + })()}/addons` + const body = toWire(req.body, schemas.createSubscriptionAddonBody) + if (client._options.validate) { + assertValid(schemas.createSubscriptionAddonBodyWire, body) + } + return http(client) + .post(path, { ...options, json: body }) + .json() + .then((data) => { + if (client._options.validate) { + assertValid(schemas.createSubscriptionAddonResponseWire, data) + } + return fromWire(data, schemas.createSubscriptionAddonResponse) + }) + }) +} + /** * List subscription addons * diff --git a/api/spec/packages/aip-client-javascript/src/index.ts b/api/spec/packages/aip-client-javascript/src/index.ts index 5e64f493d7..3a99e578a9 100644 --- a/api/spec/packages/aip-client-javascript/src/index.ts +++ b/api/spec/packages/aip-client-javascript/src/index.ts @@ -47,6 +47,7 @@ export type * from './models/operations/subscriptions.js' export type * from './models/operations/apps.js' export type * from './models/operations/billing.js' export type * from './models/operations/tax.js' +export type * from './models/operations/currencies.js' export type * from './models/operations/features.js' export type * from './models/operations/llmCost.js' export type * from './models/operations/plans.js' @@ -138,7 +139,6 @@ export type { SubscriptionAddonTimelineSegment, UpdateClosedPeriod, CostBasis, - CreateCostBasisRequest, FeatureCostQueryRow, Resource, ResourceImmutable, @@ -256,7 +256,6 @@ export type { SubscriptionChangeResponse, SubscriptionCancel, SubscriptionChange, - CreateSubscriptionAddonRequest, InvoiceUsageQuantityDetail, AppStripe, AppSandbox, @@ -344,6 +343,7 @@ export type { WorkflowPaymentSettings, InvalidParameter, RateCardEntitlement, + Currency, FeatureUnitCost, WorkflowCollectionAlignment, App, diff --git a/api/spec/packages/aip-client-javascript/src/models/operations/currencies.ts b/api/spec/packages/aip-client-javascript/src/models/operations/currencies.ts new file mode 100644 index 0000000000..00efaf64c0 --- /dev/null +++ b/api/spec/packages/aip-client-javascript/src/models/operations/currencies.ts @@ -0,0 +1,67 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + +import { z } from 'zod' +import * as schemas from '../schemas.js' +import type { AcceptDateStrings } from '../../lib/wire.js' +import type { + CostBasis, + CostBasisPagePaginatedResponse, + CreateCostBasisRequest as CreateCostBasisRequestBody, + CreateCurrencyCustomRequest, + CurrencyCustom, + CurrencyPagePaginatedResponse, + ListCostBasesParamsFilter, + ListCurrenciesParamsFilter, + SortQueryInput, +} from '../types.js' + +export interface ListCurrenciesQuery { + /** Determines which page of the collection to retrieve. */ + page?: { size?: number; number?: number } + /** + * Sort currencies returned in the response. Supported sort attributes are: + * + * - `code` (default) + * - `name` + * + * The `asc` suffix is optional as the default sort order is ascending. The `desc` + * suffix is used to specify a descending order. + */ + sort?: SortQueryInput + /** + * Filter currencies returned in the response. + * + * To filter currencies by type add the following query param: filter[type]=custom + */ + filter?: ListCurrenciesParamsFilter +} + +export type ListCurrenciesRequest = AcceptDateStrings +export type ListCurrenciesResponse = CurrencyPagePaginatedResponse + +export type CreateCustomCurrencyRequest = + AcceptDateStrings +export type CreateCustomCurrencyResponse = CurrencyCustom + +export interface ListCostBasesQuery { + /** + * Filter cost bases returned in the response. + * + * To filter cost bases by fiat currency code add the following query param: + * filter[fiat_code]=USD + */ + filter?: ListCostBasesParamsFilter + /** Determines which page of the collection to retrieve. */ + page?: { size?: number; number?: number } +} + +export type ListCostBasesRequest = AcceptDateStrings< + ListCostBasesQuery & { currencyId: string } +> +export type ListCostBasesResponse = CostBasisPagePaginatedResponse + +export type CreateCostBasisRequest = AcceptDateStrings<{ + currencyId: string + body: CreateCostBasisRequestBody +}> +export type CreateCostBasisResponse = CostBasis diff --git a/api/spec/packages/aip-client-javascript/src/models/operations/subscriptions.ts b/api/spec/packages/aip-client-javascript/src/models/operations/subscriptions.ts index aed474517b..7e575cc6f3 100644 --- a/api/spec/packages/aip-client-javascript/src/models/operations/subscriptions.ts +++ b/api/spec/packages/aip-client-javascript/src/models/operations/subscriptions.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import * as schemas from '../schemas.js' import type { AcceptDateStrings } from '../../lib/wire.js' import type { + CreateSubscriptionAddonRequest as CreateSubscriptionAddonRequestBody, ListSubscriptionsParamsFilter, SortQueryInput, Subscription, @@ -62,6 +63,12 @@ export type ChangeSubscriptionRequest = AcceptDateStrings<{ }> export type ChangeSubscriptionResponse = SubscriptionChangeResponse +export type CreateSubscriptionAddonRequest = AcceptDateStrings<{ + subscriptionId: string + body: CreateSubscriptionAddonRequestBody +}> +export type CreateSubscriptionAddonResponse = SubscriptionAddon + export interface ListSubscriptionAddonsQuery { /** Determines which page of the collection to retrieve. */ page?: { size?: number; number?: number } diff --git a/api/spec/packages/aip-client-javascript/src/models/schemas.ts b/api/spec/packages/aip-client-javascript/src/models/schemas.ts index d6201a7065..c757e123e3 100644 --- a/api/spec/packages/aip-client-javascript/src/models/schemas.ts +++ b/api/spec/packages/aip-client-javascript/src/models/schemas.ts @@ -5981,6 +5981,14 @@ export const changeSubscriptionBody = subscriptionChange export const changeSubscriptionResponse = subscriptionChangeResponse +export const createSubscriptionAddonPathParams = z.object({ + subscriptionId: ulid, +}) + +export const createSubscriptionAddonBody = createSubscriptionAddonRequest + +export const createSubscriptionAddonResponse = subscriptionAddon + export const listSubscriptionAddonsPathParams = z.object({ subscriptionId: ulid, }) @@ -6123,6 +6131,63 @@ export const deleteTaxCodePathParams = z.object({ taxCodeId: ulid, }) +export const listCurrenciesQueryParams = z.object({ + page: z + .object({ + size: z.coerce + .number() + .int() + .optional() + .describe('The number of items to include per page.'), + number: z.coerce.number().int().optional().describe('The page number.'), + }) + .optional() + .describe('Determines which page of the collection to retrieve.'), + sort: sortQuery.optional(), + filter: listCurrenciesParamsFilter.optional(), +}) + +export const listCurrenciesResponse = z.object({ + data: z.array(currency), + meta: paginatedMeta, +}) + +export const createCustomCurrencyBody = createCurrencyCustomRequest + +export const createCustomCurrencyResponse = currencyCustom + +export const listCostBasesPathParams = z.object({ + currencyId: ulid, +}) + +export const listCostBasesQueryParams = z.object({ + filter: listCostBasesParamsFilter.optional(), + page: z + .object({ + size: z.coerce + .number() + .int() + .optional() + .describe('The number of items to include per page.'), + number: z.coerce.number().int().optional().describe('The page number.'), + }) + .optional() + .describe('Determines which page of the collection to retrieve.'), +}) + +export const listCostBasesResponse = z.object({ + data: z.array(costBasis), + meta: paginatedMeta, +}) + +export const createCostBasisPathParams = z.object({ + currencyId: ulid, +}) + +export const createCostBasisBody = createCostBasisRequest + +export const createCostBasisResponse = costBasis + export const listFeaturesQueryParams = z.object({ page: z .object({ @@ -12406,6 +12471,15 @@ export const changeSubscriptionBodyWire = subscriptionChangeWire export const changeSubscriptionResponseWire = subscriptionChangeResponseWire +export const createSubscriptionAddonPathParamsWire = z.object({ + subscriptionId: ulidWire, +}) + +export const createSubscriptionAddonBodyWire = + createSubscriptionAddonRequestWire + +export const createSubscriptionAddonResponseWire = subscriptionAddonWire + export const listSubscriptionAddonsPathParamsWire = z.object({ subscriptionId: ulidWire, }) @@ -12548,6 +12622,63 @@ export const deleteTaxCodePathParamsWire = z.object({ taxCodeId: ulidWire, }) +export const listCurrenciesQueryParamsWire = z.object({ + page: z + .strictObject({ + size: z.coerce + .number() + .int() + .optional() + .describe('The number of items to include per page.'), + number: z.coerce.number().int().optional().describe('The page number.'), + }) + .optional() + .describe('Determines which page of the collection to retrieve.'), + sort: sortQueryWire.optional(), + filter: listCurrenciesParamsFilterWire.optional(), +}) + +export const listCurrenciesResponseWire = z.strictObject({ + data: z.array(currencyWire), + meta: paginatedMetaWire, +}) + +export const createCustomCurrencyBodyWire = createCurrencyCustomRequestWire + +export const createCustomCurrencyResponseWire = currencyCustomWire + +export const listCostBasesPathParamsWire = z.object({ + currencyId: ulidWire, +}) + +export const listCostBasesQueryParamsWire = z.object({ + filter: listCostBasesParamsFilterWire.optional(), + page: z + .strictObject({ + size: z.coerce + .number() + .int() + .optional() + .describe('The number of items to include per page.'), + number: z.coerce.number().int().optional().describe('The page number.'), + }) + .optional() + .describe('Determines which page of the collection to retrieve.'), +}) + +export const listCostBasesResponseWire = z.strictObject({ + data: z.array(costBasisWire), + meta: paginatedMetaWire, +}) + +export const createCostBasisPathParamsWire = z.object({ + currencyId: ulidWire, +}) + +export const createCostBasisBodyWire = createCostBasisRequestWire + +export const createCostBasisResponseWire = costBasisWire + export const listFeaturesQueryParamsWire = z.object({ page: z .strictObject({ diff --git a/api/spec/packages/aip-client-javascript/src/models/types.ts b/api/spec/packages/aip-client-javascript/src/models/types.ts index 42692a00f8..bc736de831 100644 --- a/api/spec/packages/aip-client-javascript/src/models/types.ts +++ b/api/spec/packages/aip-client-javascript/src/models/types.ts @@ -3925,7 +3925,7 @@ export interface UpdateInvoiceWorkflowSettings { /** Page paginated response. */ export interface CurrencyPagePaginatedResponse { - data: (CurrencyFiat | CurrencyCustom)[] + data: Currency[] meta: PaginatedMeta } @@ -5288,6 +5288,9 @@ export type RateCardEntitlement = | RateCardStaticEntitlement | RateCardBooleanEntitlement +/** Fiat or custom currency. */ +export type Currency = CurrencyFiat | CurrencyCustom + /** * Per-unit cost configuration for a feature. Either a fixed manual amount or a * dynamic LLM cost lookup. diff --git a/api/spec/packages/aip-client-javascript/src/sdk/internal.ts b/api/spec/packages/aip-client-javascript/src/sdk/internal.ts new file mode 100644 index 0000000000..1a27e79a10 --- /dev/null +++ b/api/spec/packages/aip-client-javascript/src/sdk/internal.ts @@ -0,0 +1,167 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + +import { type Client } from '../core.js' +import { unwrap, type RequestOptions } from '../lib/types.js' +import { paginatePages } from '../lib/paginate.js' +import { createSubscriptionAddon } from '../funcs/subscriptions.js' +import { + listCurrencies, + createCustomCurrency, + listCostBases, + createCostBasis, +} from '../funcs/currencies.js' +import type { + CreateSubscriptionAddonRequest, + CreateSubscriptionAddonResponse, +} from '../models/operations/subscriptions.js' +import type { + ListCurrenciesRequest, + ListCurrenciesResponse, + CreateCustomCurrencyRequest, + CreateCustomCurrencyResponse, + ListCostBasesRequest, + ListCostBasesResponse, + CreateCostBasisRequest, + CreateCostBasisResponse, +} from '../models/operations/currencies.js' +import type { CostBasis, Currency } from '../models/types.js' + +/** + * Operations marked internal in the API definition. They are not part of + * the customer surface: they may require additional permissions, and they + * can change or be removed without notice or semver consideration. + */ +export class Internal { + constructor(private readonly _client: Client) {} + + private _subscriptions?: InternalSubscriptions + get subscriptions(): InternalSubscriptions { + return (this._subscriptions ??= new InternalSubscriptions(this._client)) + } + + private _currencies?: InternalCurrencies + get currencies(): InternalCurrencies { + return (this._currencies ??= new InternalCurrencies(this._client)) + } +} + +export class InternalSubscriptions { + constructor(private readonly _client: Client) {} + + /** + * Create a new subscription add-on + * + * Add add-on to a subscription. + * + * POST /openmeter/subscriptions/{subscriptionId}/addons + */ + async createAddon( + request: CreateSubscriptionAddonRequest, + options?: RequestOptions, + ): Promise { + return unwrap(await createSubscriptionAddon(this._client, request, options)) + } +} + +export class InternalCurrencies { + constructor(private readonly _client: Client) {} + + /** + * List currencies + * + * List currencies supported by the billing system. + * + * GET /openmeter/currencies + */ + async list( + request?: ListCurrenciesRequest, + options?: RequestOptions, + ): Promise { + return unwrap(await listCurrencies(this._client, request, options)) + } + + /** + * List currencies + * + * List currencies supported by the billing system. + * + * Iterates every item across all pages, fetching more as the returned iterable is consumed. + * + * GET /openmeter/currencies + */ + listAll( + request?: ListCurrenciesRequest, + options?: RequestOptions, + ): AsyncIterable { + return paginatePages( + (req, opts) => listCurrencies(this._client, req, opts), + request ?? {}, + options, + ) + } + + /** + * Create custom currency + * + * Create a custom currency. This operation allows defining your own custom + * currency for billing purposes. + * + * POST /openmeter/currencies/custom + */ + async createCustomCurrency( + request: CreateCustomCurrencyRequest, + options?: RequestOptions, + ): Promise { + return unwrap(await createCustomCurrency(this._client, request, options)) + } + + /** + * List cost bases + * + * List cost bases for a currency. For custom currencies, there can be multiple + * cost bases with different `effective_from` dates. + * + * GET /openmeter/currencies/custom/{currencyId}/cost-bases + */ + async listCostBases( + request: ListCostBasesRequest, + options?: RequestOptions, + ): Promise { + return unwrap(await listCostBases(this._client, request, options)) + } + + /** + * List cost bases + * + * List cost bases for a currency. For custom currencies, there can be multiple + * cost bases with different `effective_from` dates. + * + * Iterates every item across all pages, fetching more as the returned iterable is consumed. + * + * GET /openmeter/currencies/custom/{currencyId}/cost-bases + */ + listCostBasesAll( + request: ListCostBasesRequest, + options?: RequestOptions, + ): AsyncIterable { + return paginatePages( + (req, opts) => listCostBases(this._client, req, opts), + request, + options, + ) + } + + /** + * Create cost basis + * + * Create a cost basis for a currency. + * + * POST /openmeter/currencies/custom/{currencyId}/cost-bases + */ + async createCostBasis( + request: CreateCostBasisRequest, + options?: RequestOptions, + ): Promise { + return unwrap(await createCostBasis(this._client, request, options)) + } +} diff --git a/api/spec/packages/aip-client-javascript/src/sdk/sdk.ts b/api/spec/packages/aip-client-javascript/src/sdk/sdk.ts index 9e2b71cc86..3ac360935e 100644 --- a/api/spec/packages/aip-client-javascript/src/sdk/sdk.ts +++ b/api/spec/packages/aip-client-javascript/src/sdk/sdk.ts @@ -15,6 +15,7 @@ import { Plans } from './plans.js' import { Addons } from './addons.js' import { PlanAddons } from './planAddons.js' import { Defaults } from './defaults.js' +import { Internal } from './internal.js' export class OpenMeter extends Client { private _events?: Events @@ -86,4 +87,15 @@ export class OpenMeter extends Client { get defaults(): Defaults { return (this._defaults ??= new Defaults(this)) } + + private _internal?: Internal + /** + * Operations marked internal in the API definition. They are not part + * of the customer surface: they may require additional permissions, + * and they can change or be removed without notice or semver + * consideration. + */ + get internal(): Internal { + return (this._internal ??= new Internal(this)) + } } diff --git a/api/spec/packages/aip-client-javascript/tests/internal.spec.ts b/api/spec/packages/aip-client-javascript/tests/internal.spec.ts new file mode 100644 index 0000000000..319b5b90f6 --- /dev/null +++ b/api/spec/packages/aip-client-javascript/tests/internal.spec.ts @@ -0,0 +1,71 @@ +// Code generated by @openmeter/typespec-typescript. DO NOT EDIT. + +import fetchMock from '@fetch-mock/vitest' +import { beforeEach, describe, expect, it } from 'vitest' +import { OpenMeter } from '../src/index.js' + +beforeEach(() => { + fetchMock.mockReset() +}) + +const fetch = fetchMock.fetchHandler + +function client(): OpenMeter { + return new OpenMeter({ + baseUrl: 'https://eu.api.konghq.com/v3', + apiKey: 'k', + fetch, + }) +} + +function lastUrl(): string { + return fetchMock.callHistory.lastCall()!.url +} + +describe('internal sub-client', () => { + it('is memoized and separate from the public surface', () => { + const sdk = client() + expect(sdk.internal).toBe(sdk.internal) + // x-internal operations must not leak onto the public sub-clients. + expect('createAddon' in sdk.subscriptions).toBe(false) + // Entirely-internal groups have no public getter at all. + expect('currencies' in sdk).toBe(false) + }) + + it('routes internal.subscriptions.createAddon() to the subscription addons resource', async () => { + fetchMock.route('*', { + body: { + id: '01ARZ3NDEKTSV4RRFFQ69G5FAV', + name: 'Addon', + addon: { id: '01ARZ3NDEKTSV4RRFFQ69G5FAW' }, + quantity: 1, + quantity_at: '2024-01-01T00:00:00Z', + active_from: '2024-01-01T00:00:00Z', + rate_cards: [], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + headers: { 'Content-Type': 'application/json' }, + }) + await client().internal.subscriptions.createAddon({ + subscriptionId: '01ARZ3NDEKTSV4RRFFQ69G5FAV', + body: { + addon: { id: '01ARZ3NDEKTSV4RRFFQ69G5FAW' }, + quantity: 1, + timing: 'immediate', + }, + }) + expect(lastUrl()).toBe( + 'https://eu.api.konghq.com/v3/openmeter/subscriptions/01ARZ3NDEKTSV4RRFFQ69G5FAV/addons', + ) + }) + + it('routes internal.currencies.list() to the currencies resource', async () => { + fetchMock.route('*', { + body: { data: [], meta: { page: { number: 1, size: 10, total: 0 } } }, + headers: { 'Content-Type': 'application/json' }, + }) + await client().internal.currencies.list() + expect(lastUrl()).toBe('https://eu.api.konghq.com/v3/openmeter/currencies') + }) +}) diff --git a/api/spec/packages/typespec-typescript/src/ZodOperations.tsx b/api/spec/packages/typespec-typescript/src/ZodOperations.tsx index 965511b4d0..fccab0de00 100644 --- a/api/spec/packages/typespec-typescript/src/ZodOperations.tsx +++ b/api/spec/packages/typespec-typescript/src/ZodOperations.tsx @@ -53,14 +53,18 @@ export interface OperationSchema { // Customer-visibility markers from the spec's shared/consts.tsp: x-private is // "private and should not be exposed to customers", x-internal is "internal and -// should not be used by customers". The published SDK is exactly the customer -// surface, so operations carrying either marker are omitted from it entirely -// (they remain in the OpenAPI output, which serves internal consumers too). -// x-unstable operations stay in: most of the young v3 API carries that marker, -// and it flags maturity, not audience. -const HIDDEN_EXTENSIONS: Array<`x-${string}`> = ['x-private', 'x-internal'] - -function isHiddenFromSdk(program: Program, op: Operation): boolean { +// should not be used by customers". The published SDK is the customer surface, +// so x-private operations are omitted from it entirely (they remain in the +// OpenAPI output, which serves internal consumers too). x-internal operations +// are emitted but quarantined under the `client.internal.*` sub-client so the +// audience split stays visible at every call site. x-unstable operations stay +// in the public surface: most of the young v3 API carries that marker, and it +// flags maturity, not audience. +function hasExtension( + program: Program, + op: Operation, + key: `x-${string}`, +): boolean { // The walked operation is an `extends`/`op is` instance; the @extension // decorators may live on it or on the source operation it was cloned from, // so the whole source chain is consulted. @@ -69,19 +73,34 @@ function isHiddenFromSdk(program: Program, op: Operation): boolean { current; current = current.sourceOperation ) { - const extensions = getExtensions(program, current) - if (HIDDEN_EXTENSIONS.some((key) => extensions.get(key) === true)) { + if (getExtensions(program, current).get(key) === true) { return true } } return false } +function isHiddenFromSdk(program: Program, op: Operation): boolean { + return hasExtension(program, op, 'x-private') +} + +/** + * Whether an operation is marked x-internal in the spec. Internal operations + * are emitted like any other (funcs, envelope types, zod schemas), but their + * grouped-client methods live under `client.internal.*` instead of the public + * sub-clients (see emitter.tsx). Operations that are both x-internal and + * x-private never reach this check — x-private drops them at collection. + */ +export function isInternalOperation(program: Program, op: Operation): boolean { + return hasExtension(program, op, 'x-internal') +} + /** * Collect every HTTP operation in the program, de-duplicated by the underlying * TypeSpec `Operation` (the same operation surfaces under multiple service * namespaces — OpenMeter and MeteringAndBilling — and must not be emitted - * twice). Operations marked x-private or x-internal never enter the SDK. + * twice). Operations marked x-private never enter the SDK; x-internal + * operations are collected and later routed to the `client.internal.*` surface. */ export function collectHttpOperations( program: Program, diff --git a/api/spec/packages/typespec-typescript/src/emitter.tsx b/api/spec/packages/typespec-typescript/src/emitter.tsx index aacd25ef7d..8690cdee02 100644 --- a/api/spec/packages/typespec-typescript/src/emitter.tsx +++ b/api/spec/packages/typespec-typescript/src/emitter.tsx @@ -19,7 +19,11 @@ import { Output, writeOutput } from '@typespec/emitter-framework' import { ZodSchemaDeclaration } from './components/ZodSchemaDeclaration.jsx' import { zod } from './external-packages/zod.js' import type { ZodEmitterOptions } from './lib.js' -import { collectHttpOperations, operationSchemas } from './ZodOperations.jsx' +import { + collectHttpOperations, + isInternalOperation, + operationSchemas, +} from './ZodOperations.jsx' import { resolveStrippedNames } from './strip-prefixes.js' import { newTopologicalTypeCollector, WireModeContext } from './utils.jsx' import { RUNTIME_TEMPLATES } from './runtime-templates.js' @@ -36,6 +40,7 @@ import { groupOperations, jsonBodyOverrides, sdkOperation, + type SdkOperation, } from './sdk-operations.js' import { requestTypesFor } from './request-types.js' import { readmeFile, type ReadmeResource } from './readme.js' @@ -44,6 +49,7 @@ import { funcsFile, funcsIndexFile, indexFile, + internalFile, namespaceFile, operationsAssertFile, operationsFile, @@ -171,6 +177,12 @@ export async function $onEmit(context: EmitContext) { const resources = [...groups.keys()] const sdkFiles: Array<{ path: string; content: string }> = [] const readmeResources: ReadmeResource[] = [] + // Groups' x-internal operations, destined for the `client.internal.*` + // surface. They share their group's funcs and operations modules with the + // public ops; only the facade classes are split. A group whose operations + // are all internal (e.g. currencies) gets no public facade at all. + const internalResources: ReadmeResource[] = [] + const publicResources: string[] = [] const paginationTemplates = findPaginationTemplates(context.program) // Names the operations modules export (`Request`/`Response`/ // `Query`), mirroring requestDecl/queryType/responseDecl in @@ -205,7 +217,14 @@ export async function $onEmit(context: EmitContext) { operationTypeNames.add(`${op.base}Query`) } } - readmeResources.push({ resource, ops: sdkOps }) + const publicOps: SdkOperation[] = [] + const internalOps: SdkOperation[] = [] + ops.forEach((op, i) => { + const target = isInternalOperation(context.program, op) + ? internalOps + : publicOps + target.push(sdkOps[i]!) + }) const file = namespaceFile(resource) const requestTypes = requestTypesFor( context.program, @@ -229,9 +248,22 @@ export async function $onEmit(context: EmitContext) { path: `src/funcs/${file}.ts`, content: funcsFile(resource, sdkOps), }) + if (publicOps.length > 0) { + publicResources.push(resource) + readmeResources.push({ resource, ops: publicOps }) + sdkFiles.push({ + path: `src/sdk/${file}.ts`, + content: facadeFile(resource, publicOps), + }) + } + if (internalOps.length > 0) { + internalResources.push({ resource, ops: internalOps }) + } + } + if (internalResources.length > 0) { sdkFiles.push({ - path: `src/sdk/${file}.ts`, - content: facadeFile(resource, sdkOps), + path: 'src/sdk/internal.ts', + content: internalFile(internalResources), }) } sdkFiles.push({ path: 'src/lib/wire.ts', content: WIRE_RUNTIME }) @@ -244,10 +276,18 @@ export async function $onEmit(context: EmitContext) { path: 'src/funcs/index.ts', content: funcsIndexFile(resources), }) - sdkFiles.push({ path: 'src/sdk/sdk.ts', content: sdkRootFile(resources) }) + sdkFiles.push({ + path: 'src/sdk/sdk.ts', + content: sdkRootFile(publicResources, internalResources.length > 0), + }) sdkFiles.push({ path: 'src/index.ts', - content: indexFile(resources, interfaces.typeNames, operationTypeNames), + content: indexFile( + publicResources, + resources, + interfaces.typeNames, + operationTypeNames, + ), }) sdkFiles.push({ path: 'README.md', @@ -255,6 +295,7 @@ export async function $onEmit(context: EmitContext) { readmeResources, context.options['package-name'], context.options['readme-note'], + internalResources, ), }) diff --git a/api/spec/packages/typespec-typescript/src/readme.ts b/api/spec/packages/typespec-typescript/src/readme.ts index eac20a6390..7d4eff39d2 100644 --- a/api/spec/packages/typespec-typescript/src/readme.ts +++ b/api/spec/packages/typespec-typescript/src/readme.ts @@ -61,6 +61,7 @@ const HEADINGS = { usage: 'Usage', pagination: 'Pagination', resources: 'Available Resources and Operations', + internal: 'Internal Operations', validate: 'Runtime Validation (validate option)', zod: 'Zod Schemas (./zod export)', errors: 'Error Handling', @@ -81,7 +82,10 @@ function header(note?: string): string { return lines.join('\n') } -function tableOfContents(resources: ReadmeResource[]): string { +function tableOfContents( + resources: ReadmeResource[], + internalResources: ReadmeResource[], +): string { const lines = [`## ${HEADINGS.toc}`, ''] lines.push(tocEntry(HEADINGS.install, 0)) lines.push(tocEntry(HEADINGS.init, 0)) @@ -93,6 +97,12 @@ function tableOfContents(resources: ReadmeResource[]): string { const { class: cls } = namespaceNames(resource) lines.push(tocEntry(cls, 1)) } + if (internalResources.length > 0) { + lines.push(tocEntry(HEADINGS.internal, 0)) + for (const { resource } of internalResources) { + lines.push(tocEntry(internalResourceHeading(resource), 1)) + } + } lines.push(tocEntry(HEADINGS.validate, 0)) lines.push(tocEntry(HEADINGS.zod, 0)) lines.push(tocEntry(HEADINGS.errors, 0)) @@ -343,6 +353,33 @@ function resourcesSection(resources: ReadmeResource[]): string { return blocks.join('\n') } +/** Sub-heading for an internal resource group. Prefixed so it never collides + * with the same resource's public section anchor. */ +function internalResourceHeading(resource: string): string { + return `Internal ${namespaceNames(resource).class}` +} + +function internalSection(resources: ReadmeResource[]): string { + const blocks = [ + `## ${HEADINGS.internal}`, + '', + 'Operations marked internal in the API definition are exposed under', + '`client.internal.*`, quarantined from the customer surface. They are not', + 'intended for customer use: they may require additional permissions, and', + 'they can change or be removed without notice or semver consideration.', + ] + for (const { resource, ops } of resources) { + const { getter } = namespaceNames(resource) + blocks.push( + '', + `### ${internalResourceHeading(resource)}`, + '', + operationsTable(`internal.${getter}`, ops), + ) + } + return blocks.join('\n') +} + function runtimeValidation(packageName: string): string { return [ `## ${HEADINGS.validate}`, @@ -534,17 +571,21 @@ export function readmeFile( resources: ReadmeResource[], packageName: string, note?: string, + internalResources: ReadmeResource[] = [], ): string { return ( [ header(note), - tableOfContents(resources), + tableOfContents(resources, internalResources), installation(packageName), initialization(packageName), configuration(packageName), usage(packageName), pagination(packageName), resourcesSection(resources), + ...(internalResources.length > 0 + ? [internalSection(internalResources)] + : []), runtimeValidation(packageName), zodSchemas(packageName), errorHandling(packageName), diff --git a/api/spec/packages/typespec-typescript/src/runtime-templates.ts b/api/spec/packages/typespec-typescript/src/runtime-templates.ts index da5bb5d0e2..8bd96f3bbd 100644 --- a/api/spec/packages/typespec-typescript/src/runtime-templates.ts +++ b/api/spec/packages/typespec-typescript/src/runtime-templates.ts @@ -25,6 +25,7 @@ const RUNTIME_FILES: Record = { 'tests/meters.spec.ts': 'tests/meters.spec.ts', 'tests/errors.spec.ts': 'tests/errors.spec.ts', 'tests/nesting.spec.ts': 'tests/nesting.spec.ts', + 'tests/internal.spec.ts': 'tests/internal.spec.ts', } export const RUNTIME_TEMPLATES: Record = Object.fromEntries( diff --git a/api/spec/packages/typespec-typescript/src/sdk-files.ts b/api/spec/packages/typespec-typescript/src/sdk-files.ts index f0bc7bce37..b9417a63ed 100644 --- a/api/spec/packages/typespec-typescript/src/sdk-files.ts +++ b/api/spec/packages/typespec-typescript/src/sdk-files.ts @@ -360,11 +360,8 @@ function emitNode(node: FacadeNode): string[] { return [klass, ...childClasses] } -export function facadeFile(tag: string, ops: SdkOperation[]): string { - const { class: cls } = namespaceNames(tag) - const file = namespaceFile(tag) - - const root: FacadeNode = { className: cls, ops: [], children: new Map() } +function buildFacadeRoot(className: string, ops: SdkOperation[]): FacadeNode { + const root: FacadeNode = { className, ops: [], children: new Map() } for (const op of ops) { let node = root for (const segment of op.nestPath) { @@ -378,29 +375,28 @@ export function facadeFile(tag: string, ops: SdkOperation[]): string { } node.ops.push(op) } + return root +} - const classes = emitNode(root).join('\n\n') - const funcImports = ops.map((op) => ` ${op.funcName},`).join('\n') - const typeImports = ops - .flatMap((op) => [` ${op.base}Request,`, ` ${op.base}Response,`]) - .join('\n') - - // Pagination helper imports: only the styles this resource actually uses, - // named deterministically (not by Set iteration order) so a second - // `generate` run byte-matches the first. +// Pagination helper imports: only the styles the emitted ops actually use, +// named deterministically (not by Set iteration order) so a second +// `generate` run byte-matches the first. +function paginationImportLines(ops: SdkOperation[]): string[] { const paginationHelpers = [ ...new Set(ops.map((op) => op.pagination?.style).filter(Boolean)), ].sort() - const paginationImport = - paginationHelpers.length > 0 - ? [ - `import {`, - ...paginationHelpers.map((style) => - style === 'page' ? ` paginatePages,` : ` paginateCursor,`, - ), - `} from '../lib/paginate.js'`, - ] - : [] + return paginationHelpers.length > 0 + ? [ + `import {`, + ...paginationHelpers.map((style) => + style === 'page' ? ` paginatePages,` : ` paginateCursor,`, + ), + `} from '../lib/paginate.js'`, + ] + : [] +} + +function itemInterfaceImportLines(ops: SdkOperation[]): string[] { const itemInterfaces = [ ...new Set( ops @@ -408,58 +404,159 @@ export function facadeFile(tag: string, ops: SdkOperation[]): string { .filter((name): name is string => Boolean(name)), ), ].sort() - const itemInterfaceImport = - itemInterfaces.length > 0 - ? [ - `import type {`, - ...itemInterfaces.map((name) => ` ${name},`), - `} from '../models/types.js'`, - ] - : [] + return itemInterfaces.length > 0 + ? [ + `import type {`, + ...itemInterfaces.map((name) => ` ${name},`), + `} from '../models/types.js'`, + ] + : [] +} + +export function facadeFile(tag: string, ops: SdkOperation[]): string { + const { class: cls } = namespaceNames(tag) + const file = namespaceFile(tag) + + const root = buildFacadeRoot(cls, ops) + + const classes = emitNode(root).join('\n\n') + const funcImports = ops.map((op) => ` ${op.funcName},`).join('\n') + const typeImports = ops + .flatMap((op) => [` ${op.base}Request,`, ` ${op.base}Response,`]) + .join('\n') return [ `import { type Client } from '../core.js'`, `import { unwrap, type RequestOptions } from '../lib/types.js'`, - ...paginationImport, + ...paginationImportLines(ops), `import {`, funcImports, `} from '../funcs/${file}.js'`, `import type {`, typeImports, `} from '../models/operations/${file}.js'`, - ...itemInterfaceImport, + ...itemInterfaceImportLines(ops), ``, classes, ``, ].join('\n') } -export function sdkRootFile(tags: string[]): string { +/** + * The `client.internal.*` surface: one file holding the `Internal` aggregate + * plus an `Internal` facade class per resource that has x-internal + * operations. Internal ops share their group's funcs and operation-envelope + * modules with the public surface — only the grouped-client entry point is + * quarantined, so the audience split is visible at every call site. + */ +export function internalFile( + groups: Array<{ resource: string; ops: SdkOperation[] }>, +): string { + const roots = groups.map(({ resource, ops }) => ({ + resource, + ops, + root: buildFacadeRoot(`Internal${namespaceNames(resource).class}`, ops), + })) + + const getters = roots + .map(({ resource, root }) => { + const { getter } = namespaceNames(resource) + return [ + ` private _${getter}?: ${root.className}`, + ` get ${getter}(): ${root.className} {`, + ` return (this._${getter} ??= new ${root.className}(this._client))`, + ` }`, + ].join('\n') + }) + .join('\n\n') + + const internalClass = [ + `/**`, + ` * Operations marked internal in the API definition. They are not part of`, + ` * the customer surface: they may require additional permissions, and they`, + ` * can change or be removed without notice or semver consideration.`, + ` */`, + `export class Internal {`, + ` constructor(private readonly _client: Client) {}`, + ``, + getters, + `}`, + ].join('\n') + + const groupClasses = roots.flatMap(({ root }) => emitNode(root)) + + const allOps = groups.flatMap(({ ops }) => ops) + const funcImports = roots.map(({ resource, ops }) => + [ + `import {`, + ops.map((op) => ` ${op.funcName},`).join('\n'), + `} from '../funcs/${namespaceFile(resource)}.js'`, + ].join('\n'), + ) + const typeImports = roots.map(({ resource, ops }) => + [ + `import type {`, + ops + .flatMap((op) => [` ${op.base}Request,`, ` ${op.base}Response,`]) + .join('\n'), + `} from '../models/operations/${namespaceFile(resource)}.js'`, + ].join('\n'), + ) + + return [ + `import { type Client } from '../core.js'`, + `import { unwrap, type RequestOptions } from '../lib/types.js'`, + ...paginationImportLines(allOps), + ...funcImports, + ...typeImports, + ...itemInterfaceImportLines(allOps), + ``, + [internalClass, ...groupClasses].join('\n\n'), + ``, + ].join('\n') +} + +export function sdkRootFile(tags: string[], hasInternal: boolean): string { const imports = [ `import { Client } from '../core.js'`, ...tags.map((tag) => { const { class: cls } = namespaceNames(tag) return `import { ${cls} } from './${namespaceFile(tag)}.js'` }), + ...(hasInternal ? [`import { Internal } from './internal.js'`] : []), ].join('\n') - const members = tags - .map((tag) => { - const { class: cls, getter } = namespaceNames(tag) - return [ - ` private _${getter}?: ${cls}`, - ` get ${getter}(): ${cls} {`, - ` return (this._${getter} ??= new ${cls}(this))`, + const members = tags.map((tag) => { + const { class: cls, getter } = namespaceNames(tag) + return [ + ` private _${getter}?: ${cls}`, + ` get ${getter}(): ${cls} {`, + ` return (this._${getter} ??= new ${cls}(this))`, + ` }`, + ].join('\n') + }) + if (hasInternal) { + members.push( + [ + ` private _internal?: Internal`, + ` /**`, + ` * Operations marked internal in the API definition. They are not part`, + ` * of the customer surface: they may require additional permissions,`, + ` * and they can change or be removed without notice or semver`, + ` * consideration.`, + ` */`, + ` get internal(): Internal {`, + ` return (this._internal ??= new Internal(this))`, ` }`, - ].join('\n') - }) - .join('\n\n') + ].join('\n'), + ) + } return [ imports, ``, `export class OpenMeter extends Client {`, - members, + members.join('\n\n'), `}`, ``, ].join('\n') @@ -473,17 +570,24 @@ export function funcsIndexFile(tags: string[]): string { } export function indexFile( - tags: string[], + publicTags: string[], + allTags: string[], modelTypeNames: string[], operationTypeNames: Set, ): string { - const namespaceExports = tags + // Facade classes are exported for the public groups only. The internal + // aggregate class is deliberately NOT re-exported at the package root: the + // surface is reached through `client.internal` (like the nested sub-client + // classes), and the name `Internal` is already taken by the error model. + // Operation envelope types are exported for every group — internal ops + // share their group's operations module with the public surface. + const namespaceExports = publicTags .map((tag) => { const { class: cls } = namespaceNames(tag) return `export { ${cls} } from './sdk/${namespaceFile(tag)}.js'` }) .join('\n') - const operationTypeExports = tags + const operationTypeExports = allTags .map( (tag) => `export type * from './models/operations/${namespaceFile(tag)}.js'`, diff --git a/api/spec/packages/typespec-typescript/templates/tests/internal.spec.ts b/api/spec/packages/typespec-typescript/templates/tests/internal.spec.ts new file mode 100644 index 0000000000..170274617c --- /dev/null +++ b/api/spec/packages/typespec-typescript/templates/tests/internal.spec.ts @@ -0,0 +1,69 @@ +import fetchMock from '@fetch-mock/vitest' +import { beforeEach, describe, expect, it } from 'vitest' +import { OpenMeter } from '../src/index.js' + +beforeEach(() => { + fetchMock.mockReset() +}) + +const fetch = fetchMock.fetchHandler + +function client(): OpenMeter { + return new OpenMeter({ + baseUrl: 'https://eu.api.konghq.com/v3', + apiKey: 'k', + fetch, + }) +} + +function lastUrl(): string { + return fetchMock.callHistory.lastCall()!.url +} + +describe('internal sub-client', () => { + it('is memoized and separate from the public surface', () => { + const sdk = client() + expect(sdk.internal).toBe(sdk.internal) + // x-internal operations must not leak onto the public sub-clients. + expect('createAddon' in sdk.subscriptions).toBe(false) + // Entirely-internal groups have no public getter at all. + expect('currencies' in sdk).toBe(false) + }) + + it('routes internal.subscriptions.createAddon() to the subscription addons resource', async () => { + fetchMock.route('*', { + body: { + id: '01ARZ3NDEKTSV4RRFFQ69G5FAV', + name: 'Addon', + addon: { id: '01ARZ3NDEKTSV4RRFFQ69G5FAW' }, + quantity: 1, + quantity_at: '2024-01-01T00:00:00Z', + active_from: '2024-01-01T00:00:00Z', + rate_cards: [], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + headers: { 'Content-Type': 'application/json' }, + }) + await client().internal.subscriptions.createAddon({ + subscriptionId: '01ARZ3NDEKTSV4RRFFQ69G5FAV', + body: { + addon: { id: '01ARZ3NDEKTSV4RRFFQ69G5FAW' }, + quantity: 1, + timing: 'immediate', + }, + }) + expect(lastUrl()).toBe( + 'https://eu.api.konghq.com/v3/openmeter/subscriptions/01ARZ3NDEKTSV4RRFFQ69G5FAV/addons', + ) + }) + + it('routes internal.currencies.list() to the currencies resource', async () => { + fetchMock.route('*', { + body: { data: [], meta: { page: { number: 1, size: 10, total: 0 } } }, + headers: { 'Content-Type': 'application/json' }, + }) + await client().internal.currencies.list() + expect(lastUrl()).toBe('https://eu.api.konghq.com/v3/openmeter/currencies') + }) +}) From 3c458972d1862179c9223e86b82a4bdebe6c6bb4 Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:25:06 +0200 Subject: [PATCH 4/5] fix(api): await writeOutput in the TypeScript SDK emitter writeOutput returns a promise the emitter never awaited, so $onEmit resolved before the generated files landed on the host. The tsp CLI masks the race because the process outlives the pending writes, but in-memory compilation (the emitter test harness) observes a partially written output directory. --- api/spec/packages/typespec-typescript/src/emitter.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/spec/packages/typespec-typescript/src/emitter.tsx b/api/spec/packages/typespec-typescript/src/emitter.tsx index 8690cdee02..5df7e461a8 100644 --- a/api/spec/packages/typespec-typescript/src/emitter.tsx +++ b/api/spec/packages/typespec-typescript/src/emitter.tsx @@ -305,7 +305,11 @@ export async function $onEmit(context: EmitContext) { const generatedComment = 'Code generated by @openmeter/typespec-typescript. DO NOT EDIT.' - writeOutput( + // writeOutput is async; without the await, $onEmit resolves before the + // files land on the host — the tsp CLI masks this (the process outlives the + // pending writes), but in-memory compilation (the emitter test harness) + // observes a partially written output directory. + await writeOutput( context.program, Date: Sat, 11 Jul 2026 21:25:43 +0200 Subject: [PATCH 5/5] test(api): add an in-memory compile harness for the TS SDK emitter The emitter previously had no way to test generator behavior before regenerating the real client: the conformance specs run against the committed aip-client-javascript output only. test/emit.ts builds an EmitterTester (createTester from @typespec/compiler/testing) that compiles a fixture spec in-memory, runs the built emitter through the compiler's real emit pipeline, and returns the emitted files for assertion. test/internal-surface.test.ts uses it to pin the customer-visibility partitioning: x-private operations are dropped (alone or combined with x-internal), x-internal operations are quarantined under client.internal.* with pagination companions, entirely-internal groups get no public facade or getter, funcs and operation envelope modules are shared between surfaces, and the README documents the internal section. The package test script now builds first, since the tester resolves the emitter through its package.json entry point (dist/). The harness runs in CI via make -C api/spec test in the aip-npm-release workflow. --- api/spec/AGENTS.md | 29 +++ .../packages/typespec-typescript/package.json | 2 +- .../packages/typespec-typescript/test/emit.ts | 20 ++ .../test/internal-surface.test.ts | 174 ++++++++++++++++++ 4 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 api/spec/packages/typespec-typescript/test/emit.ts create mode 100644 api/spec/packages/typespec-typescript/test/internal-surface.test.ts diff --git a/api/spec/AGENTS.md b/api/spec/AGENTS.md index 8df8fecc44..b787896be6 100644 --- a/api/spec/AGENTS.md +++ b/api/spec/AGENTS.md @@ -686,6 +686,35 @@ 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` +(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 +partitioning) 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 diff --git a/api/spec/packages/typespec-typescript/package.json b/api/spec/packages/typespec-typescript/package.json index b23c337bed..9f6271fcf7 100644 --- a/api/spec/packages/typespec-typescript/package.json +++ b/api/spec/packages/typespec-typescript/package.json @@ -14,7 +14,7 @@ "build": "alloy build", "watch": "alloy build --watch", "typecheck": "tsc --noEmit", - "test": "vitest --run", + "test": "alloy build && vitest --run", "check": "pnpm run typecheck && pnpm run test" }, "dependencies": { diff --git a/api/spec/packages/typespec-typescript/test/emit.ts b/api/spec/packages/typespec-typescript/test/emit.ts new file mode 100644 index 0000000000..33a1b9ba00 --- /dev/null +++ b/api/spec/packages/typespec-typescript/test/emit.ts @@ -0,0 +1,20 @@ +import { fileURLToPath } from 'node:url' +import { createTester } from '@typespec/compiler/testing' + +// The tester resolves the emitter through its package.json entry point, so +// tests exercise the BUILT emitter (dist/) end to end through the compiler's +// real emit pipeline — the `test` script builds first to keep dist fresh. +const packageRoot = fileURLToPath(new URL('..', import.meta.url)) + +const Tester = createTester(packageRoot, { + libraries: [ + '@typespec/http', + '@typespec/openapi', + '@openmeter/typespec-typescript', + ], +}) + +/** Compiles a fixture spec and returns the emitted SDK files keyed by path. */ +export const EmitterTester = Tester.emit('@openmeter/typespec-typescript', { + 'package-name': '@test/client', +}) diff --git a/api/spec/packages/typespec-typescript/test/internal-surface.test.ts b/api/spec/packages/typespec-typescript/test/internal-surface.test.ts new file mode 100644 index 0000000000..3297a83d00 --- /dev/null +++ b/api/spec/packages/typespec-typescript/test/internal-surface.test.ts @@ -0,0 +1,174 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { EmitterTester } from './emit.js' + +// A minimal spec exercising the customer-visibility markers, authored with the +// same extends pattern the real spec uses (grouping resolves through the +// source interface's namespace). Widgets mixes public and internal operations; +// Gadgets is entirely internal; delete-widget is private and update-widget +// carries both markers (private wins). +const FIXTURE = ` +import "@typespec/http"; +import "@typespec/openapi"; + +using TypeSpec.Http; +using TypeSpec.OpenAPI; + +namespace Shared { + model PagePaginatedResponse { + data: T[]; + meta: PageMeta; + } + model PageMeta { + page: PageInfo; + } + model PageInfo { + number: int32; + size: int32; + total: int32; + } +} + +namespace Widgets { + model Widget { + id: string; + name: string; + } + + interface WidgetOperations { + @get + @operationId("list-widgets") + list(): Shared.PagePaginatedResponse; + + @post + @operationId("create-widget") + @extension("x-internal", true) + create(@body body: Widget): Widget; + + @delete + @operationId("delete-widget") + @extension("x-private", true) + delete(@path widgetId: string): void; + + @put + @operationId("update-widget") + @extension("x-internal", true) + @extension("x-private", true) + update(@path widgetId: string, @body body: Widget): Widget; + } +} + +namespace Gadgets { + model Gadget { + id: string; + } + + interface GadgetOperations { + @get + @operationId("list-gadgets") + @extension("x-internal", true) + list(): Shared.PagePaginatedResponse; + } +} + +@service(#{ title: "Test API" }) +namespace Api { + @route("/widgets") + interface WidgetEndpoints extends Widgets.WidgetOperations {} + + @route("/gadgets") + interface GadgetEndpoints extends Gadgets.GadgetOperations {} +} +` + +describe('internal operation surface', () => { + let outputs: Record + + const file = (path: string): string => { + const content = outputs[path] + expect(content, `expected emitted file ${path}`).toBeDefined() + return content! + } + + beforeAll(async () => { + const [result, diagnostics] = + await EmitterTester.compileAndDiagnose(FIXTURE) + expect( + diagnostics.filter((d) => d.severity === 'error'), + 'fixture must compile without errors', + ).toEqual([]) + outputs = result.outputs + }) + + it('drops x-private operations entirely, even when also marked x-internal', () => { + for (const content of Object.values(outputs)) { + expect(content).not.toContain('deleteWidget') + expect(content).not.toContain('updateWidget') + } + }) + + it('keeps internal operations out of the public facade', () => { + const widgets = file('src/sdk/widgets.ts') + expect(widgets).toContain('export class Widgets {') + expect(widgets).toContain('async list(') + expect(widgets).toContain('listAll(') + expect(widgets).not.toContain('create') + }) + + it('quarantines internal operations under Internal facades', () => { + const internal = file('src/sdk/internal.ts') + expect(internal).toContain('export class Internal {') + expect(internal).toContain('get widgets(): InternalWidgets {') + expect(internal).toContain('get gadgets(): InternalGadgets {') + expect(internal).toContain('export class InternalWidgets {') + expect(internal).toContain('async create(') + expect(internal).toContain('export class InternalGadgets {') + expect(internal).toContain('async list(') + // Pagination companions are emitted for internal operations too. + expect(internal).toContain('listAll(') + }) + + it('emits no public facade or getter for an entirely-internal group', () => { + expect(outputs['src/sdk/gadgets.ts']).toBeUndefined() + const sdk = file('src/sdk/sdk.ts') + expect(sdk).not.toContain('get gadgets') + const index = file('src/index.ts') + expect(index).not.toContain('export { Gadgets }') + }) + + it('exposes the internal aggregate through the client but not the package root', () => { + const sdk = file('src/sdk/sdk.ts') + expect(sdk).toContain("import { Internal } from './internal.js'") + expect(sdk).toContain('get internal(): Internal {') + const index = file('src/index.ts') + expect(index).toContain("export { Widgets } from './sdk/widgets.js'") + expect(index).not.toContain('export { Internal }') + }) + + it('shares funcs and operation envelope modules between surfaces', () => { + expect(file('src/funcs/widgets.ts')).toContain( + 'export function createWidget(', + ) + expect(file('src/funcs/gadgets.ts')).toContain( + 'export function listGadgets(', + ) + expect(file('src/funcs/index.ts')).toContain("export * from './gadgets.js'") + expect(file('src/models/operations/widgets.ts')).toContain( + 'CreateWidgetRequest', + ) + const index = file('src/index.ts') + expect(index).toContain( + "export type * from './models/operations/gadgets.js'", + ) + }) + + it('documents the internal surface in a dedicated README section', () => { + const readme = file('README.md') + expect(readme).toContain('## Internal Operations') + expect(readme).toContain('`client.internal.widgets.create`') + expect(readme).toContain('### Internal Gadgets') + expect(readme).toContain('`client.internal.gadgets.list`') + // The public table lists only the public operation. + expect(readme).toContain('`client.widgets.list`') + expect(readme).not.toContain('`client.widgets.create`') + }) +})