Прокси на Scala: безопасный и масштабируемый доступ к OpenAI API

При работе с OpenAI API в корпоративных и продуктовых проектах часто возникают задачи:

  • централизовать доступ к API

  • логировать запросы и ответы

  • ограничить доступ по IP или токену

  • обеспечить доступ из ограниченных регионов

Эти задачи удобно решаются через промежуточный прокси-сервис.

В этой статье разбирается реальное приложение на Scala 3, которое:

  • прозрачно проксирует HTTP-запросы

  • не меняет структуру API

  • легко разворачивается на VPS

  • готово к расширению

Архитектура решения

Поток запроса:

Клиент → HTTPS (Apache/Nginx) → Scala proxy → OpenAI API

Принцип работы:

  • клиент отправляет запрос на ваш домен

  • reverse-proxy принимает HTTPS

  • Scala-сервис проксирует запрос в OpenAI

  • ответ возвращается клиенту

Настройка проекта (Scala + http4s)

build.sbt

val scala3Version = "3.3.6"

lazy val root = project
  .in(file("."))
  .settings(
    name := "openai-proxy",
    version := "0.1.0",
    scalaVersion := scala3Version,

    libraryDependencies ++= Seq(
      "org.http4s" %% "http4s-dsl" % "0.23.30",
      "org.http4s" %% "http4s-ember-server" % "0.23.30",
      "org.http4s" %% "http4s-ember-client" % "0.23.30",
      "org.typelevel" %% "cats-effect" % "3.5.4",
      "org.typelevel" %% "log4cats-slf4j" % "2.7.0",
      "ch.qos.logback" % "logback-classic" % "1.5.16",
      "com.typesafe" % "config" % "1.4.3"
    )
  )

Конфигурация приложения

application.conf

server {
  host = "127.0.0.1"
  port = 8080
}

openai {
  base-url = "https://api.openai.com"
}

AppConfig.scala

final case class AppConfig(
  host: String,
  port: Int,
  baseUrl: String
)

object AppConfig {
  def load(): AppConfig = {
    val cfg = com.typesafe.config.ConfigFactory.load()

    AppConfig(
      host = cfg.getString("server.host"),
      port = cfg.getInt("server.port"),
      baseUrl = cfg.getString("openai.base-url").stripSuffix("/")
    )
  }
}

Реализация прокси

Основная логика реализована в ProxyRoutes

Удаление hop-by-hop заголовков

val HopHeaders = Set(
  "Connection",
  "Keep-Alive",
  "Transfer-Encoding",
  "Upgrade"
)

def cleanHeaders(headers: Headers): Headers =
  Headers(headers.headers.filterNot(h => HopHeaders.contains(h.name.toString)))

Проксирование запроса

def proxy(client: Client[IO], baseUrl: String): HttpApp[IO] =
  HttpApp[IO] { req =>

    val targetUri = Uri
      .unsafeFromString(baseUrl)
      .withPath(req.uri.path)
      .copy(query = req.uri.query)

    val proxiedRequest = Request[IO](
      method = req.method,
      uri = targetUri,
      headers = cleanHeaders(req.headers),
      body = req.body
    )

    client.run(proxiedRequest).use { resp =>
      Response[IO](
        status = resp.status,
        headers = resp.headers,
        body = resp.body
      ).pure[IO]
    }
  }

Логирование

def logRequest(req: Request[IO]): IO[Unit] =
  IO.println(s">>> ${req.method} ${req.uri.path}")

def logResponse(status: Status): IO[Unit] =
  IO.println(s"<<< $status")

Использование:

for {
  _ <- logRequest(req)
  response <- client.run(proxiedRequest).use { resp =>
    logResponse(resp.status) *>
      IO.pure(Response[IO](resp.status).withEntity(resp.body))
  }
} yield response

Обработка ошибок

.handleErrorWith { e =>
  IO.println(s"Error: ${e.getMessage}") *>
    IO.pure(Response[IO](Status.BadGateway).withEntity("Proxy error"))
}

Точка входа приложения

object Main extends IOApp {

  override def run(args: List[String]): IO[ExitCode] = {
    val config = AppConfig.load()

    EmberClientBuilder.default[IO].build.use { client =>
      val app = proxy(client, config.baseUrl)

      EmberServerBuilder.default[IO]
        .withHost(ipv4"127.0.0.1")
        .withPort(port"${config.port}")
        .withHttpApp(app)
        .build
        .use(_ => IO.never)
    }.as(ExitCode.Success)
  }
}

Примеры запросов

Получение моделей:

curl http://localhost:8080/v1/models \
  -H "Authorization: Bearer sk-..."

Chat completion:

curl http://localhost:8080/v1/chat/completions \
  -H "Authorization: Bearer sk-..." \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-4o",
    "messages": [
      {"role": "user", "content": "Привет"}
    ]
  }'

Streaming:

curl -N http://localhost:8080/v1/chat/completions \
  -H "Authorization: Bearer sk-..." \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-4o",
    "messages": [{"role": "user", "content": "Расскажи шутку"}],
    "stream": true
  }'

Деплой на сервер

systemd сервис:

[Service]
ExecStart=/usr/bin/java -jar app.jar
Restart=always

Apache reverse proxy:

ProxyPass / http://127.0.0.1:8080/
ProxyPassReverse / http://127.0.0.1:8080/

Практические доработки

Добавление токена доступа:

val ProxyToken = "secret123"

def auth(req: Request[IO]): Boolean =
  req.headers.get(CIString("X-Proxy-Token"))
    .exists(_.head.value == ProxyToken)

Простое ограничение запросов:

var counter = 0

def limit(): IO[Unit] =
  IO {
    counter += 1
    if (counter > 1000) throw new Exception("Rate limit exceeded")
  }

Health-check endpoint:

case GET -> Root / "health" =>
  Ok("ok")

Когда это применимо

  • SaaS с интеграцией LLM

  • Telegram-боты

  • AI-агенты

  • внутренние корпоративные сервисы

Заключение

Прокси на Scala — это компактное и управляемое решение для работы с OpenAI API.

Он позволяет централизовать доступ, контролировать использование и постепенно расширять функциональность без изменений клиентских приложений.

По мере развития проекта в него можно добавлять авторизацию, биллинг, метрики и поддержку нескольких AI-провайдеров.