Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 77 additions & 1 deletion DevProxy.Abstractions/LanguageModel/OpenAIModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ public static bool TryGetOpenAIRequest(string content, ILogger logger, out OpenA

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

// Responses API request (check first as it's the recommended API)
if (rawRequest.TryGetProperty("input", out _) &&
rawRequest.TryGetProperty("modalities", out _))
{
logger.LogDebug("Request is a Responses API request");
request = JsonSerializer.Deserialize<OpenAIResponsesRequest>(content, ProxyUtils.JsonSerializerOptions);
return true;
}

// Check for completion request (has "prompt", but not specific to image)
if (rawRequest.TryGetProperty("prompt", out _) &&
!rawRequest.TryGetProperty("size", out _) &&
Expand All @@ -63,7 +72,8 @@ public static bool TryGetOpenAIRequest(string content, ILogger logger, out OpenA
// Embedding request
if (rawRequest.TryGetProperty("input", out _) &&
rawRequest.TryGetProperty("model", out _) &&
!rawRequest.TryGetProperty("voice", out _))
!rawRequest.TryGetProperty("voice", out _) &&
!rawRequest.TryGetProperty("modalities", out _))
{
logger.LogDebug("Request is an embedding request");
request = JsonSerializer.Deserialize<OpenAIEmbeddingRequest>(content, ProxyUtils.JsonSerializerOptions);
Expand Down Expand Up @@ -409,3 +419,69 @@ public class OpenAIImageData
[JsonPropertyName("revised_prompt")]
public string? RevisedPrompt { get; set; }
}

#region Responses API

public class OpenAIResponsesRequest : OpenAIRequest
{
public object? Input { get; set; }
public IEnumerable<string>? Modalities { get; set; }
public string? Instructions { get; set; }
public bool? Store { get; set; }
[JsonPropertyName("previous_response_id")]
public string? PreviousResponseId { get; set; }
public object? Tools { get; set; }
[JsonPropertyName("max_output_tokens")]
public long? MaxOutputTokens { get; set; }
}

public class OpenAIResponsesResponse : OpenAIResponse
{
public IEnumerable<OpenAIResponsesOutputItem>? Output { get; set; }
[JsonPropertyName("created_at")]
public long CreatedAt { get; set; }
public string? Status { get; set; }

public override string? Response
{
get
{
if (Output is null || !Output.Any())
{
return null;
}

// Find the last message-type output item with text content
var lastMessage = Output
.Where(item => item.Type == "message")
.LastOrDefault();

if (lastMessage?.Content is null)
{
return null;
}

// Extract text from content array
var textContent = lastMessage.Content
.Where(c => c.Type == "output_text")
.LastOrDefault();

return textContent?.Text;
}
}
}

public class OpenAIResponsesOutputItem
{
public string? Type { get; set; }
public string? Role { get; set; }
public IEnumerable<OpenAIResponsesContentPart>? Content { get; set; }
}

public class OpenAIResponsesContentPart
{
public string? Type { get; set; }
public string? Text { get; set; }
}

#endregion
77 changes: 38 additions & 39 deletions DevProxy.Plugins/Behavior/LanguageModelFailurePlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ public override async Task BeforeRequestAsync(ProxyRequestArgs e, CancellationTo
return;
}

if (!TryGetOpenAIRequest(request.BodyString, out var openAiRequest))
if (!OpenAIRequest.TryGetOpenAIRequest(request.BodyString, Logger, out var openAiRequest))
{
Logger.LogRequest("Skipping non-OpenAI request", MessageType.Skipped, new(e.Session));
return;
Expand Down Expand Up @@ -116,53 +116,52 @@ public override async Task BeforeRequestAsync(ProxyRequestArgs e, CancellationTo
Logger.LogRequest($"Simulating fault {faultName}", MessageType.Chaos, new(e.Session));
e.Session.SetRequestBodyString(JsonSerializer.Serialize(newRequest, ProxyUtils.JsonSerializerOptions));
}
else
{
Logger.LogDebug("Unknown OpenAI request type. Passing request as-is.");
}

await Task.CompletedTask;

Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync));
}

