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
/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);
}
code | Meaning |
|---|---|
request_blocked | A content guardrail matched. blocked_by names the rule; violations lists every rule that matched. |
provider_denied | Policy does not allow this provider. |
model_denied / model_not_allowed | Policy does not allow this model. |
provider_key_missing | No 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.
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-keyis your Vulnetix key. A provider key here is the most common401.base-urltakes 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.