Допустим, у вас в продакшене уже живёт микросервис на 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'
}
Шаг 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 ошибок!