Skip to content

Commit 940999b

Browse files
HavenDVclaude
andcommitted
feat: Add AsyncAPI spec support for WebSocket client code generation
Add end-to-end support for generating typed WebSocket clients from AsyncAPI 3.0 specifications, enabling auto-generation of realtime clients (ElevenLabs STT, OpenAI Realtime, etc.) that were previously hand-written. Key components: - AsyncAPI spec parser with JSON Schema to OpenAPI bridge for model reuse - WebSocket client class generation (ClientWebSocket wrapper with Connect, Send, Receive, Dispose, auth constructors, partial hooks) - Typed send methods (SendXxxAsync with JSON serialization) - Async enumerable receive method (ReceiveUpdatesAsync → IAsyncEnumerable<T>) - Source generator and CLI integration with new MSBuild properties - Snapshot tests for ElevenLabs realtime STT AsyncAPI spec All 160 unit tests and 94 snapshot tests pass. No existing generation affected. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a8da27d commit 940999b

86 files changed

Lines changed: 6541 additions & 8 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

specs/elevenlabs-realtime-stt.json

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
{
2+
"asyncapi": "3.0.0",
3+
"info": {
4+
"title": "ElevenLabs Realtime Speech-to-Text",
5+
"version": "1.0.0",
6+
"description": "ElevenLabs realtime speech-to-text WebSocket API for streaming audio transcription."
7+
},
8+
"servers": {
9+
"production": {
10+
"host": "api.elevenlabs.io",
11+
"pathname": "/v1/speech-to-text/realtime",
12+
"protocol": "wss",
13+
"description": "ElevenLabs production WebSocket server"
14+
}
15+
},
16+
"channels": {
17+
"realtime": {
18+
"address": "/v1/speech-to-text/realtime",
19+
"messages": {
20+
"inputAudioChunk": {
21+
"$ref": "#/components/messages/InputAudioChunk"
22+
},
23+
"sessionStarted": {
24+
"$ref": "#/components/messages/SessionStarted"
25+
},
26+
"partialTranscript": {
27+
"$ref": "#/components/messages/PartialTranscript"
28+
},
29+
"committedTranscript": {
30+
"$ref": "#/components/messages/CommittedTranscript"
31+
},
32+
"error": {
33+
"$ref": "#/components/messages/Error"
34+
}
35+
}
36+
}
37+
},
38+
"operations": {
39+
"sendAudioChunk": {
40+
"action": "send",
41+
"channel": {
42+
"$ref": "#/channels/realtime"
43+
},
44+
"summary": "Send an audio chunk to the server for transcription.",
45+
"messages": [
46+
{
47+
"$ref": "#/channels/realtime/messages/inputAudioChunk"
48+
}
49+
]
50+
},
51+
"receiveSessionStarted": {
52+
"action": "receive",
53+
"channel": {
54+
"$ref": "#/channels/realtime"
55+
},
56+
"summary": "Receive session started event.",
57+
"messages": [
58+
{
59+
"$ref": "#/channels/realtime/messages/sessionStarted"
60+
}
61+
]
62+
},
63+
"receivePartialTranscript": {
64+
"action": "receive",
65+
"channel": {
66+
"$ref": "#/channels/realtime"
67+
},
68+
"summary": "Receive partial transcript event.",
69+
"messages": [
70+
{
71+
"$ref": "#/channels/realtime/messages/partialTranscript"
72+
}
73+
]
74+
},
75+
"receiveCommittedTranscript": {
76+
"action": "receive",
77+
"channel": {
78+
"$ref": "#/channels/realtime"
79+
},
80+
"summary": "Receive committed transcript event.",
81+
"messages": [
82+
{
83+
"$ref": "#/channels/realtime/messages/committedTranscript"
84+
}
85+
]
86+
},
87+
"receiveError": {
88+
"action": "receive",
89+
"channel": {
90+
"$ref": "#/channels/realtime"
91+
},
92+
"summary": "Receive error event.",
93+
"messages": [
94+
{
95+
"$ref": "#/channels/realtime/messages/error"
96+
}
97+
]
98+
}
99+
},
100+
"components": {
101+
"schemas": {
102+
"InputAudioChunkPayload": {
103+
"type": "object",
104+
"description": "Audio chunk sent by the client.",
105+
"required": ["message_type", "audio_base_64", "commit", "sample_rate"],
106+
"properties": {
107+
"message_type": {
108+
"type": "string",
109+
"description": "Must be 'input_audio_chunk'.",
110+
"enum": ["input_audio_chunk"]
111+
},
112+
"audio_base_64": {
113+
"type": "string",
114+
"description": "Base64-encoded audio data."
115+
},
116+
"commit": {
117+
"type": "boolean",
118+
"description": "Whether to commit the audio buffer."
119+
},
120+
"sample_rate": {
121+
"type": "integer",
122+
"description": "Audio sample rate in Hz."
123+
},
124+
"previous_text": {
125+
"type": "string",
126+
"description": "Optional previous text context.",
127+
"nullable": true
128+
}
129+
}
130+
},
131+
"SessionConfig": {
132+
"type": "object",
133+
"description": "Session configuration returned by the server.",
134+
"properties": {
135+
"model_id": {
136+
"type": "string",
137+
"description": "The model used for transcription."
138+
},
139+
"audio_format": {
140+
"type": "string",
141+
"description": "Audio format being used."
142+
}
143+
}
144+
},
145+
"SessionStartedPayload": {
146+
"type": "object",
147+
"description": "Event sent when a session is started.",
148+
"required": ["message_type", "session_id"],
149+
"properties": {
150+
"message_type": {
151+
"type": "string",
152+
"description": "Must be 'session_started'.",
153+
"enum": ["session_started"]
154+
},
155+
"session_id": {
156+
"type": "string",
157+
"description": "Unique session identifier."
158+
},
159+
"config": {
160+
"$ref": "#/components/schemas/SessionConfig"
161+
}
162+
}
163+
},
164+
"PartialTranscriptPayload": {
165+
"type": "object",
166+
"description": "Event sent when a partial transcript is available.",
167+
"required": ["message_type", "text"],
168+
"properties": {
169+
"message_type": {
170+
"type": "string",
171+
"description": "Must be 'partial_transcript'.",
172+
"enum": ["partial_transcript"]
173+
},
174+
"text": {
175+
"type": "string",
176+
"description": "The partial transcript text."
177+
}
178+
}
179+
},
180+
"CommittedTranscriptPayload": {
181+
"type": "object",
182+
"description": "Event sent when a transcript is committed.",
183+
"required": ["message_type", "text"],
184+
"properties": {
185+
"message_type": {
186+
"type": "string",
187+
"description": "Must be 'committed_transcript'.",
188+
"enum": ["committed_transcript"]
189+
},
190+
"text": {
191+
"type": "string",
192+
"description": "The committed transcript text."
193+
}
194+
}
195+
},
196+
"ErrorPayload": {
197+
"type": "object",
198+
"description": "Error event.",
199+
"required": ["message_type", "error_type", "error"],
200+
"properties": {
201+
"message_type": {
202+
"type": "string",
203+
"description": "Must be 'error'.",
204+
"enum": ["error"]
205+
},
206+
"error_type": {
207+
"type": "string",
208+
"description": "Type of error."
209+
},
210+
"error": {
211+
"type": "string",
212+
"description": "Error message."
213+
}
214+
}
215+
}
216+
},
217+
"messages": {
218+
"InputAudioChunk": {
219+
"name": "InputAudioChunk",
220+
"summary": "Audio chunk for transcription.",
221+
"contentType": "application/json",
222+
"payload": {
223+
"$ref": "#/components/schemas/InputAudioChunkPayload"
224+
}
225+
},
226+
"SessionStarted": {
227+
"name": "SessionStarted",
228+
"summary": "Session started event.",
229+
"contentType": "application/json",
230+
"payload": {
231+
"$ref": "#/components/schemas/SessionStartedPayload"
232+
}
233+
},
234+
"PartialTranscript": {
235+
"name": "PartialTranscript",
236+
"summary": "Partial transcript event.",
237+
"contentType": "application/json",
238+
"payload": {
239+
"$ref": "#/components/schemas/PartialTranscriptPayload"
240+
}
241+
},
242+
"CommittedTranscript": {
243+
"name": "CommittedTranscript",
244+
"summary": "Committed transcript event.",
245+
"contentType": "application/json",
246+
"payload": {
247+
"$ref": "#/components/schemas/CommittedTranscriptPayload"
248+
}
249+
},
250+
"Error": {
251+
"name": "Error",
252+
"summary": "Error event.",
253+
"contentType": "application/json",
254+
"payload": {
255+
"$ref": "#/components/schemas/ErrorPayload"
256+
}
257+
}
258+
},
259+
"securitySchemes": {
260+
"apiKey": {
261+
"type": "httpApiKey",
262+
"name": "xi-api-key",
263+
"in": "header",
264+
"description": "ElevenLabs API key"
265+
}
266+
}
267+
}
268+
}

