-
Notifications
You must be signed in to change notification settings - Fork 4
feat: add openrouterwebapi sample #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,392 @@ | ||||||||||||||||||||||||||
| using Microsoft.AspNetCore.Mvc; | ||||||||||||||||||||||||||
| using OpenRouter.NET; | ||||||||||||||||||||||||||
| using OpenRouter.NET.Models; | ||||||||||||||||||||||||||
| using OpenRouter.NET.Tools; | ||||||||||||||||||||||||||
| using OpenRouter.NET.Events; | ||||||||||||||||||||||||||
| using OpenRouterWebApi.Models; | ||||||||||||||||||||||||||
| using System.Text; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| namespace OpenRouterWebApi.Controllers; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| [ApiController] | ||||||||||||||||||||||||||
| [Route("api/[controller]")] | ||||||||||||||||||||||||||
| public class ChatController : ControllerBase | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| private readonly OpenRouterClient _client; | ||||||||||||||||||||||||||
| private readonly ILogger<ChatController> _logger; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| public ChatController(OpenRouterClient client, ILogger<ChatController> logger) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| _client = client; | ||||||||||||||||||||||||||
| _logger = logger; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| [HttpPost("basic")] | ||||||||||||||||||||||||||
| public async Task<ActionResult<ChatResponse>> BasicChat([FromBody] ChatRequest request) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| try | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| var messages = new List<Message>(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (!string.IsNullOrEmpty(request.SystemPrompt)) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| messages.Add(Message.FromSystem(request.SystemPrompt)); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| messages.Add(Message.FromUser(request.Message)); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| var chatRequest = new ChatCompletionRequest | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| Model = request.Model, | ||||||||||||||||||||||||||
| Messages = messages, | ||||||||||||||||||||||||||
| Temperature = request.Temperature, | ||||||||||||||||||||||||||
| MaxTokens = request.MaxTokens | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| var response = await _client.CreateChatCompletionAsync(chatRequest); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return Ok(new ChatResponse( | ||||||||||||||||||||||||||
| Content: response.Choices?[0]?.Message?.Content?.ToString() ?? "No response", | ||||||||||||||||||||||||||
| Model: response.Model ?? request.Model, | ||||||||||||||||||||||||||
| FinishReason: response.Choices?[0]?.FinishReason, | ||||||||||||||||||||||||||
| Usage: response.Usage != null ? new TokenUsage( | ||||||||||||||||||||||||||
| response.Usage.TotalTokens, | ||||||||||||||||||||||||||
| response.Usage.PromptTokens, | ||||||||||||||||||||||||||
| response.Usage.CompletionTokens | ||||||||||||||||||||||||||
| ) : null | ||||||||||||||||||||||||||
| )); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| catch (OpenRouterException ex) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| _logger.LogError(ex, "OpenRouter API error"); | ||||||||||||||||||||||||||
| return StatusCode(ex.StatusCode ?? 500, new { error = ex.Message, code = ex.ErrorCode }); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| [HttpPost("stream")] | ||||||||||||||||||||||||||
| public async Task StreamChat([FromBody] StreamChatRequest request) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| Response.ContentType = "text/event-stream"; | ||||||||||||||||||||||||||
| Response.Headers.Append("Cache-Control", "no-cache"); | ||||||||||||||||||||||||||
| Response.Headers.Append("Connection", "keep-alive"); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| try | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| var messages = new List<Message>(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (!string.IsNullOrEmpty(request.SystemPrompt)) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| messages.Add(Message.FromSystem(request.SystemPrompt)); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| messages.Add(Message.FromUser(request.Message)); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| var chatRequest = new ChatCompletionRequest | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| Model = request.Model, | ||||||||||||||||||||||||||
| Messages = messages | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| await foreach (var chunk in _client.StreamAsync(chatRequest)) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| if (chunk.TextDelta != null) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| await Response.WriteAsync($"data: {chunk.TextDelta}\n\n"); | ||||||||||||||||||||||||||
| await Response.Body.FlushAsync(); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (chunk.Completion != null) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| await Response.WriteAsync($"data: [DONE] {chunk.Completion.FinishReason}\n\n"); | ||||||||||||||||||||||||||
| await Response.Body.FlushAsync(); | ||||||||||||||||||||||||||
| break; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| catch (Exception ex) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| _logger.LogError(ex, "Streaming error"); | ||||||||||||||||||||||||||
| await Response.WriteAsync($"data: [ERROR] {ex.Message}\n\n"); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| [HttpPost("conversation")] | ||||||||||||||||||||||||||
| public async Task<ActionResult<ChatResponse>> Conversation([FromBody] ConversationRequest request) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| try | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| var messages = request.Messages.Select(m => new Message | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| Role = m.Role, | ||||||||||||||||||||||||||
| Content = m.Content | ||||||||||||||||||||||||||
| }).ToList(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| var chatRequest = new ChatCompletionRequest | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| Model = request.Model, | ||||||||||||||||||||||||||
| Messages = messages, | ||||||||||||||||||||||||||
| Temperature = request.Temperature | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| var response = await _client.CreateChatCompletionAsync(chatRequest); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return Ok(new ChatResponse( | ||||||||||||||||||||||||||
| Content: response.Choices?[0]?.Message?.Content?.ToString() ?? "No response", | ||||||||||||||||||||||||||
| Model: response.Model ?? request.Model, | ||||||||||||||||||||||||||
| FinishReason: response.Choices?[0]?.FinishReason, | ||||||||||||||||||||||||||
| Usage: response.Usage != null ? new TokenUsage( | ||||||||||||||||||||||||||
| response.Usage.TotalTokens, | ||||||||||||||||||||||||||
| response.Usage.PromptTokens, | ||||||||||||||||||||||||||
| response.Usage.CompletionTokens | ||||||||||||||||||||||||||
| ) : null | ||||||||||||||||||||||||||
| )); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| catch (OpenRouterException ex) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| _logger.LogError(ex, "OpenRouter API error"); | ||||||||||||||||||||||||||
| return StatusCode(ex.StatusCode ?? 500, new { error = ex.Message, code = ex.ErrorCode }); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| [HttpPost("artifacts")] | ||||||||||||||||||||||||||
| public async Task<ActionResult<ArtifactResponse>> GenerateWithArtifacts([FromBody] ArtifactRequest request) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| try | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| var chatRequest = new ChatCompletionRequest | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| Model = request.Model, | ||||||||||||||||||||||||||
| Messages = new List<Message> { Message.FromUser(request.Prompt) } | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| chatRequest.EnableArtifactSupport(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| var responseText = new StringBuilder(); | ||||||||||||||||||||||||||
| var artifacts = new List<ArtifactInfo>(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| await foreach (var chunk in _client.StreamAsync(chatRequest)) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| if (chunk.TextDelta != null) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| responseText.Append(chunk.TextDelta); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (chunk.Artifact is ArtifactCompleted completed) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| artifacts.Add(new ArtifactInfo( | ||||||||||||||||||||||||||
| Title: completed.Title ?? "Untitled", | ||||||||||||||||||||||||||
| Type: completed.Type ?? "unknown", | ||||||||||||||||||||||||||
| Content: completed.Content ?? "", | ||||||||||||||||||||||||||
| Language: completed.Language | ||||||||||||||||||||||||||
| )); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return Ok(new ArtifactResponse( | ||||||||||||||||||||||||||
| Text: responseText.ToString(), | ||||||||||||||||||||||||||
| Artifacts: artifacts | ||||||||||||||||||||||||||
| )); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| catch (OpenRouterException ex) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| _logger.LogError(ex, "OpenRouter API error"); | ||||||||||||||||||||||||||
| return StatusCode(ex.StatusCode ?? 500, new { error = ex.Message, code = ex.ErrorCode }); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| [HttpPost("multimodal")] | ||||||||||||||||||||||||||
| public async Task<ActionResult<ChatResponse>> MultimodalChat([FromBody] MultimodalRequest request) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| try | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| var contentParts = new List<ContentPart> | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| new TextContent(request.Message) | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| foreach (var imageUrl in request.ImageUrls) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| contentParts.Add(new ImageContent(imageUrl)); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Comment on lines
+207
to
+210
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Null-safe iteration over image URLs. Guard against null to prevent NREs. - foreach (var imageUrl in request.ImageUrls)
+ foreach (var imageUrl in request.ImageUrls ?? Enumerable.Empty<string>())
{
contentParts.Add(new ImageContent(imageUrl));
}Alternative: mark ImageUrls as [Required] in DTOs (see Dtos.cs comment). 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| var chatRequest = new ChatCompletionRequest | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| Model = request.Model, | ||||||||||||||||||||||||||
| Messages = new List<Message> | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| Message.FromUser(contentParts) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| var response = await _client.CreateChatCompletionAsync(chatRequest); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return Ok(new ChatResponse( | ||||||||||||||||||||||||||
| Content: response.Choices?[0]?.Message?.Content?.ToString() ?? "No response", | ||||||||||||||||||||||||||
| Model: response.Model ?? request.Model, | ||||||||||||||||||||||||||
| FinishReason: response.Choices?[0]?.FinishReason, | ||||||||||||||||||||||||||
| Usage: response.Usage != null ? new TokenUsage( | ||||||||||||||||||||||||||
| response.Usage.TotalTokens, | ||||||||||||||||||||||||||
| response.Usage.PromptTokens, | ||||||||||||||||||||||||||
| response.Usage.CompletionTokens | ||||||||||||||||||||||||||
| ) : null | ||||||||||||||||||||||||||
| )); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| catch (OpenRouterException ex) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| _logger.LogError(ex, "OpenRouter API error"); | ||||||||||||||||||||||||||
| return StatusCode(ex.StatusCode ?? 500, new { error = ex.Message, code = ex.ErrorCode }); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| [HttpPost("tools")] | ||||||||||||||||||||||||||
| public async Task<ActionResult<string>> ChatWithTools([FromBody] ToolRequest request) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| try | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| var calculator = new OpenRouterWebApi.Tools.CalculatorTools(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| _client.RegisterTool(calculator, nameof(calculator.Add)); | ||||||||||||||||||||||||||
| _client.RegisterTool(calculator, nameof(calculator.Multiply)); | ||||||||||||||||||||||||||
| _client.RegisterTool(calculator, nameof(calculator.Subtract)); | ||||||||||||||||||||||||||
| _client.RegisterTool(calculator, nameof(calculator.Divide)); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| var chatRequest = new ChatCompletionRequest | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| Model = request.Model, | ||||||||||||||||||||||||||
| Messages = new List<Message> { Message.FromUser(request.Message) } | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| var responseText = new StringBuilder(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| await foreach (var chunk in _client.StreamAsync(chatRequest)) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| if (chunk.ServerTool != null) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| _logger.LogInformation( | ||||||||||||||||||||||||||
| "Tool {ToolName} - State: {State}, Result: {Result}", | ||||||||||||||||||||||||||
| chunk.ServerTool.ToolName, | ||||||||||||||||||||||||||
| chunk.ServerTool.State, | ||||||||||||||||||||||||||
| chunk.ServerTool.Result | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Comment on lines
+265
to
+271
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reduce logging of tool results to avoid sensitive-data leakage. Tool results may contain user data. Log metadata, not payloads, or mask them. - _logger.LogInformation(
- "Tool {ToolName} - State: {State}, Result: {Result}",
- chunk.ServerTool.ToolName,
- chunk.ServerTool.State,
- chunk.ServerTool.Result
- );
+ _logger.LogInformation(
+ "Tool {ToolName} - State: {State}",
+ chunk.ServerTool.ToolName,
+ chunk.ServerTool.State
+ );📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (chunk.TextDelta != null) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| responseText.Append(chunk.TextDelta); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return Ok(responseText.ToString()); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| catch (OpenRouterException ex) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| _logger.LogError(ex, "OpenRouter API error"); | ||||||||||||||||||||||||||
| return StatusCode(ex.StatusCode ?? 500, new { error = ex.Message, code = ex.ErrorCode }); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| [HttpPost("tools/stream")] | ||||||||||||||||||||||||||
| public async Task StreamChatWithTools([FromBody] ToolRequest request) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| Response.ContentType = "text/event-stream"; | ||||||||||||||||||||||||||
| Response.Headers.Append("Cache-Control", "no-cache"); | ||||||||||||||||||||||||||
| Response.Headers.Append("Connection", "keep-alive"); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| try | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| _logger.LogInformation("Starting tool streaming for model: {Model}, message: {Message}", | ||||||||||||||||||||||||||
| request.Model, request.Message); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| var calculator = new OpenRouterWebApi.Tools.CalculatorTools(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| _client.RegisterTool(calculator, nameof(calculator.Add)); | ||||||||||||||||||||||||||
| _client.RegisterTool(calculator, nameof(calculator.Multiply)); | ||||||||||||||||||||||||||
| _client.RegisterTool(calculator, nameof(calculator.Subtract)); | ||||||||||||||||||||||||||
| _client.RegisterTool(calculator, nameof(calculator.Divide)); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| _logger.LogInformation("Registered 4 calculator tools"); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Subscribe to streaming events | ||||||||||||||||||||||||||
| _client.OnStreamEvent += async (sender, e) => | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| _logger.LogInformation("Stream Event: {EventType}, State: {State}", e.EventType, e.State); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| var eventData = e.EventType switch | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| StreamEventType.StateChange => (object)new | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| type = "stateChange", | ||||||||||||||||||||||||||
| state = e.State.ToString() | ||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||
| StreamEventType.TextContent => (object)new | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| type = "text", | ||||||||||||||||||||||||||
| content = e.TextDelta | ||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||
| StreamEventType.ToolCall => (object)new | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| type = "toolCall", | ||||||||||||||||||||||||||
| toolName = e.ToolName, | ||||||||||||||||||||||||||
| toolCall = e.ToolCall | ||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||
| StreamEventType.ToolResult => (object)new | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| type = "toolResult", | ||||||||||||||||||||||||||
| toolName = e.ToolName, | ||||||||||||||||||||||||||
| result = e.ToolResult | ||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||
| StreamEventType.Error => (object)new | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| type = "error", | ||||||||||||||||||||||||||
| error = e.ToolResult | ||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||
| _ => null | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (eventData != null) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| var eventJson = System.Text.Json.JsonSerializer.Serialize(eventData); | ||||||||||||||||||||||||||
| await Response.WriteAsync($"event: {e.EventType}\ndata: {eventJson}\n\n"); | ||||||||||||||||||||||||||
| await Response.Body.FlushAsync(); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
Comment on lines
+309
to
+352
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chainEvent handler leak + concurrent Response writes in SSE tool streaming. Subscribing per-request without unsubscribe risks memory leaks and cross-request data leakage if OpenRouterClient is singleton. Also, multiple event invocations can write concurrently to the Response, which is not thread-safe.
Example patch (adjust delegate type to match OnStreamEvent): - // Subscribe to streaming events
- _client.OnStreamEvent += async (sender, e) =>
- {
+ // Subscribe to streaming events
+ var writeGate = new System.Threading.SemaphoreSlim(1, 1);
+ EventHandler<StreamEventArgs> handler = async (sender, e) =>
+ {
+ await writeGate.WaitAsync(HttpContext.RequestAborted);
+ try
+ {
_logger.LogInformation("Stream Event: {EventType}, State: {State}", e.EventType, e.State);
var eventData = e.EventType switch
{
StreamEventType.StateChange => (object)new
{
type = "stateChange",
state = e.State.ToString()
},
StreamEventType.TextContent => (object)new
{
type = "text",
content = e.TextDelta
},
StreamEventType.ToolCall => (object)new
{
type = "toolCall",
toolName = e.ToolName,
toolCall = e.ToolCall
},
StreamEventType.ToolResult => (object)new
{
type = "toolResult",
toolName = e.ToolName,
result = e.ToolResult
},
StreamEventType.Error => (object)new
{
type = "error",
error = e.ToolResult
},
_ => null
};
if (eventData != null)
{
var eventJson = System.Text.Json.JsonSerializer.Serialize(eventData);
await Response.WriteAsync($"event: {e.EventType}\ndata: {eventJson}\n\n", HttpContext.RequestAborted);
await Response.Body.FlushAsync(HttpContext.RequestAborted);
}
- };
+ }
+ finally
+ {
+ writeGate.Release();
+ }
+ };
+ _client.OnStreamEvent += handler;And ensure unsubscription: - await Response.Body.FlushAsync();
+ await Response.Body.FlushAsync();
+ }
+ finally
+ {
+ try { _client.OnStreamEvent -= handler; } catch { /* best-effort cleanup */ }
}Also applies to: 374-377 🏁 Script executed: #!/bin/bash
# Verify the delegate type of OnStreamEvent to adjust the handler signature.
rg -nP -C2 'OnStreamEvent' --type csLength of output: 1215 I need to verify the scope of [/run_scripts] 🏁 Script executed: #!/bin/bash
# Check _client declaration and scope in ChatController
rg -nP -C10 'class ChatController' samples/OpenRouterWebApiSample/Controllers/ChatController.cs | head -30
# Check if there's a try-finally or using pattern around the stream handler
rg -nP -B5 -A50 'OnStreamEvent \+=' samples/OpenRouterWebApiSample/Controllers/ChatController.cs | head -80Length of output: 2926 [/run_scripts] 🏁 Script executed: #!/bin/bash
# Get full method context to see if there's try-finally wrapping
rg -nP -B15 'OnStreamEvent \+=' samples/OpenRouterWebApiSample/Controllers/ChatController.cs | head -40
# Check for method signature containing line 310
sed -n '280,360p' samples/OpenRouterWebApiSample/Controllers/ChatController.cs
# Search for OpenRouterClient DI registration
fd -e cs -e json | xargs rg -l 'OpenRouterClient' | head -5Length of output: 4210 [/run_scripts] 🏁 Script executed: #!/bin/bash
# Get rest of the method to see finally block
sed -n '350,410p' samples/OpenRouterWebApiSample/Controllers/ChatController.cs
# Search for method end and finally block
rg -nP -A80 'StreamChatWithTools' samples/OpenRouterWebApiSample/Controllers/ChatController.cs | tail -100
# Check DI registration - look in Program.cs or Startup
fd -e cs | xargs rg -l 'AddScoped|AddSingleton|AddTransient' | head -3Length of output: 5186 [/run_scripts] 🏁 Script executed: #!/bin/bash
# Check DI registration in Program.cs
cat samples/OpenRouterWebApiSample/Program.csLength of output: 957 [/run_scripts] 🏁 Script executed: #!/bin/bash
# Check the "Also applies to: 374-377" location
sed -n '370,380p' samples/OpenRouterWebApiSample/Controllers/ChatController.csLength of output: 481 Add unsubscribe logic and serialize Response writes to prevent handler leak and concurrent write errors. The review comment is correct. Verification confirms:
The suggested fix—capturing the handler reference, unsubscribing in a finally block, using a SemaphoreSlim to serialize writes, and passing HttpContext.RequestAborted—directly addresses these real issues. 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| var chatRequest = new ChatCompletionRequest | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| Model = request.Model, | ||||||||||||||||||||||||||
| Messages = new List<Message> { Message.FromUser(request.Message) }, | ||||||||||||||||||||||||||
| Stream = true | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| _logger.LogInformation("Starting ProcessMessageAsync with event handler..."); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| var (response, history) = await _client.ProcessMessageAsync(chatRequest, maxToolCalls: 5); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| _logger.LogInformation("ProcessMessageAsync completed. History count: {Count}", history.Count); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| var completeEvent = new | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| type = "complete", | ||||||||||||||||||||||||||
| content = response.GetContent(), | ||||||||||||||||||||||||||
| finishReason = response.GetFinishReason() | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| var completeJson = System.Text.Json.JsonSerializer.Serialize(completeEvent); | ||||||||||||||||||||||||||
| await Response.WriteAsync($"event: complete\ndata: {completeJson}\n\n"); | ||||||||||||||||||||||||||
| await Response.Body.FlushAsync(); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| catch (Exception ex) | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| _logger.LogError(ex, "ERROR in streaming with tools: {Message}", ex.Message); | ||||||||||||||||||||||||||
| var errorEvent = new | ||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||
| type = "error", | ||||||||||||||||||||||||||
| message = ex.Message, | ||||||||||||||||||||||||||
| stackTrace = ex.StackTrace | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
| var eventJson = System.Text.Json.JsonSerializer.Serialize(errorEvent); | ||||||||||||||||||||||||||
| await Response.WriteAsync($"event: error\ndata: {eventJson}\n\n"); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid IndexOutOfRange on response.Choices.
Use FirstOrDefault() instead of [0] to handle empty choices safely.
Apply the same change to the Conversation and Multimodal responses (shown in this hunk).
Also applies to: 133-142, 223-232
🤖 Prompt for AI Agents