Spring AI

Spring AI is configuration-driven, so this is the one integration where the two settings are literally two lines of YAML. Your ChatClient beans, advisors, and tool callbacks do not change at all.

Install

<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>

Configure

spring:
  ai:
    openai:
      base-url: https://guardrails.vulnetix.com/openai/${VULNETIX_ORG_UUID}
      api-key: ${VULNETIX_API_KEY}   # Vulnetix key, not an OpenAI key
      chat:
        options:
          model: gpt-4o-mini
Warning Spring AI appends the completions path itself — it takes a base URL and adds /v1/chat/completions to it. So base-url here stops at the organisation UUID, with no /v1, unlike the OpenAI SDKs. What matters is the URL that actually goes out: it must be https://guardrails.vulnetix.com/openai/{orgUuid}/v1/chat/completions. If you get a 404, log the outgoing request and check that path before you touch anything else.

Your Java is untouched:

@Bean
CommandLineRunner demo(ChatClient.Builder builder) {
    ChatClient chat = builder.build();
    return args -> System.out.println(
        chat.prompt().user("Hello").call().content()
    );
}

For a non-OpenAI provider, keep the OpenAI starter — the wire format is OpenAI-compatible — and change the slug in base-url plus the model:

spring:
  ai:
    openai:
      base-url: https://guardrails.vulnetix.com/groq/${VULNETIX_ORG_UUID}
      api-key: ${VULNETIX_API_KEY}
      chat:
        options:
          model: llama-3.3-70b-versatile

Model names pass through the gateway verbatim.

Streaming

Unchanged.

Flux<String> stream = chat.prompt().user("Hello").stream().content();

Handling a guardrail block

A request your policy refuses never reaches the provider. It comes back as an OpenAI-shaped 403, which Spring AI’s RestClient raises as HttpClientErrorException.Forbidden, carrying the response body. The two fields worth reading are code and blocked_by.

import org.springframework.web.client.HttpClientErrorException;

try {
    String answer = chat.prompt().user(question).call().content();
} catch (HttpClientErrorException.Forbidden e) {   // HTTP 403
    String body = e.getResponseBodyAsString();
    // {"error":{"code":"request_blocked","blocked_by":"<rule>","message":"…"}}
    JsonNode error = objectMapper.readTree(body).path("error");

    throw new IllegalStateException(
        "AI firewall refused this request: %s (rule: %s)".formatted(
            error.path("code").asText(), error.path("blocked_by").asText("n/a")), e);
}
codeMeaning
request_blockedA content guardrail matched. blocked_by names the rule; violations lists every rule that matched.
provider_deniedPolicy does not allow this provider.
model_denied / model_not_allowedPolicy does not allow this model.
provider_key_missingNo provider key in the vault for this provider.

Spring AI’s retry support sits in front of this. Depending on your configuration the 403 may reach you wrapped as a NonTransientAiException — unwrap getCause() to find the HttpClientErrorException and its body. That classification is correct: a firewall block is not transient, and the same request will be refused every time, so check that no retry policy of yours is looping on it.

Note Redaction is silent — nothing is thrown. A pii_redact rule rewrites the matched spans in the prompt to the literal [REDACTED] and forwards the request. The ChatResponse comes back normally, about redacted text.

Tool / function calling

@Tool methods and ToolCallback beans pass through untouched — Spring AI drives the loop, and the gateway forwards each request. Guardrails inspect the request Spring AI sends, which on the turn after a tool runs contains that tool’s return value. A tool that reads a database row full of PII hands it to the next request, where the firewall can redact or block it.

Gotchas

  • api-key is your Vulnetix key. A provider key here is the most common 401.
  • base-url takes no /v1. Spring AI adds the completions path itself. This is the opposite of the OpenAI SDKs and the most likely thing to bite you here.
  • If your application also configures spring.ai.anthropic.*, that client speaks the Messages API and needs the gateway’s Anthropic route (https://guardrails.vulnetix.com/anthropic/{orgUuid}). The request must land on …/{orgUuid}/v1/messages.
  • Embeddings are not proxied by the gateway. Leave spring.ai.openai.embedding.* pointed at its provider.