src/libs/AutoSDK.CLI/Commands/GenerateCommand.cs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,15 @@ internal sealed class GenerateCommand : Command
137137
Description = "OpenAPI override as 'path=action'. Actions: object, dictionary, remove. Repeatable.",
138138
AllowMultipleArgumentsPerToken = true,
139139
};
140-
141-
public GenerateCommand() : base(name: "generate", description: "Generates client sdk using a OpenAPI spec.")
140+
141+
private Option<string> WebSocketClientClassName { get; } = new(
142+
name: "--websocket-class-name")
143+
{
144+
DefaultValueFactory = _ => string.Empty,
145+
Description = "Override class name for the generated WebSocket client (AsyncAPI specs only).",
146+
};
147+
148+
public GenerateCommand() : base(name: "generate", description: "Generates client sdk using an OpenAPI or AsyncAPI spec.")
142149
{
143150
Arguments.Add(Input);
144151
Options.Add(Output);
@@ -157,6 +164,7 @@ internal sealed class GenerateCommand : Command
157164
Options.Add(SecuritySchemes);
158165
Options.Add(BaseUrl);
159166
Options.Add(OpenApiOverrides);
167+
Options.Add(WebSocketClientClassName);
160168

161169
SetAction(HandleAsync);
162170
}
@@ -188,6 +196,8 @@ private async Task HandleAsync(ParseResult parseResult)
188196
SecuritySchemes = parseResult.GetRequiredValue(SecuritySchemes).ToImmutableArray(),
189197
BaseUrl = parseResult.GetRequiredValue(BaseUrl),
190198
OpenApiOverrides = parseResult.GetRequiredValue(OpenApiOverrides).ToImmutableArray(),
199+
GenerateWebSocketClient = true,
200+
WebSocketClientClassName = parseResult.GetRequiredValue(WebSocketClientClassName),
191201
};
192202

