Skip to content

Commit 1f426b5

Browse files
Add OpenAI Responses API support to all five plugins
Co-authored-by: waldekmastykarz <[email protected]>
1 parent 54b96dc commit 1f426b5

File tree

5 files changed

+331
-107
lines changed

5 files changed

+331
-107
lines changed

DevProxy.Abstractions/LanguageModel/OpenAIModels.cs

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ public static bool TryGetOpenAIRequest(string content, ILogger logger, out OpenA
4242

4343
var rawRequest = JsonSerializer.Deserialize<JsonElement>(content, ProxyUtils.JsonSerializerOptions);
4444

45+
// Responses API request (check first as it's the recommended API)
46+
if (rawRequest.TryGetProperty("input", out _) &&
47+
rawRequest.TryGetProperty("modalities", out _))
48+
{
49+
logger.LogDebug("Request is a Responses API request");
50+
request = JsonSerializer.Deserialize<OpenAIResponsesRequest>(content, ProxyUtils.JsonSerializerOptions);
51+
return true;
52+
}
53+
4554
// Check for completion request (has "prompt", but not specific to image)
4655
if (rawRequest.TryGetProperty("prompt", out _) &&
4756
!rawRequest.TryGetProperty("size", out _) &&
@@ -63,7 +72,8 @@ public static bool TryGetOpenAIRequest(string content, ILogger logger, out OpenA
6372
// Embedding request
6473
if (rawRequest.TryGetProperty("input", out _) &&
6574
rawRequest.TryGetProperty("model", out _) &&
66-
!rawRequest.TryGetProperty("voice", out _))
75+
!rawRequest.TryGetProperty("voice", out _) &&
76+
!rawRequest.TryGetProperty("modalities", out _))
6777
{
6878
logger.LogDebug("Request is an embedding request");
6979
request = JsonSerializer.Deserialize<OpenAIEmbeddingRequest>(content, ProxyUtils.JsonSerializerOptions);
@@ -409,3 +419,69 @@ public class OpenAIImageData
409419
[JsonPropertyName("revised_prompt")]
410420
public string? RevisedPrompt { get; set; }
411421
}
422+
423+
#region Responses API
424+
425+
public class OpenAIResponsesRequest : OpenAIRequest
426+
{
427+
public object? Input { get; set; }
428+
public IEnumerable<string>? Modalities { get; set; }
429+
public string? Instructions { get; set; }
430+
public bool? Store { get; set; }
431+
[JsonPropertyName("previous_response_id")]
432+
public string? PreviousResponseId { get; set; }
433+
public object? Tools { get; set; }
434+
[JsonPropertyName("max_output_tokens")]
435+
public long? MaxOutputTokens { get; set; }
436+
}
437+
438+
public class OpenAIResponsesResponse : OpenAIResponse
439+
{
440+
public IEnumerable<OpenAIResponsesOutputItem>? Output { get; set; }
441+
[JsonPropertyName("created_at")]
442+
public long CreatedAt { get; set; }
443+
public string? Status { get; set; }
444+
445+
public override string? Response
446+
{
447+
get
448+
{
449+
if (Output is null || !Output.Any())
450+
{
451+
return null;
452+
}
453+
454+
// Find the last message-type output item with text content
455+
var lastMessage = Output
456+
.Where(item => item.Type == "message")
457+
.LastOrDefault();
458+
459+
if (lastMessage?.Content is null)
460+
{
461+
return null;
462+
}
463+
464+
// Extract text from content array
465+
var textContent = lastMessage.Content
466+
.Where(c => c.Type == "output_text")
467+
.LastOrDefault();
468+
469+
return textContent?.Text;
470+
}
471+
}
472+
}
473+
474+
public class OpenAIResponsesOutputItem
475+
{
476+
public string? Type { get; set; }
477+
public string? Role { get; set; }
478+
public IEnumerable<OpenAIResponsesContentPart>? Content { get; set; }
479+
}
480+
481+
public class OpenAIResponsesContentPart
482+
{
483+
public string? Type { get; set; }
484+
public string? Text { get; set; }
485+
}
486+
487+
#endregion

DevProxy.Plugins/Behavior/LanguageModelFailurePlugin.cs

Lines changed: 38 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ public override async Task BeforeRequestAsync(ProxyRequestArgs e, CancellationTo
7373
return;
7474
}
7575

76-
if (!TryGetOpenAIRequest(request.BodyString, out var openAiRequest))
76+
if (!OpenAIRequest.TryGetOpenAIRequest(request.BodyString, Logger, out var openAiRequest))
7777
{
7878
Logger.LogRequest("Skipping non-OpenAI request", MessageType.Skipped, new(e.Session));
7979
return;
@@ -116,53 +116,52 @@ public override async Task BeforeRequestAsync(ProxyRequestArgs e, CancellationTo
116116
Logger.LogRequest($"Simulating fault {faultName}", MessageType.Chaos, new(e.Session));
117117
e.Session.SetRequestBodyString(JsonSerializer.Serialize(newRequest, ProxyUtils.JsonSerializerOptions));
118118
}
119-
else
120-
{
121-
Logger.LogDebug("Unknown OpenAI request type. Passing request as-is.");
122-
}
123-
124-
await Task.CompletedTask;
125-
126-
Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync));
127-
}
128-
129-
private bool TryGetOpenAIRequest(string content, out OpenAIRequest? request)
130-
{
131-
request = null;
132-
133-
if (string.IsNullOrEmpty(content))
134-
{
135-
return false;
136-
}
137-
138-
try
119+
else if (openAiRequest is OpenAIResponsesRequest responsesRequest)
139120
{
140-
Logger.LogDebug("Checking if the request is an OpenAI request...");
141-
142-
var rawRequest = JsonSerializer.Deserialize<JsonElement>(content, ProxyUtils.JsonSerializerOptions);
143-
144-
if (rawRequest.TryGetProperty("prompt", out _))
121+
// Handle Responses API
122+
if (responsesRequest.Input is string inputString)
145123
{
146-
Logger.LogDebug("Request is a completion request");
147-
request = JsonSerializer.Deserialize<OpenAICompletionRequest>(content, ProxyUtils.JsonSerializerOptions);
148-
return true;
124+
// Simple string input - append fault prompt
125+
responsesRequest.Input = inputString + "\n\n" + faultPrompt;
126+
Logger.LogDebug("Modified Responses API string input with fault prompt");
149127
}
150-
151-
if (rawRequest.TryGetProperty("messages", out _))
128+
else if (responsesRequest.Input is JsonElement inputElement)
152129
{
153-
Logger.LogDebug("Request is a chat completion request");
154-
request = JsonSerializer.Deserialize<OpenAIChatCompletionRequest>(content, ProxyUtils.JsonSerializerOptions);
155-
return true;
130+
// Structured input - append as new message item
131+
try
132+
{
133+
var items = JsonSerializer.Deserialize<List<JsonElement>>(inputElement.GetRawText(), ProxyUtils.JsonSerializerOptions) ?? [];
134+
var faultItem = JsonSerializer.SerializeToElement(new
135+
{
136+
role = "user",
137+
content = new[]
138+
{
139+
new { type = "input_text", text = faultPrompt }
140+
}
141+
}, ProxyUtils.JsonSerializerOptions);
142+
items.Add(faultItem);
143+
responsesRequest.Input = items;
144+
Logger.LogDebug("Added fault prompt as new item to Responses API input");
145+
}
146+
catch (JsonException)
147+
{
148+
// If we can't parse as array, append to input as string
149+
responsesRequest.Input = inputElement.GetRawText() + "\n\n" + faultPrompt;
150+
Logger.LogDebug("Modified Responses API input with fault prompt (fallback)");
151+
}
156152
}
157153

158-
Logger.LogDebug("Request is not an OpenAI request.");
159-
return false;
154+
Logger.LogRequest($"Simulating fault {faultName}", MessageType.Chaos, new(e.Session));
155+
e.Session.SetRequestBodyString(JsonSerializer.Serialize(responsesRequest, ProxyUtils.JsonSerializerOptions));
160156
}
161-
catch (JsonException ex)
157+
else
162158
{
163-
Logger.LogDebug(ex, "Failed to deserialize OpenAI request.");
164-
return false;
159+
Logger.LogDebug("Unknown OpenAI request type. Passing request as-is.");
165160
}
161+
162+
await Task.CompletedTask;
163+
164+
Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync));
166165
}
167166

