@workos/oagen-emitters 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint.yml +1 -1
  3. package/.github/workflows/release-please.yml +2 -2
  4. package/.github/workflows/release.yml +1 -1
  5. package/.husky/pre-push +11 -0
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +15 -0
  9. package/README.md +35 -224
  10. package/dist/index.d.mts +12 -1
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -12737
  13. package/dist/plugin-BSop9f9z.mjs +21471 -0
  14. package/dist/plugin-BSop9f9z.mjs.map +1 -0
  15. package/dist/plugin.d.mts +7 -0
  16. package/dist/plugin.d.mts.map +1 -0
  17. package/dist/plugin.mjs +2 -0
  18. package/docs/sdk-architecture/dotnet.md +336 -0
  19. package/oagen.config.ts +5 -343
  20. package/package.json +10 -34
  21. package/smoke/sdk-dotnet.ts +45 -12
  22. package/src/dotnet/client.ts +89 -0
  23. package/src/dotnet/enums.ts +323 -0
  24. package/src/dotnet/fixtures.ts +236 -0
  25. package/src/dotnet/index.ts +248 -0
  26. package/src/dotnet/manifest.ts +36 -0
  27. package/src/dotnet/models.ts +320 -0
  28. package/src/dotnet/naming.ts +368 -0
  29. package/src/dotnet/resources.ts +943 -0
  30. package/src/dotnet/tests.ts +713 -0
  31. package/src/dotnet/type-map.ts +228 -0
  32. package/src/dotnet/wrappers.ts +197 -0
  33. package/src/go/client.ts +35 -3
  34. package/src/go/enums.ts +4 -0
  35. package/src/go/index.ts +15 -7
  36. package/src/go/models.ts +6 -1
  37. package/src/go/naming.ts +5 -17
  38. package/src/go/resources.ts +534 -73
  39. package/src/go/tests.ts +39 -3
  40. package/src/go/type-map.ts +8 -3
  41. package/src/go/wrappers.ts +79 -21
  42. package/src/index.ts +15 -0
  43. package/src/kotlin/client.ts +58 -0
  44. package/src/kotlin/enums.ts +189 -0
  45. package/src/kotlin/index.ts +92 -0
  46. package/src/kotlin/manifest.ts +55 -0
  47. package/src/kotlin/models.ts +486 -0
  48. package/src/kotlin/naming.ts +229 -0
  49. package/src/kotlin/overrides.ts +25 -0
  50. package/src/kotlin/resources.ts +998 -0
  51. package/src/kotlin/tests.ts +1133 -0
  52. package/src/kotlin/type-map.ts +123 -0
  53. package/src/kotlin/wrappers.ts +168 -0
  54. package/src/node/client.ts +84 -7
  55. package/src/node/field-plan.ts +12 -14
  56. package/src/node/fixtures.ts +39 -3
  57. package/src/node/index.ts +1 -0
  58. package/src/node/models.ts +281 -37
  59. package/src/node/resources.ts +319 -95
  60. package/src/node/tests.ts +108 -29
  61. package/src/node/type-map.ts +1 -31
  62. package/src/node/utils.ts +96 -6
  63. package/src/node/wrappers.ts +31 -1
  64. package/src/php/client.ts +11 -3
  65. package/src/php/models.ts +0 -33
  66. package/src/php/naming.ts +2 -21
  67. package/src/php/resources.ts +275 -19
  68. package/src/php/tests.ts +118 -18
  69. package/src/php/type-map.ts +16 -2
  70. package/src/php/wrappers.ts +7 -2
  71. package/src/plugin.ts +50 -0
  72. package/src/python/client.ts +50 -32
  73. package/src/python/enums.ts +35 -10
  74. package/src/python/index.ts +35 -27
  75. package/src/python/models.ts +139 -2
  76. package/src/python/naming.ts +2 -22
  77. package/src/python/resources.ts +234 -17
  78. package/src/python/tests.ts +260 -16
  79. package/src/python/type-map.ts +16 -2
  80. package/src/ruby/client.ts +238 -0
  81. package/src/ruby/enums.ts +149 -0
  82. package/src/ruby/index.ts +93 -0
  83. package/src/ruby/manifest.ts +35 -0
  84. package/src/ruby/models.ts +360 -0
  85. package/src/ruby/naming.ts +187 -0
  86. package/src/ruby/rbi.ts +313 -0
  87. package/src/ruby/resources.ts +799 -0
  88. package/src/ruby/tests.ts +459 -0
  89. package/src/ruby/type-map.ts +97 -0
  90. package/src/ruby/wrappers.ts +161 -0
  91. package/src/shared/model-utils.ts +357 -16
  92. package/src/shared/naming-utils.ts +83 -0
  93. package/src/shared/non-spec-services.ts +13 -0
  94. package/src/shared/resolved-ops.ts +75 -1
  95. package/src/shared/wrapper-utils.ts +12 -1
  96. package/test/dotnet/client.test.ts +121 -0
  97. package/test/dotnet/enums.test.ts +193 -0
  98. package/test/dotnet/errors.test.ts +9 -0
  99. package/test/dotnet/manifest.test.ts +82 -0
  100. package/test/dotnet/models.test.ts +258 -0
  101. package/test/dotnet/resources.test.ts +387 -0
  102. package/test/dotnet/tests.test.ts +202 -0
  103. package/test/entrypoint.test.ts +89 -0
  104. package/test/go/client.test.ts +6 -6
  105. package/test/go/resources.test.ts +156 -7
  106. package/test/kotlin/models.test.ts +135 -0
  107. package/test/kotlin/resources.test.ts +210 -0
  108. package/test/kotlin/tests.test.ts +176 -0
  109. package/test/node/client.test.ts +74 -0
  110. package/test/node/models.test.ts +134 -1
  111. package/test/node/resources.test.ts +343 -34
  112. package/test/node/utils.test.ts +140 -0
  113. package/test/php/client.test.ts +2 -1
  114. package/test/php/models.test.ts +5 -4
  115. package/test/php/resources.test.ts +103 -0
  116. package/test/php/tests.test.ts +67 -0
  117. package/test/plugin.test.ts +50 -0
  118. package/test/python/client.test.ts +56 -0
  119. package/test/python/models.test.ts +99 -0
  120. package/test/python/resources.test.ts +294 -0
  121. package/test/python/tests.test.ts +91 -0
  122. package/test/ruby/client.test.ts +81 -0
  123. package/test/ruby/resources.test.ts +386 -0
  124. package/test/shared/resolved-ops.test.ts +122 -0
  125. package/tsdown.config.ts +1 -1
  126. package/dist/index.mjs.map +0 -1
  127. package/scripts/generate-php.js +0 -13
  128. package/scripts/git-push-with-published-oagen.sh +0 -21