193203
Console.WriteLine($"Loading {input}...");
@@ -284,6 +294,16 @@ private async Task HandleAsync(ParseResult parseResult)
284294
? [Sources.ResponseStream(data.Converters.Settings)]
285295
: [])
286296
.Concat([Sources.UnixTimestampJsonConverter(settings)])
297+
// WebSocket client generation (from AsyncAPI specs)
298+
.Concat(data.WebSocketClients
299+
.SelectMany(x => new []
300+
{
301+
Sources.WebSocketClient(x),
302+
Sources.WebSocketReceiveMethod(x),
303+
}))
304+
.Concat(data.WebSocketOperations
305+
.Where(x => x.Direction == AutoSDK.Models.WebSocketDirection.Send)
306+
.Select(x => Sources.WebSocketSendMethod(x)))
287307
.Where(x => !x.IsEmpty)
288308
.ToArray();
289309

src/libs/AutoSDK.SourceGenerators/AutoSDK.SourceGenerators.props

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,17 @@
102102
<CompilerVisibleProperty Include="AutoSDK_BaseUrl"/>
103103
<!-- OpenAPI overrides as 'path=action' separated by ;. Actions: object, dictionary, remove. Default: Empty -->
104104
<CompilerVisibleProperty Include="AutoSDK_OpenApiOverrides"/>
105+
106+
<!-- WebSocket client generation -->
107+
<!-- true/false. Default: true (when AsyncAPI detected) -->
108+
<CompilerVisibleProperty Include="AutoSDK_GenerateWebSocketClient"/>
109+
<!-- Override class name for the generated WebSocket client. Default: auto from spec title -->
110+
<CompilerVisibleProperty Include="AutoSDK_WebSocketClientClassName"/>
105111
</ItemGroup>
106112

