업데이트: optimus-alpha 모델은 지금 시간 기준으로 사용할 수 없개 비공개 처리되었습니다.
이번에 LINQPad에서 OpenRouter를 통한 코딩 어시스턴트 AI 기능 확장을 새롭게 지원하게 되었다는 소식을 알게 되어, OpenRouter라는 서비스를 알게 되었고 어떻게 활용할 수 있을지 탐구해보다가 흥미로운 부분을 알게 되었습니다.
- OpenRouter는 OpenAI, Claude를 비롯한 여러 오픈 소스 모델을 중계해주는 기능을 OpenAI API 기반으로 제공
- Throttling이 당연히 존재하지만, 그럼에도 충분히 생성형 AI 기능을 시험해볼 수 있는 무료 서비스 제공 (예: optimus-alpha, Google Gemini Experimental 등)
- 또한 이 중에는 Tool Calling 지원 모델도 있어서, 이를 활용하여 MCP 기능도 구현이 가능합니다.
- PKCE 인증을 통해서 웹 브라우저 인증을 통해 API 키를 하드코딩하지 않고 사용자 인증을 거쳐 C#에서 프로그래밍 방식으로 OpenRouter API를 호출할 수 있는 방법을 제공
위의 세 가지 특성을 활용한 간단한 샘플 코드도 빠르게 만들어볼 수 있었습니다. 제가 여기서 사용한 모델은 optimus-alpha
모델입니다.
LLM AI를 실제 애플리케이션에 접목할 수 있는 방법을 고민하고 계시다면 한 번 테스트해보시면 좋을 것 같습니다.
<Query Kind="Statements">
<NuGetReference>OpenAI</NuGetReference>
<Namespace>System.Net</Namespace>
<Namespace>System.Net.Http</Namespace>
<Namespace>System.Net.Sockets</Namespace>
<Namespace>System.Security.Cryptography</Namespace>
<Namespace>System.Text.Json</Namespace>
<Namespace>System.Threading.Tasks</Namespace>
<Namespace>OpenAI</Namespace>
<Namespace>OpenAI.Chat</Namespace>
<Namespace>System.ClientModel</Namespace>
</Query>
var challenge = OpenRouterPkceAuth.GeneratePkce();
var callbackUrl = OpenRouterPkceAuth.GetLocalhostCallbackAddress();
Process.Start(new ProcessStartInfo
{
FileName = OpenRouterPkceAuth.GetOAuthUrl(callbackUrl, challenge),
UseShellExecute = true
});
var receivedCode = await OpenRouterPkceAuth.ListenForCodeAsync(callbackUrl);
var apiKey = await OpenRouterPkceAuth.ObtainOpenRouterApiKeyAsync(receivedCode, challenge);
apiKey.Dump();
var client = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions() { Endpoint = new Uri("https://openrouter.ai/api/v1/", UriKind.Absolute), });
var chatClient = client.GetChatClient("openrouter/optimus-alpha");
while (true)
{
await Console.Out.WriteLineAsync("금융과 공공 부문에 관하여 궁금한 것이 있으면 질문을 입력해주세요. /exit 라고 입력하면 프로그램을 마칩니다.");
await Console.Out.WriteAsync("> ");
var message = await Console.In.ReadLineAsync();
if (string.Equals(message?.Trim(), "/exit", StringComparison.OrdinalIgnoreCase))
break;
if (string.IsNullOrWhiteSpace(message))
continue;
await foreach (var completionUpdate in chatClient.CompleteChatStreamingAsync(new ChatMessage[]
{
ChatMessage.CreateSystemMessage("You are a professional with extensive knowledge of financial services and public affairs in the Republic of Korea."),
ChatMessage.CreateUserMessage(message),
}))
{
if (completionUpdate.ContentUpdate.Count > 0)
await Console.Out.WriteAsync(completionUpdate.ContentUpdate[0].Text);
}
}
public static class OpenRouterPkceAuth
{
public static string GetOAuthUrl(string callbackUrl, PkceChallenge challenge)
=> $"https://openrouter.ai/auth?callback_url={Uri.EscapeDataString(callbackUrl)}&code_challenge={challenge.CodeChallenge}&code_challenge_method=S256";
// https://openrouter.ai/docs/use-cases/oauth-pkce#localhost-apps
public static string GetLocalhostCallbackAddress()
=> "http://localhost:3000/callback/";
public static async Task<string> ObtainOpenRouterApiKeyAsync(string receivedCode, PkceChallenge challenge)
{
using var httpClient = new HttpClient();
var requestBody = new
{
code = receivedCode,
code_verifier = challenge.CodeVerifier,
code_challenge_method = "S256",
};
var content = new StringContent(JsonSerializer.Serialize(requestBody), new UTF8Encoding(false), "application/json");
using var response = await httpClient.PostAsync("https://openrouter.ai/api/v1/auth/keys", content);
response.EnsureSuccessStatusCode();
var responseBody = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(responseBody);
return doc.RootElement.GetProperty("key").GetString();
}
public static async Task<string> ListenForCodeAsync(string callbackUrl, CancellationToken cancellationToken = default)
{
var listener = new HttpListener();
listener.Prefixes.Add(callbackUrl);
listener.Start();
Console.WriteLine($"[INFO] Listening on {callbackUrl}");
var context = await listener.GetContextAsync().ConfigureAwait(false);
var code = context.Request.QueryString["code"];
var response = context.Response;
var responseString = "<html><body>Authentication successful. You may close this window.</body></html>";
var buffer = Encoding.UTF8.GetBytes(responseString);
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
response.OutputStream.Close();
listener.Stop();
return code;
}
public static PkceChallenge GeneratePkce()
{
using var rng = RandomNumberGenerator.Create();
var bytes = new byte[32];
rng.GetBytes(bytes);
var codeVerifier = Base64UrlEncode(bytes);
using var sha256 = SHA256.Create();
var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
var codeChallenge = Base64UrlEncode(challengeBytes);
return new PkceChallenge(codeChallenge, codeVerifier);
}
private static string Base64UrlEncode(byte[] input)
{
return Convert.ToBase64String(input)
.Replace("+", "-")
.Replace("/", "_")
.TrimEnd('=');
}
}
public sealed record class PkceChallenge(
string CodeChallenge, string CodeVerifier) { }