private bool TryGetOpenAIRequest(string content, out OpenAIRequest? request)
{
request = null;

if (string.IsNullOrEmpty(content))
{
return false;
}

try
else if (openAiRequest is OpenAIResponsesRequest responsesRequest)
{
Logger.LogDebug("Checking if the request is an OpenAI request...");

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

if (rawRequest.TryGetProperty("prompt", out _))
// Handle Responses API
if (responsesRequest.Input is string inputString)
{
Logger.LogDebug("Request is a completion request");
request = JsonSerializer.Deserialize<OpenAICompletionRequest>(content, ProxyUtils.JsonSerializerOptions);
return true;
// Simple string input - append fault prompt
responsesRequest.Input = inputString + "\n\n" + faultPrompt;
Logger.LogDebug("Modified Responses API string input with fault prompt");
}

if (rawRequest.TryGetProperty("messages", out _))
else if (responsesRequest.Input is JsonElement inputElement)
{
Logger.LogDebug("Request is a chat completion request");
request = JsonSerializer.Deserialize<OpenAIChatCompletionRequest>(content, ProxyUtils.JsonSerializerOptions);
return true;
// Structured input - append as new message item
try
{
var items = JsonSerializer.Deserialize<List<JsonElement>>(inputElement.GetRawText(), ProxyUtils.JsonSerializerOptions) ?? [];
var faultItem = JsonSerializer.SerializeToElement(new
{
role = "user",
content = new[]
{
new { type = "input_text", text = faultPrompt }
}
}, ProxyUtils.JsonSerializerOptions);
items.Add(faultItem);
responsesRequest.Input = items;
Logger.LogDebug("Added fault prompt as new item to Responses API input");
}
catch (JsonException)
{
// If we can't parse as array, append to input as string
responsesRequest.Input = inputElement.GetRawText() + "\n\n" + faultPrompt;
Logger.LogDebug("Modified Responses API input with fault prompt (fallback)");
}
}

Logger.LogDebug("Request is not an OpenAI request.");
return false;
Logger.LogRequest($"Simulating fault {faultName}", MessageType.Chaos, new(e.Session));
e.Session.SetRequestBodyString(JsonSerializer.Serialize(responsesRequest, ProxyUtils.JsonSerializerOptions));
}
catch (JsonException ex)
else
{
Logger.LogDebug(ex, "Failed to deserialize OpenAI request.");
return false;
Logger.LogDebug("Unknown OpenAI request type. Passing request as-is.");
}

await Task.CompletedTask;

Logger.LogTrace("Left {Name}", nameof(BeforeRequestAsync));
}

private (string? Name, string? Prompt) GetFault()
Expand Down
56 changes: 14 additions & 42 deletions DevProxy.Plugins/Behavior/LanguageModelRateLimitingPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca
return Task.CompletedTask;
}

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

