@workos/oagen-emitters 0.4.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 (105) 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 +8 -0
  9. package/README.md +35 -224
  10. package/dist/index.d.mts +9 -1
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -15234
  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 +5 -5
  19. package/oagen.config.ts +5 -373
  20. package/package.json +10 -34
  21. package/src/dotnet/index.ts +6 -4
  22. package/src/dotnet/models.ts +58 -82
  23. package/src/dotnet/naming.ts +44 -6
  24. package/src/dotnet/resources.ts +350 -29
  25. package/src/dotnet/tests.ts +44 -24
  26. package/src/dotnet/type-map.ts +44 -17
  27. package/src/dotnet/wrappers.ts +21 -10
  28. package/src/go/client.ts +35 -3
  29. package/src/go/enums.ts +4 -0
  30. package/src/go/index.ts +10 -5
  31. package/src/go/models.ts +6 -1
  32. package/src/go/resources.ts +534 -73
  33. package/src/go/tests.ts +39 -3
  34. package/src/go/type-map.ts +8 -3
  35. package/src/go/wrappers.ts +79 -21
  36. package/src/index.ts +14 -0
  37. package/src/kotlin/client.ts +7 -2
  38. package/src/kotlin/enums.ts +30 -3
  39. package/src/kotlin/models.ts +97 -6
  40. package/src/kotlin/naming.ts +7 -1
  41. package/src/kotlin/resources.ts +370 -39
  42. package/src/kotlin/tests.ts +120 -6
  43. package/src/node/client.ts +38 -11
  44. package/src/node/field-plan.ts +12 -14
  45. package/src/node/fixtures.ts +39 -3
  46. package/src/node/models.ts +281 -37
  47. package/src/node/resources.ts +156 -52
  48. package/src/node/tests.ts +76 -27
  49. package/src/node/type-map.ts +1 -31
  50. package/src/node/utils.ts +96 -6
  51. package/src/node/wrappers.ts +31 -1
  52. package/src/php/models.ts +0 -33
  53. package/src/php/resources.ts +199 -18
  54. package/src/php/tests.ts +26 -2
  55. package/src/php/type-map.ts +16 -2
  56. package/src/php/wrappers.ts +6 -2
  57. package/src/plugin.ts +50 -0
  58. package/src/python/client.ts +13 -3
  59. package/src/python/enums.ts +28 -3
  60. package/src/python/index.ts +35 -27
  61. package/src/python/models.ts +138 -1
  62. package/src/python/resources.ts +234 -17
  63. package/src/python/tests.ts +260 -16
  64. package/src/python/type-map.ts +16 -2
  65. package/src/ruby/client.ts +238 -0
  66. package/src/ruby/enums.ts +149 -0
  67. package/src/ruby/index.ts +93 -0
  68. package/src/ruby/manifest.ts +35 -0
  69. package/src/ruby/models.ts +360 -0
  70. package/src/ruby/naming.ts +187 -0
  71. package/src/ruby/rbi.ts +313 -0
  72. package/src/ruby/resources.ts +799 -0
  73. package/src/ruby/tests.ts +459 -0
  74. package/src/ruby/type-map.ts +97 -0
  75. package/src/ruby/wrappers.ts +161 -0
  76. package/src/shared/model-utils.ts +131 -7
  77. package/src/shared/naming-utils.ts +36 -0
  78. package/src/shared/non-spec-services.ts +13 -0
  79. package/src/shared/resolved-ops.ts +75 -1
  80. package/test/dotnet/client.test.ts +2 -2
  81. package/test/dotnet/models.test.ts +7 -9
  82. package/test/dotnet/resources.test.ts +135 -3
  83. package/test/dotnet/tests.test.ts +5 -5
  84. package/test/entrypoint.test.ts +89 -0
  85. package/test/go/client.test.ts +6 -6
  86. package/test/go/resources.test.ts +156 -7
  87. package/test/kotlin/models.test.ts +1 -1
  88. package/test/kotlin/resources.test.ts +210 -0
  89. package/test/node/models.test.ts +134 -1
  90. package/test/node/resources.test.ts +134 -26
  91. package/test/node/utils.test.ts +140 -0
  92. package/test/php/models.test.ts +5 -4
  93. package/test/php/resources.test.ts +66 -1
  94. package/test/plugin.test.ts +50 -0
  95. package/test/python/client.test.ts +56 -0
  96. package/test/python/models.test.ts +99 -0
  97. package/test/python/resources.test.ts +294 -0
  98. package/test/python/tests.test.ts +91 -0
  99. package/test/ruby/client.test.ts +81 -0
  100. package/test/ruby/resources.test.ts +386 -0
  101. package/test/shared/resolved-ops.test.ts +122 -0
  102. package/tsdown.config.ts +1 -1
  103. package/dist/index.mjs.map +0 -1
  104. package/scripts/generate-php.js +0 -13
  105. package/scripts/git-push-with-published-oagen.sh +0 -21
