1+ using System . IO . Pipelines ;
2+ using System . Text ;
13using System . Text . Json ;
24using AiRouter . Models ;
35
@@ -7,7 +9,282 @@ public sealed class ResponsesTranslationException(string message) : Exception(me
79
810public sealed class OpenAiResponsesTranslator
911{
10- public ChatCompletionRequest ToChatRequest ( ResponsesRequest request ) => throw new NotImplementedException ( ) ;
11- public JsonElement ToResponsesResponse ( JsonElement chatResponse , string model ) => throw new NotImplementedException ( ) ;
12- public Stream ToResponsesStream ( Stream chatSse , CancellationToken ct = default ) => throw new NotImplementedException ( ) ;
12+ private static readonly JsonSerializerOptions Json = new ( JsonSerializerDefaults . Web ) ;
13+
14+ public ChatCompletionRequest ToChatRequest ( ResponsesRequest request )
15+ {
16+ ArgumentNullException . ThrowIfNull ( request ) ;
17+
18+ if ( request . AdditionalProperties . Count > 0 )
19+ {
20+ var unsupported = request . AdditionalProperties . Keys . Order ( StringComparer . Ordinal ) . First ( ) ;
21+ throw new ResponsesTranslationException ( $ "Responses feature '{ unsupported } ' is not supported by the chat-completions compatibility mode.") ;
22+ }
23+
24+ var result = new ChatCompletionRequest
25+ {
26+ Model = request . Model ,
27+ Stream = request . Stream ,
28+ Temperature = request . Temperature ,
29+ TopP = request . TopP ,
30+ MaxTokens = request . MaxOutputTokens ,
31+ ToolChoice = request . ToolChoice ? . Clone ( )
32+ } ;
33+
34+ if ( ! string . IsNullOrWhiteSpace ( request . Instructions ) )
35+ {
36+ result . Messages . Add ( new ChatMessage
37+ {
38+ Role = "system" ,
39+ Content = JsonSerializer . SerializeToElement ( request . Instructions , Json )
40+ } ) ;
41+ }
42+
43+ AppendInputMessages ( request . Input , result . Messages ) ;
44+ result . Tools = TranslateTools ( request . Tools ) ;
45+
46+ return result ;
47+ }
48+
49+ public JsonElement ToResponsesResponse ( JsonElement chatResponse , string model )
50+ {
51+ var id = chatResponse . TryGetProperty ( "id" , out var idElement ) && idElement . ValueKind == JsonValueKind . String
52+ ? idElement . GetString ( )
53+ : $ "resp_{ Guid . NewGuid ( ) : N} ";
54+
55+ var actualModel = chatResponse . TryGetProperty ( "model" , out var modelElement ) && modelElement . ValueKind == JsonValueKind . String
56+ ? modelElement . GetString ( )
57+ : model ;
58+
59+ var text = ExtractAssistantText ( chatResponse ) ;
60+ var usage = TranslateUsage ( chatResponse ) ;
61+
62+ return JsonSerializer . SerializeToElement ( new
63+ {
64+ id ,
65+ @object = "response" ,
66+ status = "completed" ,
67+ model = actualModel ,
68+ output = new [ ]
69+ {
70+ new
71+ {
72+ id = $ "msg_{ Guid . NewGuid ( ) : N} ",
73+ type = "message" ,
74+ role = "assistant" ,
75+ status = "completed" ,
76+ content = new [ ]
77+ {
78+ new
79+ {
80+ type = "output_text" ,
81+ text
82+ }
83+ }
84+ }
85+ } ,
86+ usage
87+ } , Json ) ;
88+ }
89+
90+ public Stream ToResponsesStream ( Stream chatSse , CancellationToken ct = default )
91+ {
92+ ArgumentNullException . ThrowIfNull ( chatSse ) ;
93+
94+ var pipe = new Pipe ( ) ;
95+ _ = PumpSseAsync ( chatSse , pipe . Writer , ct ) ;
96+ return pipe . Reader . AsStream ( ) ;
97+ }
98+
99+ private static void AppendInputMessages ( JsonElement input , ICollection < ChatMessage > messages )
100+ {
101+ switch ( input . ValueKind )
102+ {
103+ case JsonValueKind . String :
104+ messages . Add ( new ChatMessage
105+ {
106+ Role = "user" ,
107+ Content = input . Clone ( )
108+ } ) ;
109+ return ;
110+
111+ case JsonValueKind . Array :
112+ foreach ( var item in input . EnumerateArray ( ) )
113+ {
114+ if ( item . ValueKind != JsonValueKind . Object ||
115+ ! item . TryGetProperty ( "role" , out var role ) || role . ValueKind != JsonValueKind . String ||
116+ ! item . TryGetProperty ( "content" , out var content ) )
117+ {
118+ throw new ResponsesTranslationException ( "Structured Responses input must contain objects with string 'role' and 'content'." ) ;
119+ }
120+
121+ messages . Add ( new ChatMessage
122+ {
123+ Role = role . GetString ( ) ! ,
124+ Content = content . Clone ( )
125+ } ) ;
126+ }
127+ return ;
128+
129+ case JsonValueKind . Undefined :
130+ case JsonValueKind . Null :
131+ throw new ResponsesTranslationException ( "Responses input is required." ) ;
132+
133+ default :
134+ throw new ResponsesTranslationException ( "Responses input must be a string or an array of message objects in compatibility mode." ) ;
135+ }
136+ }
137+
138+ private static JsonElement ? TranslateTools ( JsonElement ? tools )
139+ {
140+ if ( tools is null || tools . Value . ValueKind is JsonValueKind . Null or JsonValueKind . Undefined )
141+ return null ;
142+
143+ if ( tools . Value . ValueKind != JsonValueKind . Array )
144+ throw new ResponsesTranslationException ( "Responses tools must be an array." ) ;
145+
146+ var translated = new List < object > ( ) ;
147+ foreach ( var tool in tools . Value . EnumerateArray ( ) )
148+ {
149+ if ( tool . ValueKind != JsonValueKind . Object ||
150+ ! tool . TryGetProperty ( "type" , out var type ) ||
151+ ! string . Equals ( type . GetString ( ) , "function" , StringComparison . Ordinal ) )
152+ {
153+ throw new ResponsesTranslationException ( "Only function tools are supported by the chat-completions compatibility mode." ) ;
154+ }
155+
156+ if ( ! tool . TryGetProperty ( "name" , out var name ) || name . ValueKind != JsonValueKind . String )
157+ throw new ResponsesTranslationException ( "Function tools require a name." ) ;
158+
159+ var description = tool . TryGetProperty ( "description" , out var descriptionElement ) && descriptionElement . ValueKind == JsonValueKind . String
160+ ? descriptionElement . GetString ( )
161+ : null ;
162+ object ? parameters = tool . TryGetProperty ( "parameters" , out var parametersElement )
163+ ? JsonSerializer . Deserialize < object > ( parametersElement . GetRawText ( ) , Json )
164+ : null ;
165+
166+ translated . Add ( new
167+ {
168+ type = "function" ,
169+ function = new
170+ {
171+ name = name . GetString ( ) ,
172+ description ,
173+ parameters
174+ }
175+ } ) ;
176+ }
177+
178+ return JsonSerializer . SerializeToElement ( translated , Json ) ;
179+ }
180+
181+ private static string ExtractAssistantText ( JsonElement chatResponse )
182+ {
183+ if ( ! chatResponse . TryGetProperty ( "choices" , out var choices ) || choices . ValueKind != JsonValueKind . Array )
184+ return string . Empty ;
185+
186+ foreach ( var choice in choices . EnumerateArray ( ) )
187+ {
188+ if ( ! choice . TryGetProperty ( "message" , out var message ) || message . ValueKind != JsonValueKind . Object )
189+ continue ;
190+ if ( ! message . TryGetProperty ( "content" , out var content ) )
191+ continue ;
192+
193+ return content . ValueKind == JsonValueKind . String ? content . GetString ( ) ?? string . Empty : content . GetRawText ( ) ;
194+ }
195+
196+ return string . Empty ;
197+ }
198+
199+ private static object ? TranslateUsage ( JsonElement chatResponse )
200+ {
201+ if ( ! chatResponse . TryGetProperty ( "usage" , out var usage ) || usage . ValueKind != JsonValueKind . Object )
202+ return null ;
203+
204+ int ? Read ( string name ) => usage . TryGetProperty ( name , out var value ) && value . TryGetInt32 ( out var number ) ? number : null ;
205+
206+ return new
207+ {
208+ input_tokens = Read ( "prompt_tokens" ) ,
209+ output_tokens = Read ( "completion_tokens" ) ,
210+ total_tokens = Read ( "total_tokens" )
211+ } ;
212+ }
213+
214+ private static async Task PumpSseAsync ( Stream source , PipeWriter writer , CancellationToken ct )
215+ {
216+ Exception ? failure = null ;
217+ try
218+ {
219+ using var reader = new StreamReader ( source , Encoding . UTF8 , detectEncodingFromByteOrderMarks : false , bufferSize : 1024 , leaveOpen : true ) ;
220+ while ( true )
221+ {
222+ ct . ThrowIfCancellationRequested ( ) ;
223+ var line = await reader . ReadLineAsync ( ct ) . ConfigureAwait ( false ) ;
224+ if ( line is null )
225+ break ;
226+ if ( ! line . StartsWith ( "data:" , StringComparison . Ordinal ) )
227+ continue ;
228+
229+ var payload = line [ 5 ..] . TrimStart ( ) ;
230+ if ( payload . Length == 0 )
231+ continue ;
232+
233+ if ( string . Equals ( payload , "[DONE]" , StringComparison . Ordinal ) )
234+ {
235+ await WriteEventAsync ( writer , new
236+ {
237+ type = "response.completed" ,
238+ response = new { status = "completed" }
239+ } , ct ) . ConfigureAwait ( false ) ;
240+ break ;
241+ }
242+
243+ using var chunk = JsonDocument . Parse ( payload ) ;
244+ var delta = ExtractDelta ( chunk . RootElement ) ;
245+ if ( delta is null )
246+ continue ;
247+
248+ await WriteEventAsync ( writer , new
249+ {
250+ type = "response.output_text.delta" ,
251+ delta
252+ } , ct ) . ConfigureAwait ( false ) ;
253+ }
254+ }
255+ catch ( Exception ex )
256+ {
257+ failure = ex ;
258+ }
259+ finally
260+ {
261+ await source . DisposeAsync ( ) . ConfigureAwait ( false ) ;
262+ await writer . CompleteAsync ( failure ) . ConfigureAwait ( false ) ;
263+ }
264+ }
265+
266+ private static string ? ExtractDelta ( JsonElement chunk )
267+ {
268+ if ( ! chunk . TryGetProperty ( "choices" , out var choices ) || choices . ValueKind != JsonValueKind . Array )
269+ return null ;
270+
271+ foreach ( var choice in choices . EnumerateArray ( ) )
272+ {
273+ if ( ! choice . TryGetProperty ( "delta" , out var deltaObject ) || deltaObject . ValueKind != JsonValueKind . Object )
274+ continue ;
275+ if ( ! deltaObject . TryGetProperty ( "content" , out var content ) || content . ValueKind != JsonValueKind . String )
276+ continue ;
277+
278+ return content . GetString ( ) ;
279+ }
280+
281+ return null ;
282+ }
283+
284+ private static async Task WriteEventAsync ( PipeWriter writer , object payload , CancellationToken ct )
285+ {
286+ var json = JsonSerializer . Serialize ( payload , Json ) ;
287+ var bytes = Encoding . UTF8 . GetBytes ( $ "event: { JsonSerializer . SerializeToElement ( payload , Json ) . GetProperty ( "type" ) . GetString ( ) } \n data: { json } \n \n ") ;
288+ await writer . WriteAsync ( bytes , ct ) . ConfigureAwait ( false ) ;
289+ }
13290}
0 commit comments