if (!TryGetOpenAIRequest(request.BodyString, out var openAiRequest))
if (!OpenAIRequest.TryGetOpenAIRequest(request.BodyString, Logger, out var openAiRequest))
{
Logger.LogDebug("Skipping non-OpenAI request");
return Task.CompletedTask;
Expand All @@ -239,7 +239,18 @@ public override Task BeforeResponseAsync(ProxyResponseArgs e, CancellationToken
{
try
{
var openAiResponse = JsonSerializer.Deserialize<OpenAIResponse>(responseBody, ProxyUtils.JsonSerializerOptions);
// Try to parse as Responses API first, then fall back to standard response
OpenAIResponse? openAiResponse = null;

if (openAiRequest is OpenAIResponsesRequest)
{
openAiResponse = JsonSerializer.Deserialize<OpenAIResponsesResponse>(responseBody, ProxyUtils.JsonSerializerOptions);
}
else
{
openAiResponse = JsonSerializer.Deserialize<OpenAIResponse>(responseBody, ProxyUtils.JsonSerializerOptions);
}

if (openAiResponse?.Usage != null)
{
var promptTokens = (int)openAiResponse.Usage.PromptTokens;
Expand Down Expand Up @@ -271,45 +282,6 @@ public override Task BeforeResponseAsync(ProxyResponseArgs e, CancellationToken
return Task.CompletedTask;
}

private bool TryGetOpenAIRequest(string content, out OpenAIRequest? request)
{
request = null;

if (string.IsNullOrEmpty(content))
{
return false;
}

try
{
Logger.LogDebug("Checking if the request is an OpenAI request...");

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

if (rawRequest.TryGetProperty("prompt", out _))
{
Logger.LogDebug("Request is a completion request");
request = JsonSerializer.Deserialize<OpenAICompletionRequest>(content, ProxyUtils.JsonSerializerOptions);
return true;
}

if (rawRequest.TryGetProperty("messages", out _))
{
Logger.LogDebug("Request is a chat completion request");
request = JsonSerializer.Deserialize<OpenAIChatCompletionRequest>(content, ProxyUtils.JsonSerializerOptions);
return true;
}

Logger.LogDebug("Request is not an OpenAI request.");
return false;
}
catch (JsonException ex)
{
Logger.LogDebug(ex, "Failed to deserialize OpenAI request.");
return false;
}
}

private ThrottlingInfo ShouldThrottle(Request request, string throttlingKey)
{
var throttleKeyForRequest = BuildThrottleKey(request);
Expand Down
74 changes: 74 additions & 0 deletions DevProxy.Plugins/Inspection/OpenAITelemetryPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,9 @@ private void AddResponseTypeSpecificTags(Activity activity, OpenAIRequest openAi
{
switch (openAiRequest)
{
case OpenAIResponsesRequest:
AddResponsesApiResponseTags(activity, openAiRequest, responseBody);
break;
case OpenAIChatCompletionRequest:
AddChatCompletionResponseTags(activity, openAiRequest, responseBody);
break;
Expand Down Expand Up @@ -541,6 +544,9 @@ private void AddRequestTypeSpecificTags(Activity activity, OpenAIRequest openAiR
{
switch (openAiRequest)
{
case OpenAIResponsesRequest responsesRequest:
AddResponsesApiRequestTags(activity, responsesRequest);
break;
case OpenAIChatCompletionRequest chatRequest:
AddChatCompletionRequestTags(activity, chatRequest);
break;
Expand Down Expand Up @@ -914,6 +920,7 @@ private static string GetOperationName(OpenAIRequest request)

return request switch
{
OpenAIResponsesRequest => "responses",
OpenAIChatCompletionRequest => "chat.completions",
OpenAICompletionRequest => "completions",
OpenAIEmbeddingRequest => "embeddings",
Expand All @@ -925,6 +932,73 @@ private static string GetOperationName(OpenAIRequest request)
};
}

private void AddResponsesApiRequestTags(Activity activity, OpenAIResponsesRequest responsesRequest)
{
Logger.LogTrace("AddResponsesApiRequestTags() called");

// OpenLIT
_ = activity.SetTag(SemanticConvention.GEN_AI_OPERATION, "responses")
// OpenTelemetry
.SetTag(SemanticConvention.GEN_AI_OPERATION_NAME, "responses");

if (Configuration.IncludePrompt && responsesRequest.Input != null)
{
var inputString = responsesRequest.Input is string str ? str : JsonSerializer.Serialize(responsesRequest.Input, ProxyUtils.JsonSerializerOptions);
_ = activity.SetTag(SemanticConvention.GEN_AI_CONTENT_PROMPT, inputString);
}

if (responsesRequest.Instructions != null)
{
_ = activity.SetTag("ai.request.instructions", responsesRequest.Instructions);
}

if (responsesRequest.Modalities != null)
{
_ = activity.SetTag("ai.request.modalities", string.Join(",", responsesRequest.Modalities));
}

Logger.LogTrace("AddResponsesApiRequestTags() finished");
}

private void AddResponsesApiResponseTags(Activity activity, OpenAIRequest openAIRequest, string responseBody)
{
Logger.LogTrace("AddResponsesApiResponseTags() called");

var responsesResponse = JsonSerializer.Deserialize<OpenAIResponsesResponse>(responseBody, ProxyUtils.JsonSerializerOptions);
if (responsesResponse is null)
{
return;
}

RecordUsageMetrics(activity, openAIRequest, responsesResponse);

_ = activity.SetTag(SemanticConvention.GEN_AI_RESPONSE_ID, responsesResponse.Id);

if (responsesResponse.Status != null)
{
_ = activity.SetTag("ai.response.status", responsesResponse.Status);
}

// Extract completion text from output items
if (Configuration.IncludeCompletion && responsesResponse.Output != null)
{
var textContent = responsesResponse.Output
.Where(item => item.Type == "message" && item.Content != null)
.SelectMany(item => item.Content!)
.Where(c => c.Type == "output_text")
.Select(c => c.Text)
.Where(t => !string.IsNullOrEmpty(t))
.LastOrDefault();

if (!string.IsNullOrEmpty(textContent))
{
_ = activity.SetTag(SemanticConvention.GEN_AI_CONTENT_COMPLETION, textContent);
}
}

Logger.LogTrace("AddResponsesApiResponseTags() finished");
}

public void Dispose()
{
_loader?.Dispose();
Expand Down
Loading