@workos/oagen-emitters 0.9.1 → 0.11.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.
@@ -1,8 +1,9 @@
1
- import type { Model, Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
2
- import { assignModelsToServices, collectFieldDependencies, planOperation, walkTypeRef } from '@workos/oagen';
1
+ import type { Model, EmitterContext, GeneratedFile } from '@workos/oagen';
2
+ import { collectFieldDependencies, walkTypeRef } from '@workos/oagen';
3
3
  import { mapTypeRef } from './type-map.js';
4
4
  import { className, fieldName, fileName, buildMountDirMap, dirToModule } from './naming.js';
5
- import { assignEnumsToServices, collectGeneratedEnumSymbolsByDir } from './enums.js';
5
+ import { collectGeneratedEnumSymbolsByDir } from './enums.js';
6
+ import { computeSchemaPlacement } from './shared-schemas.js';
6
7
 
7
8
  /**
8
9
  * Generate Python dataclass model files from IR Model definitions.
@@ -11,8 +12,19 @@ import { assignEnumsToServices, collectGeneratedEnumSymbolsByDir } from './enums
11
12
  export function generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
12
13
  if (models.length === 0) return [];
13
14
 
14
- const modelToService = assignModelsToServices(models, ctx.spec.services, ctx.modelHints);
15
- const enumToService = assignEnumsToServices(ctx.spec.enums, ctx.spec.services);
15
+ // Tests sometimes pass models that aren't in ctx.spec.models, so synthesize
16
+ // a spec view with the passed-in models to keep the placement logic accurate.
17
+ const placementSpec = models === ctx.spec.models ? ctx.spec : { ...ctx.spec, models };
18
+ const placement = computeSchemaPlacement(placementSpec, ctx);
19
+ const {
20
+ modelToService,
21
+ enumToService,
22
+ originalModelToService,
23
+ originalEnumToService,
24
+ relocatedModels,
25
+ relocatedEnums,
26
+ modelAliases: aliasOf,
27
+ } = placement;
16
28
  const mountDirMap = buildMountDirMap(ctx);
17
29
  const resolveDir = (irService: string | undefined) =>
18
30
  irService ? (mountDirMap.get(irService) ?? 'common') : 'common';
@@ -21,32 +33,9 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
21
33
  // Overrides fileName() for symbols that live in a differently-named file.
22
34
  // Used for variant type aliases (e.g. EventSchemaVariant → event_schema).
23
35
  const symbolToFile = new Map<string, string>();
24
- const modelUsage = collectModelUsage(ctx.spec);
25
-
26
- // Build recursive structural hashes for deduplication.
27
- // Model/enum references are resolved bottom-up so structurally-identical
28
- // model trees (e.g. event context/actor sub-models) get the same hash.
29
- const recursiveHashes = buildRecursiveHashMap(models, ctx.spec.enums);
30
- const hashGroups = new Map<string, string[]>(); // hash -> model names
31
- for (const model of models) {
32
- if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
33
- const hash = recursiveHashes.get(model.name) ?? '';
34
- if (!hashGroups.has(hash)) hashGroups.set(hash, []);
35
- hashGroups.get(hash)!.push(model.name);
36
- }
37
-
38
- // For each group of identical models, pick canonical (alphabetically first)
39
- const aliasOf = new Map<string, string>(); // alias name -> canonical name
40
- for (const [, names] of hashGroups) {
41
- if (names.length <= 1) continue;
42
- const sorted = [...names].sort((a, b) => compareAliasPriority(a, b, modelUsage));
43
- const canonical = sorted[0];
44
- for (let i = 1; i < sorted.length; i++) {
45
- if (canAliasModels(canonical, sorted[i], modelUsage)) {
46
- aliasOf.set(sorted[i], canonical);
47
- }
48
- }
49
- }
36
+ // Track each emitted symbol's natural (pre-relocation) service so we can
37
+ // re-export relocated symbols from their original service barrel for BC.
38
+ const symbolToOriginalService = new Map<string, string>();
50
39
 
51
40
  // Track emitted file paths to prevent duplicates when synthetic models from
52
41
  // oneOf enrichment collide with existing IR models in snake_case.
@@ -179,6 +168,12 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
179
168
  symbolToFile.set(variantTypeName, fileName(model.name));
180
169
  emittedModelSymbolsByDir.get(dirName)!.push(unknownClassName);
181
170
  symbolToFile.set(unknownClassName, fileName(model.name));
171
+ const dispatcherNatural = originalModelToService.get(model.name);
172
+ if (dispatcherNatural) {
173
+ symbolToOriginalService.set(model.name, dispatcherNatural);
174
+ symbolToOriginalService.set(variantTypeName, dispatcherNatural);
175
+ symbolToOriginalService.set(unknownClassName, dispatcherNatural);
176
+ }
182
177
  continue;
183
178
  }
184
179
 
@@ -214,6 +209,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
214
209
  });
215
210
  if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
216
211
  emittedModelSymbolsByDir.get(dirName)!.push(model.name);
212
+ const aliasNatural = originalModelToService.get(model.name);
213
+ if (aliasNatural) symbolToOriginalService.set(model.name, aliasNatural);
217
214
  continue;
218
215
  }
219
216
 
@@ -350,12 +347,22 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
350
347
  lines.push(` def from_dict(cls, data: Dict[str, Any]) -> "${modelClassName}":`);
351
348
  lines.push(` """Deserialize from a dictionary."""`);
352
349
  lines.push(' try:');
353
- lines.push(' return cls(');
350
+
351
+ const preludeLines: string[] = [];
352
+ const fieldAssignmentLines: string[] = [];
354
353
 
355
354
  for (const field of [...requiredFields, ...optionalFields]) {
356
355
  const pyFieldName = fieldName(field.name);
357
356
  const wireKey = field.name; // Wire keys are snake_case from the spec
358
357
  const isRequired = !isOptionalField(model.name, field, ctx);
358
+
359
+ const discPrelude = renderDiscriminatedUnionPrelude(field, pyFieldName, wireKey, modelClassName, isRequired);
360
+ if (discPrelude) {
361
+ preludeLines.push(...discPrelude.prelude);
362
+ fieldAssignmentLines.push(` ${pyFieldName}=${discPrelude.expr},`);
363
+ continue;
364
+ }
365
+
359
366
  let accessor: string;
360
367
  if (field.type.kind === 'literal' && isRequired) {
361
368
  // Required literal fields have a statically known value; use .get() with a default
@@ -369,9 +376,12 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
369
376
  const deserRequired = isRequired && field.type.kind !== 'nullable';
370
377
  const walrusVar = `_v_${pyFieldName}`;
371
378
  const deserExpr = deserializeField(field.type, accessor, deserRequired, walrusVar);
372
- lines.push(` ${pyFieldName}=${deserExpr},`);
379
+ fieldAssignmentLines.push(` ${pyFieldName}=${deserExpr},`);
373
380
  }
374
381
 
382
+ for (const preludeLine of preludeLines) lines.push(preludeLine);
383
+ lines.push(' return cls(');
384
+ for (const assignment of fieldAssignmentLines) lines.push(assignment);
375
385
  lines.push(' )');
376
386
  lines.push(' except (KeyError, ValueError) as e:');
377
387
  lines.push(` _raise_deserialize_error("${modelClassName}", e)`);
@@ -418,15 +428,21 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
418
428
  });
419
429
  if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
420
430
  emittedModelSymbolsByDir.get(dirName)!.push(model.name);
431
+ const regularNatural = originalModelToService.get(model.name);
432
+ if (regularNatural) symbolToOriginalService.set(model.name, regularNatural);
421
433
  }
422
434
 
423
435
  // Generate __init__.py barrel files for each models/ directory
424
- // Include both models and enums
425
- const symbolsByDir = new Map<string, string[]>();
436
+ // Include both models and enums.
437
+ // A direct symbol lives in the file at `dirPath/<file>.py`. A re-exported
438
+ // symbol was relocated to common/ but is being mirrored from its natural
439
+ // service barrel for backwards compatibility.
440
+ type BarrelSymbol = { name: string; reExport?: { fromDir: string; file: string } };
441
+ const symbolsByDir = new Map<string, BarrelSymbol[]>();
426
442
  for (const [dirName, names] of emittedModelSymbolsByDir) {
427
443
  const key = `src/${ctx.namespace}/${dirName}/models`;
428
444
  if (!symbolsByDir.has(key)) symbolsByDir.set(key, []);
429
- symbolsByDir.get(key)!.push(...names);
445
+ for (const name of names) symbolsByDir.get(key)!.push({ name });
430
446
  }
431
447
 
432
448
  // Also include enums in the barrels using the enum emitter's actual output placement.
@@ -436,7 +452,37 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
436
452
  for (const [dirName, names] of enumSymbolsByDir) {
437
453
  const key = `src/${ctx.namespace}/${dirName}/models`;
438
454
  if (!symbolsByDir.has(key)) symbolsByDir.set(key, []);
439
- symbolsByDir.get(key)!.push(...names);
455
+ for (const name of names) symbolsByDir.get(key)!.push({ name });
456
+ }
457
+
458
+ // Backwards-compat re-exports: every relocated model is also re-exported
459
+ // from its pre-relocation service barrel so existing
460
+ // `from workos.<service>.models import X` imports keep working.
461
+ const commonDirName = 'common';
462
+ const addReExport = (naturalService: string | undefined, name: string, sourceFile: string): void => {
463
+ if (!naturalService) return;
464
+ const naturalDir = mountDirMap.get(naturalService) ?? naturalService;
465
+ if (naturalDir === commonDirName) return;
466
+ const key = `src/${ctx.namespace}/${naturalDir}/models`;
467
+ if (!symbolsByDir.has(key)) symbolsByDir.set(key, []);
468
+ symbolsByDir.get(key)!.push({ name, reExport: { fromDir: commonDirName, file: sourceFile } });
469
+ };
470
+
471
+ for (const symbol of symbolToOriginalService.keys()) {
472
+ // Only re-export symbols that ended up relocated (i.e. their owning model is in relocatedModels)
473
+ // or whose dispatcher parent is relocated.
474
+ const naturalService = symbolToOriginalService.get(symbol)!;
475
+ // Find what file the symbol lives in (in common/)
476
+ const file = symbolToFile.get(symbol) ?? fileName(symbol);
477
+ // Only re-export if it actually got relocated — that is, if its primary
478
+ // model name (or the parent it shares a file with) is in relocatedModels.
479
+ const primaryName = file === fileName(symbol) ? symbol : reverseLookupModelByFile(file, ctx);
480
+ if (primaryName && !relocatedModels.has(primaryName)) continue;
481
+ addReExport(naturalService, symbol, file);
482
+ }
483
+ for (const enumName of relocatedEnums) {
484
+ const naturalService = originalEnumToService.get(enumName);
485
+ addReExport(naturalService, enumName, fileName(enumName));
440
486
  }
441
487
 
442
488
  // Build set of service directory model paths — these get their parent __init__.py
@@ -465,13 +511,28 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
465
511
  }
466
512
  }
467
513
 
468
- for (const [dirPath, names] of symbolsByDir) {
514
+ for (const [dirPath, symbols] of symbolsByDir) {
515
+ // Deduplicate by symbol name (a direct emission always wins over a stale
516
+ // re-export with the same name).
517
+ const seen = new Map<string, BarrelSymbol>();
518
+ for (const sym of symbols) {
519
+ const existing = seen.get(sym.name);
520
+ if (!existing || (existing.reExport && !sym.reExport)) seen.set(sym.name, sym);
521
+ }
522
+ const uniqueSymbols = [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
523
+
469
524
  // Use `import X as X` syntax for explicit re-exports (required by pyright strict)
470
- const uniqueNames = [...new Set(names)].sort();
471
525
  const importLines: string[] = [];
472
- for (const name of uniqueNames) {
473
- const fileNameForSymbol = symbolToFile.get(name) ?? fileName(name);
474
- importLines.push(`from .${fileNameForSymbol} import ${className(name)} as ${className(name)}`);
526
+ for (const sym of uniqueSymbols) {
527
+ const cls = className(sym.name);
528
+ if (sym.reExport) {
529
+ importLines.push(
530
+ `from ${ctx.namespace}.${dirToModule(sym.reExport.fromDir)}.models.${sym.reExport.file} import ${cls} as ${cls}`,
531
+ );
532
+ } else {
533
+ const fileNameForSymbol = symbolToFile.get(sym.name) ?? fileName(sym.name);
534
+ importLines.push(`from .${fileNameForSymbol} import ${cls} as ${cls}`);
535
+ }
475
536
  }
476
537
  const imports = importLines.join('\n');
477
538
  files.push({
@@ -486,9 +547,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
486
547
  // which includes both the resource class re-export and model star import.
487
548
  if (!serviceDirModelPaths.has(dirPath)) {
488
549
  const parentDir = dirPath.replace(/\/models$/, '');
489
- const reExports = [...new Set(names)]
490
- .sort()
491
- .map((name) => `from .models import ${className(name)} as ${className(name)}`)
550
+ const reExports = uniqueSymbols
551
+ .map((sym) => `from .models import ${className(sym.name)} as ${className(sym.name)}`)
492
552
  .join('\n');
493
553
  files.push({
494
554
  path: `${parentDir}/__init__.py`,
@@ -502,6 +562,18 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
502
562
  return files;
503
563
  }
504
564
 
565
+ /**
566
+ * Given a snake_case file name, return the IR model name that owns it.
567
+ * Used to attribute dispatcher children (FooVariant, FooUnknown) to their
568
+ * parent model when computing relocation re-exports.
569
+ */
570
+ function reverseLookupModelByFile(file: string, ctx: EmitterContext): string | undefined {
571
+ for (const m of ctx.spec.models) {
572
+ if (fileName(m.name) === file) return m.name;
573
+ }
574
+ return undefined;
575
+ }
576
+
505
577
  function collectTypingImports(ref: any, imports: Set<string>): void {
506
578
  switch (ref.kind) {
507
579
  case 'array':
@@ -579,73 +651,6 @@ function collectReachableEnumNames(ctx: EmitterContext): Set<string> {
579
651
  return referencedEnums;
580
652
  }
581
653
 
582
- function collectModelUsage(spec: EmitterContext['spec']): {
583
- requestOnly: Set<string>;
584
- response: Set<string>;
585
- mixed: Set<string>;
586
- } {
587
- const request = new Set<string>();
588
- const response = new Set<string>();
589
-
590
- for (const service of spec.services) {
591
- for (const op of service.operations) {
592
- const plan = planOperation(op);
593
- if (plan.responseModelName) {
594
- response.add(plan.responseModelName);
595
- }
596
- if (op.pagination?.itemType.kind === 'model') {
597
- response.add(op.pagination.itemType.name);
598
- }
599
- if (op.requestBody?.kind === 'model') {
600
- request.add(op.requestBody.name);
601
- }
602
- if (op.requestBody?.kind === 'union') {
603
- for (const variant of op.requestBody.variants ?? []) {
604
- if (variant.kind === 'model') request.add(variant.name);
605
- }
606
- }
607
- }
608
- }
609
-
610
- const mixed = new Set<string>();
611
- for (const name of request) {
612
- if (response.has(name)) mixed.add(name);
613
- }
614
-
615
- const requestOnly = new Set([...request].filter((name) => !mixed.has(name)));
616
- const responseOnly = new Set([...response].filter((name) => !mixed.has(name)));
617
-
618
- return { requestOnly, response: responseOnly, mixed };
619
- }
620
-
621
- function compareAliasPriority(left: string, right: string, usage: ReturnType<typeof collectModelUsage>): number {
622
- const score = (name: string): number => {
623
- if (usage.response.has(name)) return 0;
624
- if (usage.mixed.has(name)) return 1;
625
- if (usage.requestOnly.has(name)) return 2;
626
- return 3;
627
- };
628
-
629
- const diff = score(left) - score(right);
630
- if (diff !== 0) return diff;
631
- return left.localeCompare(right);
632
- }
633
-
634
- function canAliasModels(canonical: string, alias: string, usage: ReturnType<typeof collectModelUsage>): boolean {
635
- // Don't alias when both models produce the same file name — the TypeAlias
636
- // file would import from itself (e.g., FooBar aliased to Foo_bar).
637
- if (fileName(canonical) === fileName(alias)) return false;
638
- // Don't alias across request/response boundaries — a request-only model
639
- // and a response-only model may look identical today but evolve independently.
640
- if (
641
- (usage.response.has(canonical) && usage.requestOnly.has(alias)) ||
642
- (usage.response.has(alias) && usage.requestOnly.has(canonical))
643
- ) {
644
- return false;
645
- }
646
- return true;
647
- }
648
-
649
654
  function isOptionalField(modelName: string, field: Model['fields'][number], ctx: EmitterContext): boolean {
650
655
  void modelName;
651
656
  void ctx;
@@ -683,6 +688,87 @@ function isDateTimeType(ref: any): boolean {
683
688
  return ref.kind === 'primitive' && ref.type === 'string' && ref.format === 'date-time';
684
689
  }
685
690
 
691
+ /**
692
+ * If `field` is a discriminated-union (or nullable-wrapped discriminated-union)
693
+ * field, return prelude statements that perform strict dispatch and an
694
+ * expression for the `cls(...)` call. Returns null otherwise.
695
+ *
696
+ * Strict dispatch means: an unknown discriminator value raises ValueError
697
+ * naming the parent class, field, observed value, and valid options. The
698
+ * caller's `try/except` block converts that into `_raise_deserialize_error`.
699
+ */
700
+ function renderDiscriminatedUnionPrelude(
701
+ field: any,
702
+ pyFieldName: string,
703
+ wireKey: string,
704
+ parentClassName: string,
705
+ isRequired: boolean,
706
+ ): { prelude: string[]; expr: string } | null {
707
+ let unionRef: any = null;
708
+ let nullable = false;
709
+ if (field.type.kind === 'union' && field.type.discriminator?.mapping) {
710
+ unionRef = field.type;
711
+ } else if (
712
+ field.type.kind === 'nullable' &&
713
+ field.type.inner.kind === 'union' &&
714
+ field.type.inner.discriminator?.mapping
715
+ ) {
716
+ unionRef = field.type.inner;
717
+ nullable = true;
718
+ }
719
+ if (!unionRef) return null;
720
+
721
+ const mapping = unionRef.discriminator.mapping as Record<string, string>;
722
+ const entries = Object.entries(mapping);
723
+ if (entries.length === 0) return null;
724
+
725
+ const discProp = unionRef.discriminator.property as string;
726
+ const rawVar = `_${pyFieldName}_raw`;
727
+ const dataVar = `_${pyFieldName}_data`;
728
+ const typeVar = `_${pyFieldName}_disc`;
729
+ const mapVar = `_${pyFieldName}_disc_map`;
730
+ const clsVar = `_${pyFieldName}_cls`;
731
+ const valueVar = `_${pyFieldName}_value`;
732
+ const indent = ' ';
733
+
734
+ const dispatchBlock = (innerIndent: string): string[] => {
735
+ const lines: string[] = [];
736
+ lines.push(`${innerIndent}${dataVar} = cast(Dict[str, Any], ${rawVar})`);
737
+ lines.push(`${innerIndent}${typeVar} = cast(str, ${dataVar}.get("${discProp}"))`);
738
+ lines.push(`${innerIndent}${mapVar}: Dict[str, Any] = {`);
739
+ for (const [value, variantModelName] of entries) {
740
+ lines.push(`${innerIndent} "${value}": ${className(variantModelName)},`);
741
+ }
742
+ lines.push(`${innerIndent}}`);
743
+ lines.push(`${innerIndent}${clsVar} = ${mapVar}.get(${typeVar})`);
744
+ lines.push(`${innerIndent}if ${clsVar} is None:`);
745
+ lines.push(`${innerIndent} raise ValueError(`);
746
+ lines.push(
747
+ `${innerIndent} f"Unknown discriminator '${discProp}' for ${parentClassName}.${pyFieldName}: {${typeVar}!r}. "`,
748
+ );
749
+ lines.push(`${innerIndent} f"Expected one of {sorted(${mapVar})}."`);
750
+ lines.push(`${innerIndent} )`);
751
+ return lines;
752
+ };
753
+
754
+ const prelude: string[] = [];
755
+ if (isRequired && !nullable) {
756
+ prelude.push(`${indent}${rawVar} = data["${wireKey}"]`);
757
+ prelude.push(...dispatchBlock(indent));
758
+ return { prelude, expr: `${clsVar}.from_dict(${dataVar})` };
759
+ }
760
+
761
+ // Optional or nullable: handle missing/None explicitly.
762
+ const accessor = isRequired ? `data["${wireKey}"]` : `data.get("${wireKey}")`;
763
+ prelude.push(`${indent}${rawVar} = ${accessor}`);
764
+ prelude.push(`${indent}if ${rawVar} is None:`);
765
+ prelude.push(`${indent} ${valueVar} = None`);
766
+ prelude.push(`${indent}else:`);
767
+ prelude.push(...dispatchBlock(indent + ' '));
768
+ prelude.push(`${indent} ${valueVar} = ${clsVar}.from_dict(${dataVar})`);
769
+ return { prelude, expr: valueVar };
770
+ }
771
+
686
772
  function deserializeField(ref: any, accessor: string, isRequired: boolean, walrusVar: string = '_v'): string {
687
773
  if (isDateTimeType(ref)) {
688
774
  if (isRequired) {
@@ -726,28 +812,10 @@ function deserializeField(ref: any, accessor: string, isRequired: boolean, walru
726
812
  case 'nullable':
727
813
  return deserializeField(ref.inner, accessor, false, walrusVar);
728
814
  case 'union': {
815
+ // Discriminated unions are handled by `renderDiscriminatedUnionPrelude`
816
+ // before deserializeField is called, so they never reach this branch.
729
817
  const modelVariants = (ref.variants ?? []).filter((v: any) => v.kind === 'model');
730
818
  const uniqueModels = [...new Set(modelVariants.map((v: any) => v.name))] as string[];
731
- // Discriminated union: dispatch on the discriminator property to call
732
- // the matching variant's from_dict. Unknown discriminator values fall
733
- // back to the raw payload so callers can introspect.
734
- if (ref.discriminator && ref.discriminator.mapping) {
735
- const entries = Object.entries(ref.discriminator.mapping as Record<string, string>);
736
- if (entries.length > 0) {
737
- const dispatchMap = entries.map(([value, modelName]) => `"${value}": ${className(modelName)}`).join(', ');
738
- const dataExpr = isRequired ? accessor : walrusVar;
739
- const dataCast = `cast(Dict[str, Any], ${dataExpr})`;
740
- // The dispatch dict has `str` keys, so pyright (strict) rejects the
741
- // raw `Any | None` returned by `.get(prop)` even though `dict.get`
742
- // accepts any hashable. Cast through `str` to satisfy the parameter
743
- // type — runtime semantics are unchanged because a missing/`None`
744
- // discriminator simply misses the dispatch and falls through.
745
- const lookupExpr = `{${dispatchMap}}.get(cast(str, ${dataCast}.get("${ref.discriminator.property}")))`;
746
- const branch = `(_disc.from_dict(${dataCast}) if (_disc := ${lookupExpr}) is not None else ${dataExpr})`;
747
- if (isRequired) return branch;
748
- return `(${branch}) if (${walrusVar} := ${accessor}) is not None else None`;
749
- }
750
- }
751
819
  if (uniqueModels.length === 1) {
752
820
  return deserializeField({ kind: 'model', name: uniqueModels[0] }, accessor, isRequired, walrusVar);
753
821
  }
@@ -783,11 +851,11 @@ function serializeField(ref: any, accessor: string): string {
783
851
  if (uniqueModels.length === 1) {
784
852
  return `${accessor}.to_dict()`;
785
853
  }
786
- // Discriminated union: deserialize produced a dataclass instance for
787
- // known discriminator values and the raw dict for unknowns. Round-trip
788
- // both call `.to_dict()` if it exists, otherwise pass through.
854
+ // Discriminated union: from_dict always produces a concrete dataclass
855
+ // instance (unknown discriminators raise instead of falling back to a
856
+ // raw dict), so the serialized field is unconditionally `.to_dict()`-able.
789
857
  if (ref.discriminator && ref.discriminator.mapping && modelVariants.length > 0) {
790
- return `${accessor}.to_dict() if hasattr(${accessor}, "to_dict") else ${accessor}`;
858
+ return `${accessor}.to_dict()`;
791
859
  }
792
860
  return accessor;
793
861
  }
@@ -796,89 +864,6 @@ function serializeField(ref: any, accessor: string): string {
796
864
  }
797
865
  }
798
866
 
799
- /**
800
- * Build recursive structural hashes for all models.
801
- *
802
- * Model references are resolved to their own structural hash (bottom-up) and
803
- * enum references are resolved to their value-set hash. This means
804
- * structurally-identical model *trees* — like the dozens of per-event Context /
805
- * ContextActor / ContextGoogleAnalyticsSession sub-models in the spec — get
806
- * the same hash even though their IR names differ.
807
- */
808
- function buildRecursiveHashMap(models: Model[], enums: Enum[]): Map<string, string> {
809
- const modelByName = new Map(models.map((m) => [m.name, m]));
810
- const hashCache = new Map<string, string>();
811
- const visiting = new Set<string>(); // cycle guard
812
-
813
- // Pre-compute enum value hashes so identically-valued enums hash the same.
814
- const enumVH = new Map<string, string>();
815
- for (const e of enums) {
816
- enumVH.set(
817
- e.name,
818
- [...e.values]
819
- .map((v) => String(v.value))
820
- .sort()
821
- .join('|'),
822
- );
823
- }
824
-
825
- function modelHash(name: string): string {
826
- const cached = hashCache.get(name);
827
- if (cached != null) return cached;
828
- if (visiting.has(name)) return `m:${name}`; // cycle — fall back to name
829
- visiting.add(name);
830
-
831
- const model = modelByName.get(name);
832
- if (!model) {
833
- visiting.delete(name);
834
- return `m:${name}`; // unknown model
835
- }
836
-
837
- const hash = [...model.fields]
838
- .sort((a, b) => a.name.localeCompare(b.name))
839
- .map((f) => `${f.name}:${deepTypeHash(f.type)}:${f.required}`)
840
- .join('|');
841
-
842
- visiting.delete(name);
843
- hashCache.set(name, hash);
844
- return hash;
845
- }
846
-
847
- function deepTypeHash(ref: any): string {
848
- switch (ref.kind) {
849
- case 'primitive':
850
- return `p:${ref.type}${ref.format ? `:${ref.format}` : ''}`;
851
- case 'model':
852
- return `m:{${modelHash(ref.name)}}`;
853
- case 'enum': {
854
- const vh = enumVH.get(ref.name);
855
- return vh != null ? `e:{${vh}}` : `e:${ref.name}`;
856
- }
857
- case 'array':
858
- return `a:${deepTypeHash(ref.items)}`;
859
- case 'nullable':
860
- return `n:${deepTypeHash(ref.inner)}`;
861
- case 'union':
862
- return `u:${(ref.variants ?? [])
863
- .map((v: any) => deepTypeHash(v))
864
- .sort()
865
- .join(',')}`;
866
- case 'map':
867
- return `d:${deepTypeHash(ref.valueType)}`;
868
- case 'literal':
869
- return `l:${String(ref.value)}`;
870
- default:
871
- return 'unknown';
872
- }
873
- }
874
-
875
- for (const model of models) {
876
- modelHash(model.name);
877
- }
878
-
879
- return hashCache;
880
- }
881
-
882
867
  // Import and re-export shared model detection utilities
883
868
  import { isListMetadataModel, isListWrapperModel } from '../shared/model-utils.js';
884
869
  export { isListMetadataModel, isListWrapperModel };
@@ -12,14 +12,7 @@ import type {
12
12
 
13
13
  /** Extend Parameter with `explode` until @workos/oagen publishes the field. */
14
14
  type ParameterExt = Parameter & { explode?: boolean };
15
- import {
16
- planOperation,
17
- toPascalCase,
18
- toSnakeCase,
19
- collectModelRefs,
20
- collectEnumRefs,
21
- assignModelsToServices,
22
- } from '@workos/oagen';
15
+ import { planOperation, toPascalCase, toSnakeCase, collectModelRefs, collectEnumRefs } from '@workos/oagen';
23
16
  import { mapTypeRefUnquoted } from './type-map.js';
24
17
  import {
25
18
  className,
@@ -49,6 +42,7 @@ import {
49
42
  clientFieldExpression,
50
43
  } from './wrappers.js';
51
44
  import { buildPythonPathExpression } from './path-expression.js';
45
+ import { computeSchemaPlacement } from './shared-schemas.js';
52
46
 
53
47
  /**
54
48
  * Compute the Python parameter name for a body field, prefixing with `body_` if it
@@ -293,11 +287,20 @@ function emitMethodSignature(
293
287
  lines.push(' limit: Optional[int] = None,');
294
288
  lines.push(' before: Optional[str] = None,');
295
289
  lines.push(' after: Optional[str] = None,');
296
- // Use typed enum for order param if the spec provides one, otherwise fall back to str
290
+ // Use typed enum for order param if the spec provides one, otherwise fall
291
+ // back to str. The default value comes from the spec's `default:` field;
292
+ // when the spec drops the default, we surface that as `None` rather than
293
+ // silently restoring "desc" client-side (which would mask a server-side
294
+ // behavior change from the caller).
297
295
  const orderParam = op.queryParams.find((p) => p.name === 'order');
298
296
  const orderType =
299
297
  orderParam && orderParam.type.kind === 'enum' ? mapTypeRefUnquoted(orderParam.type, specEnumNames, true) : 'str';
300
- lines.push(` order: Optional[${orderType}] = "desc",`);
298
+ const orderDefaultRaw = orderParam?.default;
299
+ const orderDefault =
300
+ typeof orderDefaultRaw === 'string' || typeof orderDefaultRaw === 'number' || typeof orderDefaultRaw === 'boolean'
301
+ ? pythonLiteral(orderDefaultRaw)
302
+ : 'None';
303
+ lines.push(` order: Optional[${orderType}] = ${orderDefault},`);
301
304
  // Additional non-pagination query params
302
305
  for (const param of op.queryParams) {
303
306
  if (['limit', 'before', 'after', 'order'].includes(param.name)) continue;
@@ -1127,7 +1130,8 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
1127
1130
  const actualModelImports = [...modelImports];
1128
1131
 
1129
1132
  // Split imports into same-service and cross-service (using mount-based dirs)
1130
- const modelToServiceMap = assignModelsToServices(ctx.spec.models, ctx.spec.services, ctx.modelHints);
1133
+ const placement = computeSchemaPlacement(ctx.spec, ctx);
1134
+ const modelToServiceMap = new Map(placement.modelToService);
1131
1135
  // Discriminator variant type aliases (e.g. EventSchemaVariant) live in the same
1132
1136
  // service as their dispatcher model, so ensure they resolve to the same directory.
1133
1137
  for (const model of ctx.spec.models) {
@@ -1167,30 +1171,10 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
1167
1171
  }
1168
1172
  }
1169
1173
 
1170
- // Enum imports — same-service vs cross-service
1171
- const enumToServiceMap = new Map<string, string>();
1172
- for (const e of ctx.spec.enums) {
1173
- // Find which service uses this enum by walking full type trees
1174
- for (const svc of ctx.spec.services) {
1175
- for (const op of svc.operations) {
1176
- const refs = new Set<string>();
1177
- // Walk all type refs (including nested nullable/array/union) to find enums
1178
- const allTypeRefs = [
1179
- op.response,
1180
- ...(op.requestBody ? [op.requestBody] : []),
1181
- ...op.pathParams.map((p) => p.type),
1182
- ...op.queryParams.map((p) => p.type),
1183
- ...op.headerParams.map((p) => p.type),
1184
- ];
1185
- for (const typeRef of allTypeRefs) {
1186
- for (const ref of collectEnumRefs(typeRef)) refs.add(ref);
1187
- }
1188
- if (refs.has(e.name) && !enumToServiceMap.has(e.name)) {
1189
- enumToServiceMap.set(e.name, svc.name);
1190
- }
1191
- }
1192
- }
1193
- }
1174
+ // Enum imports — same-service vs cross-service. Shared enums (referenced
1175
+ // by 2+ services) are intentionally absent from this map so they resolve
1176
+ // to common/ via the resolveDir() fallback below.
1177
+ const enumToServiceMap = placement.enumToService;
1194
1178
 
1195
1179
  const localEnums: string[] = [];
1196
1180
  const crossServiceEnums = new Map<string, string[]>();