@@ -31,7 +31,7 @@ function emitWrapperMethod(
31
31
  // PHPDoc block
32
32
  const docParts: string[] = [];
33
33
  for (const { paramName, field, isOptional } of wrapperParams) {
34
- const docType = field ? mapTypeRefForPHPDoc(field.type) : 'mixed';
34
+ const docType = field ? mapTypeRefForPHPDoc(field.type) : 'string';
35
35
  const nullSuffix = isOptional && !docType.endsWith('|null') ? '|null' : '';
36
36
  docParts.push(`@param ${docType}${nullSuffix} $${fieldName(paramName)}`);
37
37
  }
@@ -57,7 +57,11 @@ function emitWrapperMethod(
57
57
  requiredParams.push(` ${phpType} $${phpName},`);
58
58
  }
59
59
  } else {
60
- optionalParamLines.push(` mixed $${phpName} = null,`);
60
+ if (isOptional) {
61
+ optionalParamLines.push(` ?string $${phpName} = null,`);
62
+ } else {
63
+ requiredParams.push(` string $${phpName},`);
64
+ }
61
65
  }
62
66
  }
63
67
  optionalParamLines.push(` ?\\${ns}\\RequestOptions $options = null,`);
package/src/plugin.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import * as path from 'node:path';
3
+ import type { OagenConfig } from '@workos/oagen';
4
+ import { nodeEmitter } from './node/index.js';
5
+ import { pythonEmitter } from './python/index.js';
6
+ import { phpEmitter } from './php/index.js';
7
+ import { goEmitter } from './go/index.js';
8
+ import { dotnetEmitter } from './dotnet/index.js';
9
+ import { kotlinEmitter } from './kotlin/index.js';
10
+ import { rubyEmitter } from './ruby/index.js';
11
+ import { nodeExtractor } from './compat/extractors/node.js';
12
+ import { rubyExtractor } from './compat/extractors/ruby.js';
13
+ import { pythonExtractor } from './compat/extractors/python.js';
14
+ import { phpExtractor } from './compat/extractors/php.js';
15
+ import { goExtractor } from './compat/extractors/go.js';
16
+ import { rustExtractor } from './compat/extractors/rust.js';
17
+ import { kotlinExtractor } from './compat/extractors/kotlin.js';
18
+ import { dotnetExtractor } from './compat/extractors/dotnet.js';
19
+ import { elixirExtractor } from './compat/extractors/elixir.js';
20
+
21
+ // Resolve smoke runner paths relative to the package root so they work
22
+ // regardless of which project loads the config (CWD-independent).
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
+ const smokeDir = path.resolve(__dirname, '..', 'smoke');
25
+
26
+ export const workosEmittersPlugin: Pick<OagenConfig, 'emitters' | 'extractors' | 'smokeRunners'> = {
27
+ emitters: [nodeEmitter, pythonEmitter, phpEmitter, goEmitter, dotnetEmitter, kotlinEmitter, rubyEmitter],
28
+ extractors: [
29
+ nodeExtractor,
30
+ rubyExtractor,
31
+ pythonExtractor,
32
+ phpExtractor,
33
+ goExtractor,
34
+ rustExtractor,
35
+ kotlinExtractor,
36
+ dotnetExtractor,
37
+ elixirExtractor,
38
+ ],
39
+ smokeRunners: {
40
+ node: path.join(smokeDir, 'sdk-node.ts'),
41
+ ruby: path.join(smokeDir, 'sdk-ruby.ts'),
42
+ python: path.join(smokeDir, 'sdk-python.ts'),
43
+ php: path.join(smokeDir, 'sdk-php.ts'),
44
+ go: path.join(smokeDir, 'sdk-go.ts'),
45
+ rust: path.join(smokeDir, 'sdk-rust.ts'),
46
+ elixir: path.join(smokeDir, 'sdk-elixir.ts'),
47
+ kotlin: path.join(smokeDir, 'sdk-kotlin.ts'),
48
+ dotnet: path.join(smokeDir, 'sdk-dotnet.ts'),
49
+ },
50
+ };
@@ -1,8 +1,8 @@
1
1
  import type { ApiSpec, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
2
2
  import { toPascalCase } from '@workos/oagen';
3
3
  import { className, resolveServiceDir, servicePropertyName, buildMountDirMap, dirToModule } from './naming.js';
4
- import { resolveResourceClassName } from './resources.js';
5
- import { getMountTarget } from '../shared/resolved-ops.js';
4
+ import { resolveResourceClassName, collectParameterGroupClassNames } from './resources.js';
5
+ import { getMountTarget, groupByMount } from '../shared/resolved-ops.js';
6
6
  import { NON_SPEC_SERVICES } from '../shared/non-spec-services.js';
7
7
 
8
8
  /** Python-specific wiring for each non-spec service. */
@@ -254,12 +254,22 @@ function generateServiceInits(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
254
254
  const topLevel = deduplicateByMount(spec.services, ctx);
255
255
  const serviceDirMap = buildMountDirMap(ctx);
256
256
 
257
+ // Build a map from mount target -> operations so we can discover parameter
258
+ // group dataclasses that need re-exporting from __init__.py.
259
+ const mountGroups = groupByMount(ctx);
260
+
257
261
  for (const service of topLevel) {
258
262
  const resolvedName = resolveResourceClassName(service, ctx);
259
263
  const dirName = serviceDirMap.get(service.name) ?? resolveServiceDir(resolvedName);
260
264
  const lines: string[] = [];
261
265
 
262
- lines.push(`from ._resource import ${resolvedName}, Async${resolvedName}`);
266
+ // Collect parameter group class names from all operations in this mount group
267
+ const mountTarget = getMountTarget(service, ctx);
268
+ const ops = mountGroups.get(mountTarget)?.operations ?? service.operations;
269
+ const groupClassNames = collectParameterGroupClassNames(ops);
270
+
271
+ const resourceImports = [resolvedName, `Async${resolvedName}`, ...groupClassNames];
272
+ lines.push(`from ._resource import ${resourceImports.join(', ')}`);
263
273
  lines.push('from .models import *');
264
274
 
265
275
  files.push({
@@ -37,6 +37,8 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
37
37
  // If this enum is an alias for a canonical enum, generate a type alias file
38
38
  const canonicalName = aliasOf.get(enumDef.name);
39
39
  if (canonicalName) {
40
+ // Skip when alias and canonical produce the same file name (self-import)
41
+ if (fileName(enumDef.name) === fileName(canonicalName)) continue;
40
42
  const canonicalService = enumToService.get(canonicalName);
41
43
  const canonicalDir = resolveDir(canonicalService);
42
44
  const canonicalCls = className(canonicalName);
@@ -137,7 +139,15 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
137
139
  lines.push('from typing import Union');
138
140
  lines.push('from typing import Literal, TypeAlias');
139
141
  lines.push('');
140
- const literals = uniqueValues.map((v) => (typeof v.value === 'string' ? `"${v.value}"` : String(v.value)));
142
+ const literals = uniqueValues.map((v) =>
143
+ typeof v.value === 'string'
144
+ ? `"${v.value}"`
145
+ : typeof v.value === 'boolean'
146
+ ? v.value
147
+ ? 'True'
148
+ : 'False'
149
+ : String(v.value),
150
+ );
141
151
  lines.push(`${cls}: TypeAlias = Union[Literal[${literals.join(', ')}], str]`);
142
152
  files.push({
143
153
  path: `src/${ctx.namespace}/${dirName}/models/${fileName(enumDef.name)}.py`,
@@ -157,7 +167,14 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
157
167
  memberName = `${memberName}_${suffix}`;
158
168
  }
159
169
  usedNames.add(memberName);
160
- const valueStr = typeof v.value === 'string' ? `"${v.value}"` : String(v.value);
170
+ const valueStr =
171
+ typeof v.value === 'string'
172
+ ? `"${v.value}"`
173
+ : typeof v.value === 'boolean'
174
+ ? v.value
175
+ ? 'True'
176
+ : 'False'
177
+ : String(v.value);
161
178
  lines.push(` ${memberName} = ${valueStr}`);
162
179
  if (v.description || v.deprecated) {
163
180
  const parts: string[] = [];
@@ -180,7 +197,15 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
180
197
  lines.push('');
181
198
  lines.push(
182
199
  `${cls}Literal: TypeAlias = Literal[${uniqueValues
183
- .map((v) => (typeof v.value === 'string' ? `"${v.value}"` : String(v.value)))
200
+ .map((v) =>
201
+ typeof v.value === 'string'
202
+ ? `"${v.value}"`
203
+ : typeof v.value === 'boolean'
204
+ ? v.value
205
+ ? 'True'
206
+ : 'False'
207
+ : String(v.value),
208
+ )
184
209
  .join(', ')}]`,
185
210
  );
186
211
  }
@@ -8,10 +8,9 @@ import type {
8
8
  Enum,
9
9
  Service,
10
10
  } from '@workos/oagen';
11
- import * as fs from 'node:fs';
12
- import * as path from 'node:path';
13
11
 
14
12
  import { generateModels } from './models.js';
13
+ import { detectDiscriminators } from '../shared/model-utils.js';
15
14
  import { generateEnums } from './enums.js';
16
15
  import { generateResources } from './resources.js';
17
16
  import { generateClient } from './client.js';
@@ -27,11 +26,23 @@ function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
27
26
  return files;
28
27
  }
29
28
 
29
+ /**
30
+ * Annotate models with implicit discriminator info (without full oneOf
31
+ * field flattening which can break existing model structures/fixtures).
32
+ */
33
+ function withDiscriminators(ctx: EmitterContext): EmitterContext {
34
+ const annotated = detectDiscriminators(ctx.spec.models);
35
+ if (annotated === ctx.spec.models) return ctx;
36
+ const spec: ApiSpec = { ...ctx.spec, models: annotated };
37
+ return { ...ctx, spec };
38
+ }
39
+
30
40
  export const pythonEmitter: Emitter = {
31
41
  language: 'python',
32
42
 
33
43
  generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
34
- return ensureTrailingNewlines(generateModels(models, ctx));
44
+ const annotated = detectDiscriminators(models);
45
+ return ensureTrailingNewlines(generateModels(annotated, ctx));
35
46
  },
36
47
 
37
48
  generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
@@ -39,11 +50,11 @@ export const pythonEmitter: Emitter = {
39
50
  },
40
51
 
41
52
  generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
42
- return ensureTrailingNewlines(generateResources(services, ctx));
53
+ return ensureTrailingNewlines(generateResources(services, withDiscriminators(ctx)));
43
54
  },
44
55
 
45
56
  generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
46
- return ensureTrailingNewlines(generateClient(spec, ctx));
57
+ return ensureTrailingNewlines(generateClient(spec, withDiscriminators(ctx)));
47
58
  },
48
59
 
49
60
  generateErrors(): GeneratedFile[] {
@@ -59,7 +70,15 @@ export const pythonEmitter: Emitter = {
59
70
  },
60
71
 
61
72
  generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
62
- return ensureTrailingNewlines(generateTests(spec, ctx));
73
+ // Use original spec for model filtering (requestOnlyModelNames etc.) but
74
+ // annotate discriminator models so dispatch tests are generated.
75
+ const annotated = detectDiscriminators(spec.models);
76
+ // Only replace discriminator-annotated models, keep the rest unchanged
77
+ // so request-only filtering and field-based logic work correctly.
78
+ const annotatedByName = new Map(annotated.filter((m: any) => m.discriminator).map((m) => [m.name, m]));
79
+ const testModels = spec.models.map((m) => annotatedByName.get(m.name) ?? m);
80
+ const testSpec: ApiSpec = { ...spec, models: testModels };
81
+ return ensureTrailingNewlines(generateTests(testSpec, { ...ctx, spec: testSpec }));
63
82
  },
64
83
 
65
84
  generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
@@ -70,26 +89,15 @@ export const pythonEmitter: Emitter = {
70
89
  return '# This file is auto-generated by oagen. Do not edit.';
71
90
  },
72
91
 
73
- formatCommand(targetDir: string): FormatCommand | null {
74
- const hasRuff =
75
- fs.existsSync(path.join(targetDir, 'ruff.toml')) || fs.existsSync(path.join(targetDir, '.ruff.toml'));
76
- // Also check pyproject.toml for [tool.ruff] section
77
- const pyproject = path.join(targetDir, 'pyproject.toml');
78
- const hasRuffInPyproject = fs.existsSync(pyproject) && fs.readFileSync(pyproject, 'utf8').includes('[tool.ruff]');
79
- // Check for noxfile that uses ruff
80
- const noxfile = path.join(targetDir, 'noxfile.py');
81
- const hasRuffInNox = fs.existsSync(noxfile) && fs.readFileSync(noxfile, 'utf8').includes('ruff');
82
-
83
- if (hasRuff || hasRuffInPyproject || hasRuffInNox) {
84
- return {
85
- cmd: 'bash',
86
- args: [
87
- '-c',
88
- 'PY_FILES=$(printf "%s\\n" "$@" | grep "\\.py$"); [ -n "$PY_FILES" ] && echo "$PY_FILES" | xargs ruff check --fix --quiet 2>/dev/null; [ -n "$PY_FILES" ] && echo "$PY_FILES" | xargs ruff format --quiet',
89
- '--',
90
- ],
91
- };
92
- }
93
- return null;
92
+ formatCommand(_targetDir: string): FormatCommand | null {
93
+ return {
94
+ cmd: 'bash',
95
+ args: [
96
+ '-c',
97
+ 'PY_FILES=$(printf "%s\\n" "$@" | grep "\\.py$" || true); if [ -n "$PY_FILES" ]; then echo "$PY_FILES" | xargs ruff check --fix --quiet 2>/dev/null; echo "$PY_FILES" | xargs ruff format --quiet; fi',
98
+ '--',
99
+ ],
100
+ batchSize: 1000,
101
+ };
94
102
  },
95
103
  };
@@ -18,6 +18,9 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
18
18
  irService ? (mountDirMap.get(irService) ?? 'common') : 'common';
19
19
  const files: GeneratedFile[] = [];
20
20
  const emittedModelSymbolsByDir = new Map<string, string[]>();
21
+ // Overrides fileName() for symbols that live in a differently-named file.
22
+ // Used for variant type aliases (e.g. EventSchemaVariant → event_schema).
23
+ const symbolToFile = new Map<string, string>();
21
24
  const modelUsage = collectModelUsage(ctx.spec);
22
25
 
23
26
  // Build recursive structural hashes for deduplication.
@@ -45,6 +48,10 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
45
48
  }
46
49
  }
47
50
 
51
+ // Track emitted file paths to prevent duplicates when synthetic models from
52
+ // oneOf enrichment collide with existing IR models in snake_case.
53
+ const emittedFilePaths = new Set<string>();
54
+
48
55
  for (const model of models) {
49
56
  // Skip list wrapper models (e.g., OrganizationList) — SyncPage handles envelopes
50
57
  if (isListWrapperModel(model)) continue;
@@ -55,9 +62,135 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
55
62
  const dirName = resolveDir(service);
56
63
  const modelClassName = className(model.name);
57
64
 
65
+ // Skip models whose file path was already emitted (name collision after snake_case)
66
+ const modelFilePath = `src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`;
67
+ if (emittedFilePaths.has(modelFilePath)) continue;
68
+ emittedFilePaths.add(modelFilePath);
69
+
70
+ // If this model is a discriminated union dispatcher, generate a factory class
71
+ // instead of a regular dataclass (e.g. EventSchema where each variant has
72
+ // event: const: "..."). The dispatcher owns a Union type alias and a from_dict
73
+ // method that routes to the correct concrete variant at runtime.
74
+ if ((model as any).discriminator) {
75
+ const disc = (model as any).discriminator as { property: string; mapping: Record<string, string> };
76
+ const variantNames = Object.values(disc.mapping); // model names, e.g. ["ActionAuthenticationDenied", ...]
77
+ const variantTypeName = `${modelClassName}Variant`; // e.g. "EventSchemaVariant"
78
+
79
+ const dispLines: string[] = [];
80
+ dispLines.push('from __future__ import annotations');
81
+ dispLines.push('');
82
+ dispLines.push('from dataclasses import dataclass');
83
+ dispLines.push('from typing import Any, ClassVar, Dict, Union, cast');
84
+ dispLines.push(`from ${ctx.namespace}._types import _raise_deserialize_error`);
85
+ dispLines.push('');
86
+
87
+ // Import each variant model from its resolved location
88
+ const sortedVariants = [...new Set(variantNames)].sort();
89
+ for (const variantModelName of sortedVariants) {
90
+ const variantService = modelToService.get(variantModelName);
91
+ const variantDir = resolveDir(variantService);
92
+ if (variantDir === dirName) {
93
+ dispLines.push(`from .${fileName(variantModelName)} import ${className(variantModelName)}`);
94
+ } else {
95
+ dispLines.push(
96
+ `from ${ctx.namespace}.${dirToModule(variantDir)}.models.${fileName(variantModelName)} import ${className(variantModelName)}`,
97
+ );
98
+ }
99
+ }
100
+
101
+ // Unknown variant for forward-compatible unknown discriminator values
102
+ const unknownClassName = `${modelClassName}Unknown`;
103
+ dispLines.push('');
104
+ dispLines.push('');
105
+ dispLines.push('@dataclass(slots=True)');
106
+ dispLines.push(`class ${unknownClassName}:`);
107
+ dispLines.push(` """Unknown variant of ${modelClassName} not yet recognized by this SDK version."""`);
108
+ dispLines.push('');
109
+ dispLines.push(' raw_data: Dict[str, Any]');
110
+ dispLines.push(' """The raw payload, preserved so callers can still inspect the data."""');
111
+ dispLines.push('');
112
+ dispLines.push(' @classmethod');
113
+ dispLines.push(` def from_dict(cls, data: Dict[str, Any]) -> "${unknownClassName}":`);
114
+ dispLines.push(' """Wrap raw data in an unknown variant."""');
115
+ dispLines.push(' return cls(raw_data=data)');
116
+ dispLines.push('');
117
+ dispLines.push(' def to_dict(self) -> Dict[str, Any]:');
118
+ dispLines.push(' """Return the original raw data."""');
119
+ dispLines.push(' return dict(self.raw_data)');
120
+
121
+ dispLines.push('');
122
+ dispLines.push('');
123
+
124
+ // Union type alias — includes unknown variant for forward compatibility
125
+ dispLines.push(`${variantTypeName} = Union[`);
126
+ for (const variantModelName of sortedVariants) {
127
+ dispLines.push(` ${className(variantModelName)},`);
128
+ }
129
+ dispLines.push(` ${unknownClassName},`);
130
+ dispLines.push(']');
131
+
132
+ dispLines.push('');
133
+ dispLines.push('');
134
+
135
+ // Dispatcher class
136
+ if (model.description) {
137
+ dispLines.push(`class ${modelClassName}:`);
138
+ dispLines.push(` """${model.description}"""`);
139
+ } else {
140
+ dispLines.push(`class ${modelClassName}:`);
141
+ dispLines.push(` """Discriminated union dispatcher (discriminated by '${disc.property}')."""`);
142
+ }
143
+ dispLines.push('');
144
+ dispLines.push(` _DISPATCH: ClassVar[Dict[str, type]] = {`);
145
+ for (const [value, variantModelName] of Object.entries(disc.mapping).sort(([a], [b]) => a.localeCompare(b))) {
146
+ dispLines.push(` "${value}": ${className(variantModelName)},`);
147
+ }
148
+ dispLines.push(' }');
149
+ dispLines.push('');
150
+ dispLines.push(' @classmethod');
151
+ dispLines.push(` def from_dict(cls, data: Dict[str, Any]) -> "${variantTypeName}":`);
152
+ dispLines.push(' """Deserialize from a dictionary, dispatching to the correct variant."""');
153
+ dispLines.push(` if "${disc.property}" not in data:`);
154
+ dispLines.push(
155
+ ` _raise_deserialize_error("${modelClassName}", ValueError("Missing required field '${disc.property}'"))`,
156
+ );
157
+ dispLines.push(` disc_value = data["${disc.property}"]`);
158
+ dispLines.push(' if disc_value is None:');
159
+ dispLines.push(
160
+ ` _raise_deserialize_error("${modelClassName}", ValueError("${disc.property} must not be None"))`,
161
+ );
162
+ dispLines.push(' dispatch_cls = cls._DISPATCH.get(disc_value)');
163
+ dispLines.push(' if dispatch_cls is not None:');
164
+ dispLines.push(` return cast("${variantTypeName}", dispatch_cls.from_dict(data))`);
165
+ dispLines.push(` return ${unknownClassName}.from_dict(data)`);
166
+
167
+ files.push({
168
+ path: `src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`,
169
+ content: dispLines.join('\n'),
170
+ integrateTarget: true,
171
+ overwriteExisting: true,
172
+ });
173
+
174
+ if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
175
+ emittedModelSymbolsByDir.get(dirName)!.push(model.name);
176
+ // Also register the variant type alias and unknown variant in the barrel,
177
+ // pointing to the same file as the dispatcher.
178
+ emittedModelSymbolsByDir.get(dirName)!.push(variantTypeName);
179
+ symbolToFile.set(variantTypeName, fileName(model.name));
180
+ emittedModelSymbolsByDir.get(dirName)!.push(unknownClassName);
181
+ symbolToFile.set(unknownClassName, fileName(model.name));
182
+ continue;
183
+ }
184
+
58
185
  // If this model is an alias for a canonical model, generate a type alias file
59
186
  const canonicalName = aliasOf.get(model.name);
60
187
  if (canonicalName) {
188
+ // Skip when alias and canonical produce the same file name (self-import).
189
+ // This happens when synthetic models from oneOf enrichment collide with
190
+ // existing IR models in snake_case (e.g., Foo_bar vs FooBar).
191
+ if (fileName(model.name) === fileName(canonicalName)) {
192
+ continue;
193
+ }
61
194
  const canonicalService = modelToService.get(canonicalName);
62
195
  const canonicalDir = resolveDir(canonicalService);
63
196
  const canonicalClassName = className(canonicalName);
@@ -312,7 +445,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
312
445
  const uniqueNames = [...new Set(names)].sort();
313
446
  const importLines: string[] = [];
314
447
  for (const name of uniqueNames) {
315
- importLines.push(`from .${fileName(name)} import ${className(name)} as ${className(name)}`);
448
+ const fileNameForSymbol = symbolToFile.get(name) ?? fileName(name);
449
+ importLines.push(`from .${fileNameForSymbol} import ${className(name)} as ${className(name)}`);
316
450
  }
317
451
  const imports = importLines.join('\n');
318
452
  files.push({
@@ -473,6 +607,9 @@ function compareAliasPriority(left: string, right: string, usage: ReturnType<typ
473
607
  }
474
608
 
475
609
  function canAliasModels(canonical: string, alias: string, usage: ReturnType<typeof collectModelUsage>): boolean {
610
+ // Don't alias when both models produce the same file name — the TypeAlias
611
+ // file would import from itself (e.g., FooBar aliased to Foo_bar).
612
+ if (fileName(canonical) === fileName(alias)) return false;
476
613
  // Don't alias across request/response boundaries — a request-only model
477
614
  // and a response-only model may look identical today but evolve independently.
478
615
  if (