OpenRouter로 무료 생성형 AI 기반 챗봇 구현해보기

업데이트: 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를 실제 애플리케이션에 접목할 수 있는 방법을 고민하고 계시다면 한 번 테스트해보시면 좋을 것 같습니다. :smiley:

<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) { }

7개의 좋아요