107113
<ItemGroup>
108114
<CompilerVisibleItemMetadata Include="AdditionalFiles" MetadataName="AutoSDK_OpenApiSpecification" />
115+
<CompilerVisibleItemMetadata Include="AdditionalFiles" MetadataName="AutoSDK_AsyncApiSpecification" />
109116
<CompilerVisibleItemMetadata Include="AdditionalFiles" MetadataName="AutoSDK_Namespace" />
110117
<CompilerVisibleItemMetadata Include="AdditionalFiles" MetadataName="AutoSDK_ClassName" />
111118
</ItemGroup>

src/libs/AutoSDK.SourceGenerators/OptionsExtensions.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ public static Settings GetSettings(
8080
[]).ToImmutableArray(),
8181
BaseUrl: options.GetGlobalOption(nameof(Settings.BaseUrl), prefix) ?? string.Empty,
8282
OpenApiOverrides: (options.GetGlobalOption(nameof(Settings.OpenApiOverrides), prefix)?.Split(';') ??
83-
[]).Where(static x => !string.IsNullOrWhiteSpace(x)).ToImmutableArray());
83+
[]).Where(static x => !string.IsNullOrWhiteSpace(x)).ToImmutableArray(),
84+
GenerateWebSocketClient: options.GetBoolGlobalOption(nameof(Settings.GenerateWebSocketClient), prefix, defaultValue: Settings.Default.GenerateWebSocketClient),
85+
WebSocketClientClassName: options.GetGlobalOption(nameof(Settings.WebSocketClientClassName), prefix) ?? string.Empty);
8486

8587
string? GetOptionFromAdditionalText(string name)
8688
{

src/libs/AutoSDK.SourceGenerators/SdkGenerator.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
4747
var data = context.AdditionalTextsProvider
4848
.Combine(context.AnalyzerConfigOptionsProvider)
4949
.Where(static pair =>
50-
pair.Right.GetOption(pair.Left, "OpenApiSpecification", prefix: "AutoSDK")?.ToUpperInvariant() == "TRUE")
50+
pair.Right.GetOption(pair.Left, "OpenApiSpecification", prefix: "AutoSDK")?.ToUpperInvariant() == "TRUE" ||
51+
pair.Right.GetOption(pair.Left, "AsyncApiSpecification", prefix: "AutoSDK")?.ToUpperInvariant() == "TRUE")
5152
.Select((pair, cancellationToken) => (
5253
GetContent(pair.Left, cancellationToken),
5354
pair.Right.GetSettings(prefix: "AutoSDK", additionalText: pair.Left)))
@@ -155,6 +156,24 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
155156
.SelectAndReportExceptions((x, c) => Sources.JsonSerializerContextConverters(x, c)
156157
.AsFileWithName(), context, Id)
157158
.AddSource(context);
159+
160+
// WebSocket client generation (from AsyncAPI specs)
161+
data
162+
.SelectMany(static (x, _) => x.WebSocketClients)
163+
.SelectAndReportExceptions((x, c) => Sources.WebSocketClient(x, c)
164+
.AsFileWithName(), context, Id)
165+
.AddSource(context);
166+
data
167+
.SelectMany(static (x, _) => x.WebSocketOperations)
168+
.Where(static x => x.Direction == AutoSDK.Models.WebSocketDirection.Send)
169+
.SelectAndReportExceptions((x, c) => Sources.WebSocketSendMethod(x, c)
170+
.AsFileWithName(), context, Id)
171+
.AddSource(context);
172+
data
173+
.SelectMany(static (x, _) => x.WebSocketClients)
174+
.SelectAndReportExceptions((x, c) => Sources.WebSocketReceiveMethod(x, c)
175+
.AsFileWithName(), context, Id)
176+
.AddSource(context);
158177
}
159178

160179
private static string GetContent(AdditionalText additionalText, CancellationToken cancellationToken = default)

0 commit comments

Comments
 (0)