Skip to content
Open
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
392 changes: 392 additions & 0 deletions samples/OpenRouterWebApiSample/Controllers/ChatController.cs
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
));
Comment on lines +48 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Avoid IndexOutOfRange on response.Choices.

Use FirstOrDefault() instead of [0] to handle empty choices safely.

-            return Ok(new ChatResponse(
-                Content: response.Choices?[0]?.Message?.Content?.ToString() ?? "No response",
-                Model: response.Model ?? request.Model,
-                FinishReason: response.Choices?[0]?.FinishReason,
+            return Ok(new ChatResponse(
+                Content: response.Choices?.FirstOrDefault()?.Message?.Content?.ToString() ?? "No response",
+                Model: response.Model ?? request.Model,
+                FinishReason: response.Choices?.FirstOrDefault()?.FinishReason,
                 Usage: response.Usage != null ? new TokenUsage(
                     response.Usage.TotalTokens,
                     response.Usage.PromptTokens,
                     response.Usage.CompletionTokens
                 ) : null
             ));

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
In samples/OpenRouterWebApiSample/Controllers/ChatController.cs around lines
48-57, the code accesses response.Choices[0] which can throw IndexOutOfRange
when choices is empty; replace uses of [0] with
response.Choices?.FirstOrDefault() (or .FirstOrDefault() stored in a local
variable) and null-conditional checks before accessing
Message/Content/FinishReason so it safely falls back to defaults; apply the same
change pattern to the other occurrences at lines 133-142 and 223-232
(Conversation and Multimodal response blocks) to avoid indexing an empty
collection.

}
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
foreach (var imageUrl in request.ImageUrls)
{
contentParts.Add(new ImageContent(imageUrl));
}
foreach (var imageUrl in request.ImageUrls ?? Enumerable.Empty<string>())
{
contentParts.Add(new ImageContent(imageUrl));
}
🤖 Prompt for AI Agents
In samples/OpenRouterWebApiSample/Controllers/ChatController.cs around lines 207
to 210, the foreach over request.ImageUrls can throw a null reference if
ImageUrls is null; change the code to guard against null by iterating over
request.ImageUrls ?? Enumerable.Empty<string>() or add an explicit if
(request.ImageUrls != null) check before the loop, or alternatively make
ImageUrls required on the DTOs per the Dtos.cs suggestion.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_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
);
🤖 Prompt for AI Agents
In samples/OpenRouterWebApiSample/Controllers/ChatController.cs around lines 265
to 271, the current logger writes the full tool result which may contain
user-sensitive data; change the logging to avoid payloads by logging only
metadata (e.g., ToolName and State) and either omit the Result field or replace
it with a redacted placeholder or safe summary (e.g., result length, truncated
hash, or "[REDACTED]"). Update the LogInformation call to remove
chunk.ServerTool.Result from structured output or pass a masked value produced
by a small helper (MaskSensitive(string) or Summary(string)) so no raw tool
payload is emitted to logs. Ensure any helper used is deterministic and does not
include user content.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Event 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.

  • Capture a handler, subscribe, and always unsubscribe in finally.
  • Serialize writes with a gate to avoid interleaving.
  • If supported, pass HttpContext.RequestAborted to downstream APIs.

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 cs

Length of output: 1215


I need to verify the scope of _client in the ChatController and check if there's already proper cleanup logic:

[/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 -80

Length 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 -5

Length 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 -3

Length of output: 5186


[/run_scripts]


🏁 Script executed:

#!/bin/bash
# Check DI registration in Program.cs
cat samples/OpenRouterWebApiSample/Program.cs

Length 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.cs

Length 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:

  • OpenRouterClient is registered as AddSingleton (Program.cs line 17), so the _client instance persists across all requests.
  • Handler leak confirmed: The event subscription at line 310 lacks any unsubscribe logic; there is no finally block in the method (lines 295-387), so handlers accumulate on each request call.
  • Concurrent writes confirmed: Multiple event handlers can invoke simultaneously and write directly to Response without synchronization, which is not thread-safe.
  • Lines 374-377 show additional unprotected Response writes that must also be gated.

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
In samples/OpenRouterWebApiSample/Controllers/ChatController.cs around lines 309
to 352, the streamed event handler is added to the singleton _client without
being removed and writes to Response concurrently; fix by capturing the delegate
when subscribing, use a SemaphoreSlim (shared inside the request scope) to
serialize all Response.WriteAsync/FlushAsync calls (including the unprotected
writes around lines 374-377), pass HttpContext.RequestAborted into the async
write calls, and ensure you unsubscribe the captured handler in a finally block
so the handler does not leak after the request completes or is cancelled.


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");
}
}
}

Loading