Skip to content

Commit 76a9ece

Browse files
committed
feat: support Responses API translation
1 parent 5b48e64 commit 76a9ece

1 file changed

Lines changed: 280 additions & 3 deletions

File tree

Lines changed: 280 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System.IO.Pipelines;
2+
using System.Text;
13
using System.Text.Json;
24
using AiRouter.Models;
35

@@ -7,7 +9,282 @@ public sealed class ResponsesTranslationException(string message) : Exception(me
79

810
public 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()}\ndata: {json}\n\n");
288+
await writer.WriteAsync(bytes, ct).ConfigureAwait(false);
289+
}
13290
}

0 commit comments

Comments
 (0)