@@ -7,6 +7,7 @@ import type {
7
7
  TypeRef,
8
8
  ResolvedOperation,
9
9
  Parameter,
10
+ Model,
10
11
  } from '@workos/oagen';
11
12
 
12
13
  /** Extend Parameter with `explode` until @workos/oagen publishes the field. */
@@ -38,6 +39,8 @@ import {
38
39
  getOpDefaults,
39
40
  getOpInferFromClient,
40
41
  buildHiddenParams as buildHiddenParamsShared,
42
+ collectGroupedParamNames,
43
+ collectBodyFieldTypes,
41
44
  } from '../shared/resolved-ops.js';
42
45
  import {
43
46
  generateSyncWrapperMethods,
@@ -65,6 +68,85 @@ export function resolveResourceClassName(service: Service, ctx: EmitterContext):
65
68
  // buildHiddenParams is imported from ../shared/resolved-ops.js as buildHiddenParamsShared
66
69
  const buildHiddenParams = buildHiddenParamsShared;
67
70
 
71
+ // ─── Parameter group support ─────────────────────────────────────────
72
+
73
+ /**
74
+ * PascalCase variant class name for a parameter group variant.
75
+ * E.g., group "parent_resource", variant "by_id" -> "ParentResourceById".
76
+ */
77
+ function groupVariantClassName(groupName: string, variantName: string): string {
78
+ return className(`${groupName}_${variantName}`);
79
+ }
80
+
81
+ /**
82
+ * Generate Python dataclass definitions for all parameter group variants
83
+ * across a set of operations. Returns lines to insert near the top of the
84
+ * resource file (after imports, before class definitions).
85
+ */
86
+ function generateParameterGroupDataclasses(
87
+ operations: Operation[],
88
+ specEnumNames: Set<string>,
89
+ models: Model[],
90
+ ): string[] {
91
+ const lines: string[] = [];
92
+ const emitted = new Set<string>();
93
+
94
+ for (const op of operations) {
95
+ const bodyFieldTypes = collectBodyFieldTypes(op, models);
96
+ for (const group of op.parameterGroups ?? []) {
97
+ for (const variant of group.variants) {
98
+ const variantClass = groupVariantClassName(group.name, variant.name);
99
+ if (emitted.has(variantClass)) continue;
100
+ emitted.add(variantClass);
101
+
102
+ lines.push('');
103
+ lines.push('@dataclass');
104
+ lines.push(`class ${variantClass}:`);
105
+ const readableGroup = group.name.replace(/_/g, ' ');
106
+ const readableVariant = variant.name.replace(/_/g, ' ');
107
+ lines.push(` """Identify ${readableGroup} ${readableVariant}."""`);
108
+ for (const param of variant.parameters) {
109
+ const pyField = fieldName(param.name);
110
+ const effectiveType = bodyFieldTypes.get(param.name) ?? param.type;
111
+ const pyType = mapTypeRefUnquoted(effectiveType, specEnumNames, true);
112
+ lines.push(` ${pyField}: ${pyType}`);
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ return lines;
119
+ }
120
+
121
+ /**
122
+ * Check whether any operation has parameter groups.
123
+ */
124
+ function hasParameterGroups(operations: Operation[]): boolean {
125
+ return operations.some((op) => (op.parameterGroups?.length ?? 0) > 0);
126
+ }
127
+
128
+ /**
129
+ * Collect the PascalCase class names of all parameter group variants
130
+ * across a set of operations. Used by client.ts to re-export these
131
+ * dataclasses from the service __init__.py.
132
+ */
133
+ export function collectParameterGroupClassNames(operations: Operation[]): string[] {
134
+ const names: string[] = [];
135
+ const seen = new Set<string>();
136
+ for (const op of operations) {
137
+ for (const group of op.parameterGroups ?? []) {
138
+ for (const variant of group.variants) {
139
+ const cls = groupVariantClassName(group.name, variant.name);
140
+ if (!seen.has(cls)) {
141
+ seen.add(cls);
142
+ names.push(cls);
143
+ }
144
+ }
145
+ }
146
+ }
147
+ return names;
148
+ }
149
+
68
150
  // ─── Shared method-emission helpers ──────────────────────────────────
69
151
 
70
152
  /** Metadata returned by emitMethodSignature, consumed by docstring & body emitters. */
@@ -133,11 +215,16 @@ function emitMethodSignature(
133
215
  const pathParamNames = new Set(op.pathParams.map((p) => fieldName(p.name)));
134
216
 
135
217
  // Request body fields as keyword args (rename fields that clash with path params)
218
+ const groupedBodyParamNames = collectGroupedParamNames(op);
136
219
  if (plan.hasBody && op.requestBody) {
137
220
  const bodyModel = ctx.spec.models.find((m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name);
138
221
  if (bodyModel) {
139
- const reqFields = bodyModel.fields.filter((f) => f.required && !hiddenParams.has(f.name));
140
- const optFields = bodyModel.fields.filter((f) => !f.required && !hiddenParams.has(f.name));
222
+ const reqFields = bodyModel.fields.filter(
223
+ (f) => f.required && !hiddenParams.has(f.name) && !groupedBodyParamNames.has(f.name),
224
+ );
225
+ const optFields = bodyModel.fields.filter(
226
+ (f) => !f.required && !hiddenParams.has(f.name) && !groupedBodyParamNames.has(f.name),
227
+ );
141
228
  for (const f of reqFields) {
142
229
  const fieldType = mapTypeRefUnquoted(f.type, specEnumNames, true);
143
230
  if (usesClientCredentialDefaults && (f.name === 'client_id' || f.name === 'client_secret')) {
@@ -175,9 +262,11 @@ function emitMethodSignature(
175
262
  }
176
263
 
177
264
  // Query params for non-paginated methods
265
+ const groupedParamNames = collectGroupedParamNames(op);
178
266
  if (plan.hasQueryParams && !isPaginated) {
179
267
  for (const param of op.queryParams) {
180
268
  if (hiddenParams.has(param.name)) continue;
269
+ if (groupedParamNames.has(param.name)) continue;
181
270
  const paramName = fieldName(param.name);
182
271
  if (pathParamNames.has(paramName)) continue;
183
272
  // Skip query params that collide with body field names (using possibly-renamed names)
@@ -207,11 +296,12 @@ function emitMethodSignature(
207
296
  const orderParam = op.queryParams.find((p) => p.name === 'order');
208
297
  const orderType =
209
298
  orderParam && orderParam.type.kind === 'enum' ? mapTypeRefUnquoted(orderParam.type, specEnumNames, true) : 'str';
210
- lines.push(` order: Optional[${orderType}] = None,`);
299
+ lines.push(` order: Optional[${orderType}] = "desc",`);
211
300
  // Additional non-pagination query params
212
301
  for (const param of op.queryParams) {
213
302
  if (['limit', 'before', 'after', 'order'].includes(param.name)) continue;
214
303
  if (hiddenParams.has(param.name)) continue;
304
+ if (groupedParamNames.has(param.name)) continue;
215
305
  const paramName = fieldName(param.name);
216
306
  const paramType = mapTypeRefUnquoted(param.type, specEnumNames, true);
217
307
  if (param.required) {
@@ -222,6 +312,18 @@ function emitMethodSignature(
222
312
  }
223
313
  }
224
314
 
315
+ // Parameter group union kwargs
316
+ for (const group of op.parameterGroups ?? []) {
317
+ const variantClasses = group.variants.map((v) => groupVariantClassName(group.name, v.name));
318
+ const unionType = `Union[${variantClasses.join(', ')}]`;
319
+ const paramName = fieldName(group.name);
320
+ if (group.optional) {
321
+ lines.push(` ${paramName}: Optional[${unionType}] = None,`);
322
+ } else {
323
+ lines.push(` ${paramName}: ${unionType},`);
324
+ }
325
+ }
326
+
225
327
  // Idempotency key for idempotent POSTs
226
328
  if (plan.isIdempotentPost) {
227
329
  lines.push(' idempotency_key: Optional[str] = None,');
@@ -249,7 +351,11 @@ function emitMethodSignature(
249
351
  returnType = 'str';
250
352
  } else if (isPaginated) {
251
353
  const resolvedItem = resolvePageItemName(op.pagination!.itemType, listWrapperNames, ctx);
252
- returnType = `${pageType}[${className(resolvedItem)}]`;
354
+ const resolvedItemModel = ctx.spec.models.find((m) => m.name === resolvedItem);
355
+ const itemTypeName = (resolvedItemModel as any)?.discriminator
356
+ ? className(resolvedItem) + 'Variant'
357
+ : className(resolvedItem);
358
+ returnType = `${pageType}[${itemTypeName}]`;
253
359
  } else if (isArrayResponse) {
254
360
  returnType = `List[${className(plan.responseModelName!)}]`;
255
361
  } else if (plan.responseModelName) {
@@ -299,6 +405,7 @@ function emitMethodDocstring(
299
405
  }));
300
406
 
301
407
  // Add body model fields to docs
408
+ const groupedDocBodyParams = collectGroupedParamNames(op);
302
409
  if (plan.hasBody && op.requestBody) {
303
410
  if (op.requestBody.kind === 'model') {
304
411
  const requestBodyName = op.requestBody.name;
@@ -306,6 +413,7 @@ function emitMethodDocstring(
306
413
  if (bodyModel) {
307
414
  for (const f of bodyModel.fields) {
308
415
  if (hiddenParams.has(f.name)) continue;
416
+ if (groupedDocBodyParams.has(f.name)) continue;
309
417
  allParams.push({
310
418
  name: bodyParamName(f, pathParamNames),
311
419
  desc: f.deprecated ? (f.description ? `(deprecated) ${f.description}` : '(deprecated)') : f.description,
@@ -326,9 +434,11 @@ function emitMethodDocstring(
326
434
  }
327
435
 
328
436
  // Add query params for non-paginated methods
437
+ const groupedDocParams = collectGroupedParamNames(op);
329
438
  if (plan.hasQueryParams && !isPaginated) {
330
439
  for (const param of op.queryParams) {
331
440
  if (hiddenParams.has(param.name)) continue;
441
+ if (groupedDocParams.has(param.name)) continue;
332
442
  const pn = fieldName(param.name);
333
443
  if (pathParamNames.has(pn)) continue;
334
444
  // Skip params already documented from body fields
@@ -362,6 +472,7 @@ function emitMethodDocstring(
362
472
  }
363
473
  for (const param of op.queryParams) {
364
474
  if (['limit', 'before', 'after', 'order'].includes(param.name)) continue;
475
+ if (groupedDocParams.has(param.name)) continue;
365
476
  let desc = param.deprecated
366
477
  ? param.description
367
478
  ? `(deprecated) ${param.description}`
@@ -375,6 +486,14 @@ function emitMethodDocstring(
375
486
  }
376
487
  }
377
488
 
489
+ // Add parameter group docs
490
+ for (const group of op.parameterGroups ?? []) {
491
+ const variantClasses = group.variants.map((v) => groupVariantClassName(group.name, v.name));
492
+ const readableGroup = group.name.replace(/_/g, ' ');
493
+ const desc = `Identifies the ${readableGroup}. One of: ${variantClasses.join(', ')}.`;
494
+ allParams.push({ name: fieldName(group.name), desc });
495
+ }
496
+
378
497
  // Add idempotency key parameter to docs
379
498
  if (plan.isIdempotentPost) {
380
499
  allParams.push({ name: 'idempotency_key', desc: 'Optional idempotency key for safe retries.' });
@@ -521,6 +640,10 @@ function emitMethodBody(
521
640
  } else if (isPaginated) {
522
641
  const resolvedItemName = resolvePageItemName(op.pagination!.itemType, listWrapperNames, ctx);
523
642
  const itemTypeClass = className(resolvedItemName);
643
+ const resolvedItemModel = ctx.spec.models.find((m) => m.name === resolvedItemName);
644
+ const isDiscriminatorModel = !!(resolvedItemModel as any)?.discriminator;
645
+ const variantTypeName = isDiscriminatorModel ? itemTypeClass + 'Variant' : null;
646
+ const pageType = isAsync ? 'AsyncPage' : 'SyncPage';
524
647
  const orderParam = op.queryParams.find((p) => p.name === 'order');
525
648
  // Build query params dict
526
649
  lines.push(' params = {k: v for k, v in {');
@@ -530,9 +653,11 @@ function emitMethodBody(
530
653
  lines.push(
531
654
  ` "order": ${serializeParameterValue(orderParam?.type, 'order', false, (orderParam as ParameterExt | undefined)?.explode)},`,
532
655
  );
656
+ const paginatedGroupedParams = collectGroupedParamNames(op);
533
657
  for (const param of op.queryParams) {
534
658
  if (['limit', 'before', 'after', 'order'].includes(param.name)) continue;
535
659
  if (hiddenParams.has(param.name)) continue;
660
+ if (paginatedGroupedParams.has(param.name)) continue;
536
661
  const pn = fieldName(param.name);
537
662
  const value = serializeParameterValue(param.type, pn, param.required, (param as ParameterExt).explode);
538
663
  lines.push(` "${param.name}": ${value},`);
@@ -552,20 +677,42 @@ function emitMethodBody(
552
677
  lines.push(` params["${field}"] = ${expr}`);
553
678
  }
554
679
  }
555
- lines.push(` return ${awaitPrefix}self._client.request_page(`);
556
- lines.push(` method="${httpMethod}",`);
557
- lines.push(` path=${pathStr},`);
558
- lines.push(` model=${itemTypeClass},`);
559
- lines.push(' params=params,');
560
- lines.push(' request_options=request_options,');
561
- lines.push(' )');
680
+ // isinstance dispatch for parameter groups
681
+ emitGroupDispatch(lines, op);
682
+ if (isDiscriminatorModel && variantTypeName) {
683
+ // Dispatcher model: cast the page to the variant union type so callers get
684
+ // rich type information when iterating events.
685
+ lines.push(` return cast(`);
686
+ lines.push(` ${pageType}[${variantTypeName}],`);
687
+ lines.push(` ${awaitPrefix}self._client.request_page(`);
688
+ lines.push(` method="${httpMethod}",`);
689
+ lines.push(` path=${pathStr},`);
690
+ lines.push(
691
+ ` model=${itemTypeClass}, # type: ignore[arg-type] # dispatcher; pagination only calls from_dict`,
692
+ );
693
+ lines.push(' params=params,');
694
+ lines.push(' request_options=request_options,');
695
+ lines.push(' )');
696
+ lines.push(' )');
697
+ } else {
698
+ lines.push(` return ${awaitPrefix}self._client.request_page(`);
699
+ lines.push(` method="${httpMethod}",`);
700
+ lines.push(` path=${pathStr},`);
701
+ lines.push(` model=${itemTypeClass},`);
702
+ lines.push(' params=params,');
703
+ lines.push(' request_options=request_options,');
704
+ lines.push(' )');
705
+ }
562
706
  } else if (plan.isDelete) {
563
707
  // Build body dict if the DELETE has a request body
708
+ const deleteGroupedParams = collectGroupedParamNames(op);
564
709
  const deleteBodyFieldNames = new Set<string>();
565
710
  if (plan.hasBody && op.requestBody) {
566
711
  const bodyModel = ctx.spec.models.find((m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name);
567
712
  if (bodyModel) {
568
- const bodyFields = bodyModel.fields.filter((f) => !hiddenParams.has(f.name));
713
+ const bodyFields = bodyModel.fields.filter(
714
+ (f) => !hiddenParams.has(f.name) && !deleteGroupedParams.has(f.name),
715
+ );
569
716
  for (const f of bodyFields) deleteBodyFieldNames.add(bodyParamName(f, pathParamNames));
570
717
  const hasOptionalBodyFields = bodyFields.some((f) => !f.required);
571
718
  if (bodyFields.length > 0 && hasOptionalBodyFields) {
@@ -599,6 +746,8 @@ function emitMethodBody(
599
746
  lines.push(` body["${field}"] = ${expr}`);
600
747
  }
601
748
  }
749
+ // isinstance dispatch for parameter groups into body
750
+ emitGroupDispatch(lines, op, 'body', collectBodyFieldTypes(op, ctx.spec.models));
602
751
  }
603
752
  }
604
753
  // Build query params dict if any exist alongside the body/path
@@ -618,10 +767,11 @@ function emitMethodBody(
618
767
  } else if (plan.hasBody && op.requestBody) {
619
768
  const responseModel = plan.responseModelName ? className(plan.responseModelName) : 'None';
620
769
  // Build body dict
770
+ const bodyGroupedParams = collectGroupedParamNames(op);
621
771
  const bodyModel = ctx.spec.models.find((m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name);
622
772
  const bodyFieldNamesSet = new Set<string>();
623
773
  if (bodyModel) {
624
- const bodyFields = bodyModel.fields.filter((f) => !hiddenParams.has(f.name));
774
+ const bodyFields = bodyModel.fields.filter((f) => !hiddenParams.has(f.name) && !bodyGroupedParams.has(f.name));
625
775
  for (const f of bodyFields) bodyFieldNamesSet.add(bodyParamName(f, pathParamNames));
626
776
  const hasOptionalBodyFields = bodyFields.some((f) => !f.required);
627
777
  if (bodyFields.length > 0 && hasOptionalBodyFields) {
@@ -657,6 +807,8 @@ function emitMethodBody(
657
807
  lines.push(` body["${field}"] = ${expr}`);
658
808
  }
659
809
  }
810
+ // isinstance dispatch for parameter groups into body
811
+ emitGroupDispatch(lines, op, 'body', collectBodyFieldTypes(op, ctx.spec.models));
660
812
  } else {
661
813
  // Union or non-model body — convert model instances to dicts
662
814
  lines.push(' _body: Dict[str, Any] = body if isinstance(body, dict) else body.to_dict()');
@@ -710,11 +862,14 @@ function emitMethodBody(
710
862
  } else {
711
863
  // GET or similar with query params
712
864
  const responseModel = plan.responseModelName ? className(plan.responseModelName) : 'None';
713
- const visibleQueryParams = op.queryParams.filter((p) => !hiddenParams.has(p.name));
865
+ const getGroupedParams = collectGroupedParamNames(op);
866
+ const hasGroups = (op.parameterGroups?.length ?? 0) > 0;
867
+ const visibleQueryParams = op.queryParams.filter((p) => !hiddenParams.has(p.name) && !getGroupedParams.has(p.name));
714
868
  const hasVisibleQueryParams = plan.hasQueryParams && visibleQueryParams.length > 0;
715
869
  const hasInjections =
716
870
  (opDefaults && Object.keys(opDefaults).length > 0) || (opInferFromClient && opInferFromClient.length > 0);
717
- if (hasVisibleQueryParams || hasInjections) {
871
+ const needsParamsDict = hasVisibleQueryParams || hasInjections || hasGroups;
872
+ if (needsParamsDict) {
718
873
  const hasOptionalQueryParams = visibleQueryParams.some((p) => !p.required);
719
874
  if (hasOptionalQueryParams) {
720
875
  lines.push(' params: Dict[str, Any] = {k: v for k, v in {');
@@ -733,7 +888,7 @@ function emitMethodBody(
733
888
  }
734
889
  lines.push(' }');
735
890
  } else {
736
- // No visible query params but we have injections — start with empty dict
891
+ // No visible query params but we have injections or groups — start with empty dict
737
892
  lines.push(' params: Dict[str, Any] = {}');
738
893
  }
739
894
  // Inject constant defaults
@@ -760,8 +915,10 @@ function emitMethodBody(
760
915
  );
761
916
  }
762
917
  }
918
+ // isinstance dispatch for parameter groups
919
+ emitGroupDispatch(lines, op);
763
920
  }
764
- const emittedParams = hasVisibleQueryParams || hasInjections;
921
+ const emittedParams = needsParamsDict;
765
922
  if (isArrayResponse) {
766
923
  // Array response: request_list returns List[Dict], then deserialize each item
767
924
  const itemModel = className(plan.responseModelName!);
@@ -791,6 +948,44 @@ function emitMethodBody(
791
948
  }
792
949
  }
793
950
 
951
+ /**
952
+ * Emit isinstance dispatch blocks for all parameter groups on an operation.
953
+ * Each group produces a chain of if/elif branches that test the group kwarg
954
+ * against each variant dataclass and serializes the variant's parameters
955
+ * into the target dict using their wire names.
956
+ *
957
+ * @param target - The Python dict variable to write into (e.g., "params" or "body").
958
+ */
959
+ function emitGroupDispatch(
960
+ lines: string[],
961
+ op: Operation,
962
+ target = 'params',
963
+ bodyFieldTypes?: Map<string, TypeRef>,
964
+ ): void {
965
+ for (const group of op.parameterGroups ?? []) {
966
+ const groupParam = fieldName(group.name);
967
+ if (group.optional) {
968
+ lines.push(` if ${groupParam} is not None:`);
969
+ }
970
+ const indent = group.optional ? ' ' : '';
971
+ let first = true;
972
+ for (const variant of group.variants) {
973
+ const variantClass = groupVariantClassName(group.name, variant.name);
974
+ const keyword = first ? 'if' : 'elif';
975
+ first = false;
976
+ lines.push(` ${indent}${keyword} isinstance(${groupParam}, ${variantClass}):`);
977
+ for (const param of variant.parameters) {
978
+ const pyField = fieldName(param.name);
979
+ const resolvedType = bodyFieldTypes?.get(param.name) ?? param.type;
980
+ const effectiveType = resolvedType.kind === 'nullable' ? resolvedType.inner : resolvedType;
981
+ const isEnum = effectiveType.kind === 'enum';
982
+ const value = isEnum ? `enum_value(${groupParam}.${pyField})` : `${groupParam}.${pyField}`;
983
+ lines.push(` ${indent}${target}["${param.name}"] = ${value}`);
984
+ }
985
+ }
986
+ }
987
+ }
988
+
794
989
  // ─── Main generator ──────────────────────────────────────────────────
795
990
 
796
991
  /**
@@ -859,7 +1054,9 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
859
1054
  // Also collect types from body model fields (expanded as keyword params)
860
1055
  const bodyModel = ctx.spec.models.find((m) => m.name === requestBodyRef.name);
861
1056
  if (bodyModel) {
1057
+ const importGroupedParams = collectGroupedParamNames(op);
862
1058
  for (const f of bodyModel.fields) {
1059
+ if (importGroupedParams.has(f.name)) continue;
863
1060
  for (const ref of collectModelRefs(f.type)) modelImports.add(ref);
864
1061
  for (const ref of collectEnumRefs(f.type)) enumImports.add(ref);
865
1062
  }
@@ -890,6 +1087,11 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
890
1087
  }
891
1088
  }
892
1089
  modelImports.add(paginationItemName);
1090
+ // For discriminator (dispatcher) models also import the variant union type alias
1091
+ const paginationItemModel = ctx.spec.models.find((m) => m.name === paginationItemName);
1092
+ if ((paginationItemModel as any)?.discriminator) {
1093
+ modelImports.add(paginationItemName + 'Variant');
1094
+ }
893
1095
  }
894
1096
  // Collect model imports for union split wrapper response types
895
1097
  const resolved = lookupResolved(op, resolvedLookup);
@@ -915,6 +1117,14 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
915
1117
 
916
1118
  // Split imports into same-service and cross-service (using mount-based dirs)
917
1119
  const modelToServiceMap = assignModelsToServices(ctx.spec.models, ctx.spec.services);
1120
+ // Discriminator variant type aliases (e.g. EventSchemaVariant) live in the same
1121
+ // service as their dispatcher model, so ensure they resolve to the same directory.
1122
+ for (const model of ctx.spec.models) {
1123
+ if ((model as any).discriminator) {
1124
+ const svc = modelToServiceMap.get(model.name);
1125
+ if (svc) modelToServiceMap.set(model.name + 'Variant', svc);
1126
+ }
1127
+ }
918
1128
  const resolveModelDir = (modelName: string) => {
919
1129
  const svc = modelToServiceMap.get(modelName);
920
1130
  return svc ? (mountDirMap.get(svc) ?? 'common') : 'common';
@@ -997,6 +1207,13 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
997
1207
  if (hasPaginated) {
998
1208
  lines.push(`from ${importPrefix}_pagination import AsyncPage, SyncPage`);
999
1209
  }
1210
+ // dataclass import + group variant definitions
1211
+ const hasGroups = hasParameterGroups(allOperations);
1212
+ if (hasGroups) {
1213
+ lines.push('from dataclasses import dataclass');
1214
+ const dataclassLines = generateParameterGroupDataclasses(allOperations, specEnumNames, ctx.spec.models);
1215
+ lines.push(...dataclassLines);
1216
+ }
1000
1217
  // --- Generate sync class ---
1001
1218
  lines.push('');
1002
1219
  lines.push(`class ${resourceClassName}:`);