diff --git a/api/spec/packages/aip-client-javascript/src/core.ts b/api/spec/packages/aip-client-javascript/src/core.ts
index 37464ed34d..0a6efc7512 100644
--- a/api/spec/packages/aip-client-javascript/src/core.ts
+++ b/api/spec/packages/aip-client-javascript/src/core.ts
@@ -29,10 +29,15 @@ 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')) {
+ // User-Agent is settable on the web but is not CORS-safelisted, so
+ // setting it would preflight otherwise-simple cross-origin requests.
+ // Gate on a positive Node check, not `window`: workers are also
+ // CORS-bound yet have no `window`.
+ if (
+ typeof process !== 'undefined' &&
+ process.versions?.node != null &&
+ !request.headers.has('User-Agent')
+ ) {
request.headers.set('User-Agent', `openmeter-node/${SDK_VERSION}`)
}
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 26711babd6..03f5ba19d9 100644
--- a/api/spec/packages/aip-client-javascript/src/models/schemas.ts
+++ b/api/spec/packages/aip-client-javascript/src/models/schemas.ts
@@ -163,10 +163,8 @@ export const cursorMetaPage = z
.object({
first: z.string().optional().describe('URI to the first page.'),
last: z.string().optional().describe('URI to the last page.'),
- next: z.union([z.string(), z.null()]).describe('URI to the next page.'),
- previous: z
- .union([z.string(), z.null()])
- .describe('URI to the previous page.'),
+ next: z.string().nullable().describe('URI to the next page.'),
+ previous: z.string().nullable().describe('URI to the previous page.'),
size: z.number().int().describe('Requested page size.'),
})
.describe('Cursor pagination metadata.')
@@ -1829,14 +1827,16 @@ export const event = z
'Contains a value describing the type of event related to the originating occurrence.',
),
datacontenttype: z
- .union([z.literal('application/json'), z.null()])
+ .literal('application/json')
+ .nullable()
.optional()
.describe(
'Content type of the CloudEvents data value. Only the value "application/json" is allowed over HTTP.',
),
dataschema: z
- .union([z.string(), z.null()])
+ .string()
+ .nullable()
.optional()
.describe('Identifies the schema that data adheres to.'),
subject: z
@@ -1846,15 +1846,16 @@ export const event = z
.describe(
'Describes the subject of the event in the context of the event producer (identified by source).',
),
- time: z
- .union([dateTime, z.null()])
+ time: dateTime
+ .nullable()
.optional()
.describe(
'Timestamp of when the occurrence happened. Must adhere to RFC 3339.',
),
data: z
- .union([z.record(z.string(), z.unknown()), z.null()])
+ .record(z.string(), z.unknown())
+ .nullable()
.optional()
.describe(
@@ -1978,8 +1979,8 @@ export const createCostBasisRequest = z
export const featureCostQueryRow = z
.object({
usage: numeric,
- cost: z
- .union([numeric, z.null()])
+ cost: numeric
+ .nullable()
.describe(
'The computed cost amount (usage × unit cost). Null when pricing is not available for the given combination of dimensions.',
@@ -4797,8 +4798,8 @@ export const createFeatureRequest = z
export const updateFeatureRequest = z
.object({
- unitCost: z
- .union([featureUnitCost, z.null()])
+ unitCost: featureUnitCost
+ .nullable()
.optional()
.describe(
@@ -6844,10 +6845,8 @@ export const cursorMetaPageWire = z
.strictObject({
first: z.string().optional().describe('URI to the first page.'),
last: z.string().optional().describe('URI to the last page.'),
- next: z.union([z.string(), z.null()]).describe('URI to the next page.'),
- previous: z
- .union([z.string(), z.null()])
- .describe('URI to the previous page.'),
+ next: z.string().nullable().describe('URI to the next page.'),
+ previous: z.string().nullable().describe('URI to the previous page.'),
size: z.number().int().describe('Requested page size.'),
})
.describe('Cursor pagination metadata.')
@@ -8503,14 +8502,16 @@ export const eventWire = z
'Contains a value describing the type of event related to the originating occurrence.',
),
datacontenttype: z
- .union([z.literal('application/json'), z.null()])
+ .literal('application/json')
+ .nullable()
.optional()
.describe(
'Content type of the CloudEvents data value. Only the value "application/json" is allowed over HTTP.',
),
dataschema: z
- .union([z.string(), z.null()])
+ .string()
+ .nullable()
.optional()
.describe('Identifies the schema that data adheres to.'),
subject: z
@@ -8520,15 +8521,16 @@ export const eventWire = z
.describe(
'Describes the subject of the event in the context of the event producer (identified by source).',
),
- time: z
- .union([dateTimeWire, z.null()])
+ time: dateTimeWire
+ .nullable()
.optional()
.describe(
'Timestamp of when the occurrence happened. Must adhere to RFC 3339.',
),
data: z
- .union([z.record(z.string(), z.unknown()), z.null()])
+ .record(z.string(), z.unknown())
+ .nullable()
.optional()
.describe(
@@ -8652,8 +8654,8 @@ export const createCostBasisRequestWire = z
export const featureCostQueryRowWire = z
.strictObject({
usage: numericWire,
- cost: z
- .union([numericWire, z.null()])
+ cost: numericWire
+ .nullable()
.describe(
'The computed cost amount (usage × unit cost). Null when pricing is not available for the given combination of dimensions.',
@@ -11460,8 +11462,8 @@ export const createFeatureRequestWire = z
export const updateFeatureRequestWire = z
.strictObject({
- unit_cost: z
- .union([featureUnitCostWire, z.null()])
+ unit_cost: featureUnitCostWire
+ .nullable()
.optional()
.describe(
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 76118fd80c..260cbbc599 100644
--- a/api/spec/packages/aip-client-javascript/tests/client.spec.ts
+++ b/api/spec/packages/aip-client-javascript/tests/client.spec.ts
@@ -1,7 +1,7 @@
// Code generated by @openmeter/typespec-typescript. DO NOT EDIT.
import fetchMock from '@fetch-mock/vitest'
-import { beforeEach, describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { OpenMeter, ServerList } from '../src/index.js'
import { SDK_VERSION } from '../src/lib/version.js'
@@ -130,6 +130,10 @@ describe('option clobbering is prevented', () => {
})
describe('SDK telemetry headers', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
it('sets a default User-Agent header', async () => {
mockMeter()
const sdk = new OpenMeter({
@@ -152,6 +156,48 @@ describe('SDK telemetry headers', () => {
await sdk.meters.get({ meterId: 'm' })
expect(lastUserAgent()).toBe('custom-agent/1.0')
})
+
+ it('does not set a default User-Agent in browsers', async () => {
+ vi.stubGlobal('window', {})
+ vi.stubGlobal('process', undefined)
+ mockMeter()
+ const sdk = new OpenMeter({
+ baseUrl: 'https://eu.api.konghq.com/v3',
+ apiKey: 'k',
+ fetch,
+ })
+ await sdk.meters.get({ meterId: 'm' })
+ expect(lastUserAgent()).toBeNull()
+ })
+
+ // Workers are CORS-bound but have no `window`, so a `window`-based gate would
+ // leak the header here and preflight the request.
+ it('does not set a default User-Agent in workers', async () => {
+ vi.stubGlobal('self', {})
+ vi.stubGlobal('process', undefined)
+ mockMeter()
+ const sdk = new OpenMeter({
+ baseUrl: 'https://eu.api.konghq.com/v3',
+ apiKey: 'k',
+ fetch,
+ })
+ await sdk.meters.get({ meterId: 'm' })
+ expect(lastUserAgent()).toBeNull()
+ })
+
+ it('honors a caller-provided User-Agent in browsers', async () => {
+ vi.stubGlobal('window', {})
+ vi.stubGlobal('process', undefined)
+ 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', () => {
diff --git a/api/spec/packages/typespec-typescript/src/zodBaseSchema.tsx b/api/spec/packages/typespec-typescript/src/zodBaseSchema.tsx
index 395c2f909e..b02103fcc4 100644
--- a/api/spec/packages/typespec-typescript/src/zodBaseSchema.tsx
+++ b/api/spec/packages/typespec-typescript/src/zodBaseSchema.tsx
@@ -384,6 +384,42 @@ function unionBaseType(type: Union) {
const discriminated = $.union.getDiscriminatedUnion(type)
+ if ($.union.isExpression(type)) {
+ const variants = Array.from(type.variants.values())
+ const nonNullVariants = variants.filter(
+ (variant) =>
+ variant.type.kind !== 'Intrinsic' || variant.type.name !== 'null',
+ )
+
+ if (
+ nonNullVariants.length > 0 &&
+ nonNullVariants.length < variants.length
+ ) {
+ const inner =
+ nonNullVariants.length === 1 ? (
+
+ ) : (
+ zodMemberExpr(
+ callPart(
+ 'union',
+
+
+ {(variant) => }
+
+ ,
+ ),
+ )
+ )
+
+ return (
+
+ {inner}
+ {callPart('nullable')}
+
+ )
+ }
+ }
+
if ($.union.isExpression(type) || !discriminated) {
return zodMemberExpr(
callPart(
diff --git a/api/spec/packages/typespec-typescript/templates/runtime/core.ts b/api/spec/packages/typespec-typescript/templates/runtime/core.ts
index b02882640f..88c9b8c163 100644
--- a/api/spec/packages/typespec-typescript/templates/runtime/core.ts
+++ b/api/spec/packages/typespec-typescript/templates/runtime/core.ts
@@ -27,10 +27,15 @@ 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')) {
+ // User-Agent is settable on the web but is not CORS-safelisted, so
+ // setting it would preflight otherwise-simple cross-origin requests.
+ // Gate on a positive Node check, not `window`: workers are also
+ // CORS-bound yet have no `window`.
+ if (
+ typeof process !== 'undefined' &&
+ process.versions?.node != null &&
+ !request.headers.has('User-Agent')
+ ) {
request.headers.set('User-Agent', `openmeter-node/${SDK_VERSION}`)
}
diff --git a/api/spec/packages/typespec-typescript/templates/tests/client.spec.ts b/api/spec/packages/typespec-typescript/templates/tests/client.spec.ts
index 8047bd35a7..3d41d1ece9 100644
--- a/api/spec/packages/typespec-typescript/templates/tests/client.spec.ts
+++ b/api/spec/packages/typespec-typescript/templates/tests/client.spec.ts
@@ -1,5 +1,5 @@
import fetchMock from '@fetch-mock/vitest'
-import { beforeEach, describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { OpenMeter, ServerList } from '../src/index.js'
import { SDK_VERSION } from '../src/lib/version.js'
@@ -128,6 +128,10 @@ describe('option clobbering is prevented', () => {
})
describe('SDK telemetry headers', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
it('sets a default User-Agent header', async () => {
mockMeter()
const sdk = new OpenMeter({
@@ -150,6 +154,48 @@ describe('SDK telemetry headers', () => {
await sdk.meters.get({ meterId: 'm' })
expect(lastUserAgent()).toBe('custom-agent/1.0')
})
+
+ it('does not set a default User-Agent in browsers', async () => {
+ vi.stubGlobal('window', {})
+ vi.stubGlobal('process', undefined)
+ mockMeter()
+ const sdk = new OpenMeter({
+ baseUrl: 'https://eu.api.konghq.com/v3',
+ apiKey: 'k',
+ fetch,
+ })
+ await sdk.meters.get({ meterId: 'm' })
+ expect(lastUserAgent()).toBeNull()
+ })
+
+ // Workers are CORS-bound but have no `window`, so a `window`-based gate would
+ // leak the header here and preflight the request.
+ it('does not set a default User-Agent in workers', async () => {
+ vi.stubGlobal('self', {})
+ vi.stubGlobal('process', undefined)
+ mockMeter()
+ const sdk = new OpenMeter({
+ baseUrl: 'https://eu.api.konghq.com/v3',
+ apiKey: 'k',
+ fetch,
+ })
+ await sdk.meters.get({ meterId: 'm' })
+ expect(lastUserAgent()).toBeNull()
+ })
+
+ it('honors a caller-provided User-Agent in browsers', async () => {
+ vi.stubGlobal('window', {})
+ vi.stubGlobal('process', undefined)
+ 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', () => {
diff --git a/api/spec/packages/typespec-typescript/test/nullable-union.test.ts b/api/spec/packages/typespec-typescript/test/nullable-union.test.ts
new file mode 100644
index 0000000000..24c79d6006
--- /dev/null
+++ b/api/spec/packages/typespec-typescript/test/nullable-union.test.ts
@@ -0,0 +1,143 @@
+import { beforeAll, describe, expect, it } from 'vitest'
+import { z } from 'zod'
+import { fromWire, toWire } from '../src/runtime/wire.js'
+import { EmitterTester } from './emit.js'
+
+const FIXTURE = `
+import "@typespec/http";
+import "@typespec/openapi";
+
+using TypeSpec.Http;
+using TypeSpec.OpenAPI;
+
+namespace Widgets {
+ model FlatCost {
+ type: "flat";
+ amount: int32;
+ }
+
+ model DynamicCost {
+ type: "dynamic";
+ provider_property: string;
+ model_property: string;
+ }
+
+ @discriminated(#{ envelope: "none", discriminatorPropertyName: "type" })
+ union Cost {
+ flat: FlatCost,
+ dynamic: DynamicCost,
+ }
+
+ model Payload {
+ nullable_cost?: Cost | null;
+ multi: "alpha" | 42 | null;
+ null_only: null;
+ plain_union: "alpha" | 42;
+ }
+
+ interface WidgetOperations {
+ @post
+ @operationId("create-widget")
+ create(@body body: Payload): Payload;
+ }
+}
+
+@service(#{ title: "Test API" })
+namespace Api {
+ @route("/widgets")
+ interface WidgetEndpoints extends Widgets.WidgetOperations {}
+}
+`
+
+describe('nullable union emission', () => {
+ let schemas: string
+
+ beforeAll(async () => {
+ const [result, diagnostics] =
+ await EmitterTester.compileAndDiagnose(FIXTURE)
+ expect(
+ diagnostics.filter((d) => d.severity === 'error'),
+ 'fixture must compile without errors',
+ ).toEqual([])
+ schemas = result.outputs['src/models/schemas.ts']!
+ })
+
+ it('preserves a nullable discriminated union in public and wire schemas', () => {
+ expect(schemas).toMatch(/nullableCost: cost\.nullable\(\)\.optional\(\)/)
+ expect(schemas).toMatch(
+ /nullable_cost: costWire\.nullable\(\)\.optional\(\)/,
+ )
+ expect(schemas).not.toMatch(
+ /z\.union\(\[\s*cost(?:Wire)?,\s*z\.null\(\)\s*\]\)/,
+ )
+ })
+
+ it('keeps every non-null variant in a multi-variant nullable union', () => {
+ expect(
+ schemas.match(
+ /multi: z\s*\.union\(\[z\.literal\("alpha"\), z\.literal\(42\)\]\)\s*\.nullable\(\)/g,
+ ),
+ ).toHaveLength(2)
+ })
+
+ it('keeps bare null intrinsic and non-null union emission unchanged', () => {
+ expect(schemas.match(/nullOnly: z\.null\(\)/g)).toHaveLength(1)
+ expect(schemas.match(/null_only: z\.null\(\)/g)).toHaveLength(1)
+ expect(schemas).toMatch(
+ /plainUnion: z\s*\.union\(\[z\.literal\("alpha"\), z\.literal\(42\)\]\)(?!\s*\.nullable\(\))/,
+ )
+ expect(schemas).toMatch(
+ /plain_union: z\s*\.union\(\[z\.literal\("alpha"\), z\.literal\(42\)\]\)(?!\s*\.nullable\(\))/,
+ )
+ })
+
+ it('round-trips renamed keys through a nullable discriminated union', () => {
+ const cost = z.discriminatedUnion('type', [
+ z.object({ type: z.literal('flat'), amount: z.number() }),
+ z.object({
+ type: z.literal('dynamic'),
+ providerProperty: z.string(),
+ modelProperty: z.string(),
+ }),
+ ])
+ const body = z.object({ nullableCost: cost.nullable().optional() })
+ const bodyWire = z.strictObject({
+ nullable_cost: z
+ .discriminatedUnion('type', [
+ z.strictObject({ type: z.literal('flat'), amount: z.number() }),
+ z.strictObject({
+ type: z.literal('dynamic'),
+ provider_property: z.string(),
+ model_property: z.string(),
+ }),
+ ])
+ .nullable()
+ .optional(),
+ })
+ const input = {
+ nullableCost: {
+ type: 'dynamic' as const,
+ providerProperty: 'provider',
+ modelProperty: 'model',
+ },
+ }
+
+ const wire = toWire(input, body)
+
+ expect(body.safeParse(input).success).toBe(true)
+ expect(wire).toEqual({
+ nullable_cost: {
+ type: 'dynamic',
+ provider_property: 'provider',
+ model_property: 'model',
+ },
+ })
+ expect(bodyWire.safeParse(wire).success).toBe(true)
+ expect(fromWire(wire, body)).toEqual(input)
+
+ const nullWire = toWire({ nullableCost: null }, body)
+ expect(nullWire).toEqual({ nullable_cost: null })
+ expect(bodyWire.safeParse(nullWire).success).toBe(true)
+ expect(fromWire(nullWire, body)).toEqual({ nullableCost: null })
+ })
+})