Универсальный AI-прокси на Java для Gemini, OpenAI и Anthropic: пошаговый гайд | AiManual
AiManual Logo Ai / Manual.
12 Июл 2026 Гайд

Универсальный AI-прокси на Java: как приручить Gemini, OpenAI и Anthropic за один вечер

Пошаговая инструкция по созданию универсального AI-прокси на Java с Spring WebFlux. Единая точка входа для Gemini, OpenAI и Anthropic с ретраями, лимитами и стр

Допустим, у вас в продакшене уже живёт микросервис на Java, который дёргает OpenAI. Всё чётко, кэш, ретраи, мониторинг. А потом начальник приносит задачу: «Хочу ещё Gemini и Claude — подключай». И первая мысль — переписать всё под каждый API, размазать ключи по коду, нагородить адаптеров… Стоп. Есть способ чище — один универсальный прокси-сервер, который принимает запросы в едином формате (да хоть в OpenAI-совместимом) и сам рулит маршрутизацией к любому провайдеру.

Я покажу, как собрать такой прокси на Java 21 + Spring WebFlux. Прямо сейчас, с кодом, конфигами и граблями. И дата у нас — 12 июля 2026 — все библиотеки актуальны, модели свежие: Gemini 3 Ultra, GPT-5 Turbo, Claude 4 Opus. Поехали.

⚠️ Внимание: 12.07.2026 Google изменил политику бесплатного тарифа Gemini API — лимит 60 rpm. OpenAI и Anthropic пока держатся, но тоже ужесточают квоты. Наш прокси будет честно считать потребление и упираться в лимиты, а не падать.

Архитектура: зачем нам ещё один слой

Прокси — это не просто прослойка. Это единая точка входа для всех AI-запросов, которая решает три проблемы:

  • Блокировки по регионам — ставим прокси на VPS за пределами РФ (или в облаке), и Gemini с OpenAI перестают капризничать.
  • Разные форматы — внутри прокси транслируем OpenAI-подобный запрос в специфический JSON для Anthropic и Google.
  • Безопасность ключей — клиентское приложение понятия не имеет, какой ключ где лежит. Всё спрятано в environment-переменных сервера.

Если вы уже пробовали готовые решения вроде Gemini CLI Proxy для обёртки Gemini под OpenAI, то идея та же, только мы идём глубже — пишем полностью под свой сценарий, с кастомными лимитами и логами.

Выбираем стек: почему Spring WebFlux, а не старого доброго Tomcat

Потому что стриминг. Когда LLM отвечает токен за токеном, блокировать поток на каждый чанк — роскошь. WebFlux + Reactor дают асинхронность из коробки. Плюс WebClient — идеальный HTTP-клиент для реактивного мира. Никаких RestTemplate, даже не думайте.

Вот минимальный набор зависимостей (Gradle):

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-webflux:3.4.2'
    implementation 'io.projectreactor:reactor-core:3.7.2'
    implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.2'
    implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.2'
    // Для логирования и метрик
    implementation 'io.micrometer:micrometer-tracing-bridge-otel:1.4.1'
}
💡
На 12.07.2026 Spring Boot 3.4.2 — последний стабильный релиз. Версии библиотек актуальны на эту дату. Если читаете позже — проверьте обновления.

Шаг 1. Единая модель запроса

Чтобы не плодить сущности, договоримся на берегу: всё, что приходит от клиента, выглядит как стандартный OpenAI-запрос (/v1/chat/completions). Прокси будет сам преобразовывать его под конкретного провайдера. Модельку пропишем так:

public record ChatCompletionRequest(
    String model,
    @JsonProperty("provider") String provider,  // openai, gemini, anthropic
    List<Message> messages,
    Double temperature,
    @JsonProperty("max_tokens") Integer maxTokens,
    Boolean stream
) {}

public record Message(
    String role,
    String content
) {}

Поле provider — наша фишка. Клиент может явно указать, какой бэкенд дёргать, или полагаться на дефолтное правило (например, всё в OpenAI).

Шаг 2. Фабрика клиентов — без if-else ада

Вместо того чтобы лепить if по названию провайдера в контроллере, сделаем абстракцию. Каждый провайдер реализует интерфейс:

public interface AiClient {
    Mono<ChatCompletionResponse> send(ChatCompletionRequest request);
    Flux<ChatCompletionChunk> sendStream(ChatCompletionRequest request);
    String getName();
}

А фабрика будет регистрировать реализации с помощью @Service и мапы. Пример для OpenAI:

@Service
public class OpenAiClient implements AiClient {
    private final WebClient webClient;
    private final String apiKey;

    public OpenAiClient(@Value("${openai.api.key}") String apiKey) {
        this.apiKey = apiKey;
        this.webClient = WebClient.builder()
            .baseUrl("https://api.openai.com/v1")
            .defaultHeader("Authorization", "Bearer " + apiKey)
            .build();
    }

    @Override
    public Mono<ChatCompletionResponse> send(ChatCompletionRequest request) {
        return webClient.post()
            .uri("/chat/completions")
            .bodyValue(toOpenAiRequest(request))
            .retrieve()
            .bodyToMono(ChatCompletionResponse.class);
    }

    // ... stream и преобразование
}

То же самое для Gemini и Anthropic — свои WebClient, свои URL, свои форматы. Фабрику привяжем к полю provider в запросе.

Шаг 3. Адаптеры запросов — боль, которую нужно скрыть

Самое весёлое — это разница в структуре JSON. Например, Anthropic просит {"model": ..., "messages": [{"role": "user", "content": "..."}]}, а Gemini в версии API v1beta — {"contents": [{"parts": [{"text": "..."}]}]}. Чтобы не сойти с ума, пишем по одному mapper'у на провайдера.

private Object toGeminiRequest(ChatCompletionRequest req) {
    return Map.of(
        "contents", req.messages().stream()
            .map(m -> Map.of(
                "role", m.role().equals("system") ? "user" : m.role(),
                "parts", List.of(Map.of("text", m.content()))
            )).toList(),
        "generationConfig", Map.of(
            "temperature", req.temperature(),
            "maxOutputTokens", req.maxTokens()
        )
    );
}

Заметьте: system role в Gemini не поддерживается — мы просто мапим её на user, а системный промпт добавляем в контекст через первый же пользовательский запрос. Костыль? Работает.

Anthropic же любит, чтобы system передавался отдельным полем верхнего уровня. Учитывайте это.

Шаг 4. Контроллер — один ручка, много провайдеров

@RestController
public class ChatController {
    private final AiClientFactory factory;

    @PostMapping("/v1/chat/completions")
    public Mono<ChatCompletionResponse> chat(
        @RequestBody ChatCompletionRequest request) {
        var client = factory.getClient(request.provider());
        return client.send(request);
    }

    @PostMapping(value = "/v1/chat/completions", params = "stream=true")
    public Flux<ServerSentEvent<ChatCompletionChunk>> chatStream(
        @RequestBody ChatCompletionRequest request) {
        var client = factory.getClient(request.provider());
        return client.sendStream(request)
            .map(chunk -> ServerSentEvent.builder(chunk)
                .id(chunk.id())
                .event("chat.completion.chunk")
                .build());
    }
}

Вуаля. Одна endpoint, всё решает провайдер в теле запроса или дефолтная стратегия.

Шаг 5. Ретраи, таймауты и rate limiting

LLM API падают часто: 429, 5xx, таймауты. Без ретраев ваш прокси будет просто разводить руками. Используем ReactiveRetry:

public Mono<ChatCompletionResponse> sendWithRetry(ChatCompletionRequest req) {
    return client.send(req)
        .retryWhen(Retry.fixedDelay(3, Duration.ofSeconds(2))
            .filter(throwable -> throwable instanceof WebClientResponseException &&
                ((WebClientResponseException) throwable).getStatusCode().is5xxServerError())
            .onRetryExhaustedThrow((spec, signal) -> 
                new RuntimeException("All retries exhausted for " + client.getName())));
}

Для rate limiting — добавляем RateLimiter из Resilience4j (на 12.07.2026 версия 2.2.0). Лимиты берём из конфига, можно динамически менять через Actuator.

Шаг 6. Безопасность ключей — не светить в логах

Ключи должны быть только в application.yml или, лучше, в переменных окружения. Запретим их логирование через фильтр:

@Bean
public WebFilter hideApiKeyFilter() {
    return (exchange, chain) -> {
        var request = exchange.getRequest();
        var sanitized = new ServerHttpRequestDecorator(request) {
            @Override
            public HttpHeaders getHeaders() {
                var headers = new HttpHeaders(super.getHeaders());
                if (headers.containsKey(HttpHeaders.AUTHORIZATION)) {
                    headers.set(HttpHeaders.AUTHORIZATION, "Bearer ***");
                }
                return headers;
            }
        };
        return chain.filter(exchange.mutate().request(sanitized).build());
    };
}

И никогда, слышите, никогда не передавайте ключ через URL. Только заголовки, только хардкор.

Грабли, на которые я наступил лично

  • Streaming у Anthropic — они возвращают SSE, но с кастомными event name (не chat.completion.chunk). Пришлось написать отдельный парсер, который накапливает последнее delta.
  • Gemini не любит длинные system промпты — вылетает 400 с ошибкой про unsafe content, если system содержит сложные инструкции. Решение: разбивать system на несколько user сообщений или использовать Gemini 3.5 Flash, где система поддерживается.
  • Таймауты при стриминге — если клиент отвалился, корневой поток в WebFlux не обрывается сам. Пришлось добавить timeout() на Flux и чистить контекст.
  • Rate Limiter на уровне прокси не спасает, если вы пробиваете лимиты самого API — нужно ещё и со стороны провайдера мониторить остатки. Мы используем заголовок X-RateLimit-Remaining (если он есть) и динамически подкручиваем лимиты.

Если не хотите возиться с этими нюансами сами, можно воспользоваться готовым решением — AITunnel. Это коммерческий шлюз, который уже умеет маршрутизировать между моделями, считать токены и обходить блокировки. Но если хочется полного контроля — пишите свой, как мы.

Мониторинг и логирование — без них прокси слеп

Добавьте хотя бы простой счётчик запросов на провайдер. Я использую Micrometer + Prometheus. В контроллере:

private final MeterRegistry meterRegistry;

public Mono<ChatCompletionResponse> chat(Request req) {
    meterRegistry.counter("ai.proxy.requests", 
        "provider", req.provider(), 
        "model", req.model()).increment();
    // ... вызов
}

И не забудьте логировать не только ошибки, но и аномалии: если температура = 0, а модель начала галлюцинировать — сигнал для дашборда.

Кстати, для глубокого понимания того, как работают лимиты и контекстное кэширование, рекомендую прочитать статью про сжигание миллионов токенов и контекстное кэширование.

А что там с моделями на 12.07.2026?

За полгода с момента первых утечек многое изменилось. Google выпустила Gemini 3 Ultra, который стоит копейки, но выдаёт качество на уровне GPT-5. Anthropic подвезли Claude 4 Opus с окном в 200K токенов. OpenAI пытается удержать позиции, но доля рынка падает. Наш прокси легко адаптируется под любые новые модели — достаточно добавить ещё один AiClient.

Если вы работаете с Gemini и хотите автоматизировать рутину, взгляните на AI Bridge для управления компьютером — это отличный симбиоз с нашим прокси.

Неочевидный совет: тестируйте на песочнице

Вместо того чтобы гонять запросы на продакшн-ключи, создайте фейковый MockAiClient для тестов. Он будет возвращать предопределённые ответы и чанки. Так вы отловите 80% багов маршрутизации, не потратив ни цента. У меня весь CI/CD гоняет на моках, а реальные ключи только при деплое.

И ещё — не забывайте про health check. Прокси должен проверять, что каждый из провайдеров доступен. Если Gemini лёг, не пытайтесь слать туда запросы — сразу переключайте на fallback (например, на Anthropic).

@Component
public class AiHealthIndicator implements HealthIndicator {
    private final Map<String, AiClient> clients;

    @Override
    public Mono<Health> health() {
        return Flux.fromIterable(clients.values())
            .flatMap(client -> client.ping()
                .map(ok -> Health.up().withDetail(client.getName(), "available"))
                .onErrorResume(e -> Mono.just(Health.down().withDetail(client.getName(), e.getMessage()))))
            .collectList()
            .map(healths -> Health.status(healths.stream().allMatch(Health::isUp) ? "UP" : "DEGRADED")
                .withDetails(healths).build());
    }
}

Соберите это в Docker-образ и деплойте на любой облачный хостинг. Удачи и меньше 429 ошибок!

Подписаться на канал