168167
private (string? Name, string? Prompt) GetFault()

DevProxy.Plugins/Behavior/LanguageModelRateLimitingPlugin.cs

Lines changed: 14 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca
9999
return Task.CompletedTask;
100100
}
101101

102-
if (!TryGetOpenAIRequest(request.BodyString, out var openAiRequest))
102+
if (!OpenAIRequest.TryGetOpenAIRequest(request.BodyString, Logger, out var openAiRequest))
103103
{
104104
Logger.LogRequest("Skipping non-OpenAI request", MessageType.Skipped, new(e.Session));
105105
return Task.CompletedTask;
@@ -224,7 +224,7 @@ public override Task BeforeResponseAsync(ProxyResponseArgs e, CancellationToken
224224
return Task.CompletedTask;
225225
}
226226

227-
if (!TryGetOpenAIRequest(request.BodyString, out var openAiRequest))
227+
if (!OpenAIRequest.TryGetOpenAIRequest(request.BodyString, Logger, out var openAiRequest))
228228
{
229229
Logger.LogDebug("Skipping non-OpenAI request");
230230
return Task.CompletedTask;
@@ -239,7 +239,18 @@ public override Task BeforeResponseAsync(ProxyResponseArgs e, CancellationToken
239239
{
240240
try
241241
{
242-
var openAiResponse = JsonSerializer.Deserialize<OpenAIResponse>(responseBody, ProxyUtils.JsonSerializerOptions);
242+
// Try to parse as Responses API first, then fall back to standard response
243+
OpenAIResponse? openAiResponse = null;
244+
245+
if (openAiRequest is OpenAIResponsesRequest)
246+
{
247+
openAiResponse = JsonSerializer.Deserialize<OpenAIResponsesResponse>(responseBody, ProxyUtils.JsonSerializerOptions);
248+
}
249+
else
250+
{
251+
openAiResponse = JsonSerializer.Deserialize<OpenAIResponse>(responseBody, ProxyUtils.JsonSerializerOptions);
252+
}
253+
243254
if (openAiResponse?.Usage != null)
244255
{
245256
var promptTokens = (int)openAiResponse.Usage.PromptTokens;
@@ -271,45 +282,6 @@ public override Task BeforeResponseAsync(ProxyResponseArgs e, CancellationToken
271282
return Task.CompletedTask;
272283
}
273284

274-
private bool TryGetOpenAIRequest(string content, out OpenAIRequest? request)
275-
{
276-
request = null;
277-
278-
if (string.IsNullOrEmpty(content))
279-
{
280-
return false;
281-
}
282-
283-
try
284-
{
285-
Logger.LogDebug("Checking if the request is an OpenAI request...");
286-
287-
var rawRequest = JsonSerializer.Deserialize<JsonElement>(content, ProxyUtils.JsonSerializerOptions);
288-
289-
if (rawRequest.TryGetProperty("prompt", out _))
290-
{
291-
Logger.LogDebug("Request is a completion request");
292-
request = JsonSerializer.Deserialize<OpenAICompletionRequest>(content, ProxyUtils.JsonSerializerOptions);
293-
return true;
294-
}
295-
296-
if (rawRequest.TryGetProperty("messages", out _))
297-
{
298-
Logger.LogDebug("Request is a chat completion request");
299-
request = JsonSerializer.Deserialize<OpenAIChatCompletionRequest>(content, ProxyUtils.JsonSerializerOptions);
300-
return true;
301-
}
302-
303-
Logger.LogDebug("Request is not an OpenAI request.");
304-
return false;
305-
}
306-
catch (JsonException ex)
307-
{
308-
Logger.LogDebug(ex, "Failed to deserialize OpenAI request.");
309-
return false;
310-
}
311-
}
312-
313285
private ThrottlingInfo ShouldThrottle(Request request, string throttlingKey)
314286
{
315287
var throttleKeyForRequest = BuildThrottleKey(request);

DevProxy.Plugins/Inspection/OpenAITelemetryPlugin.cs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,9 @@ private void AddResponseTypeSpecificTags(Activity activity, OpenAIRequest openAi
341341
{
342342
switch (openAiRequest)
343343
{
344+
case OpenAIResponsesRequest:
345+
AddResponsesApiResponseTags(activity, openAiRequest, responseBody);
346+
break;
344347
case OpenAIChatCompletionRequest:
345348
AddChatCompletionResponseTags(activity, openAiRequest, responseBody);
346349
break;
@@ -532,6 +535,9 @@ private void AddRequestTypeSpecificTags(Activity activity, OpenAIRequest openAiR
532535
{
533536
switch (openAiRequest)
534537
{
538+
case OpenAIResponsesRequest responsesRequest:
539+
AddResponsesApiRequestTags(activity, responsesRequest);
540+
break;
535541
case OpenAIChatCompletionRequest chatRequest:
536542
AddChatCompletionRequestTags(activity, chatRequest);
537543
break;
@@ -905,6 +911,7 @@ private static string GetOperationName(OpenAIRequest request)
905911

906912
return request switch
907913
{
914+
OpenAIResponsesRequest => "responses",
908915
OpenAIChatCompletionRequest => "chat.completions",
909916
OpenAICompletionRequest => "completions",
910917
OpenAIEmbeddingRequest => "embeddings",
@@ -916,6 +923,73 @@ private static string GetOperationName(OpenAIRequest request)
916923
};
917924
}
918925

926+
private void AddResponsesApiRequestTags(Activity activity, OpenAIResponsesRequest responsesRequest)
927+
{
928+
Logger.LogTrace("AddResponsesApiRequestTags() called");
929+
930+
// OpenLIT
931+
_ = activity.SetTag(SemanticConvention.GEN_AI_OPERATION, "responses")
932+
// OpenTelemetry
933+
.SetTag(SemanticConvention.GEN_AI_OPERATION_NAME, "responses");
934+
935+
if (Configuration.IncludePrompt && responsesRequest.Input != null)
936+
{
937+
var inputString = responsesRequest.Input is string str ? str : JsonSerializer.Serialize(responsesRequest.Input, ProxyUtils.JsonSerializerOptions);
938+
_ = activity.SetTag(SemanticConvention.GEN_AI_CONTENT_PROMPT, inputString);
939+
}
940+
941+
if (responsesRequest.Instructions != null)
942+
{
943+
_ = activity.SetTag("ai.request.instructions", responsesRequest.Instructions);
944+
}
945+
946+
if (responsesRequest.Modalities != null)
947+
{
948+
_ = activity.SetTag("ai.request.modalities", string.Join(",", responsesRequest.Modalities));
949+
}
950+
951+
Logger.LogTrace("AddResponsesApiRequestTags() finished");
952+
}
953+
954+
private void AddResponsesApiResponseTags(Activity activity, OpenAIRequest openAIRequest, string responseBody)
955+
{
956+
Logger.LogTrace("AddResponsesApiResponseTags() called");
957+
958+
var responsesResponse = JsonSerializer.Deserialize<OpenAIResponsesResponse>(responseBody, ProxyUtils.JsonSerializerOptions);
959+
if (responsesResponse is null)
960+
{
961+
return;
962+
}
963+
964+
RecordUsageMetrics(activity, openAIRequest, responsesResponse);
965+
966+
_ = activity.SetTag(SemanticConvention.GEN_AI_RESPONSE_ID, responsesResponse.Id);
967+
968+
if (responsesResponse.Status != null)
969+
{
970+
_ = activity.SetTag("ai.response.status", responsesResponse.Status);
971+
}
972+
973+
// Extract completion text from output items
974+
if (Configuration.IncludeCompletion && responsesResponse.Output != null)
975+
{
976+
var textContent = responsesResponse.Output
977+
.Where(item => item.Type == "message" && item.Content != null)
978+
.SelectMany(item => item.Content!)
979+
.Where(c => c.Type == "output_text")
980+
.Select(c => c.Text)
981+
.Where(t => !string.IsNullOrEmpty(t))
982+
.LastOrDefault();
983+
984+
if (!string.IsNullOrEmpty(textContent))
985+
{
986+
_ = activity.SetTag(SemanticConvention.GEN_AI_CONTENT_COMPLETION, textContent);
987+
}
988+
}
989+
990+
Logger.LogTrace("AddResponsesApiResponseTags() finished");
991+
}
992+
919993
public void Dispose()
920994
{
921995
_loader?.Dispose();

0 commit comments

Comments
 (0)