OpenRouter 原生集成 Spring AI:openrouter-spring-ai 0.1.0-RC3 发布
Subhransu-De/openrouter-spring-ai
社区项目 Subhransu-De/openrouter-spring-ai 发布 0.1.0-RC3,为 Spring AI 提供 OpenRouter 原生集成,支持类型化模型回退、provider 路由、reasoning 回放、成本核算与归因请求头。
Native OpenRouter integration for Spring AI
A native Spring AI integration for
OpenRouter, with typed model fallbacks, provider routing,
reasoning replay, cost accounting, and attribution headers under spring.ai.openrouter.*.
Spring AI's OpenAI integration can also send OpenRouter-specific fields through
extraBody.
This library adds typed routing and OpenRouter-specific response and reasoning handling;
it does not promise universal provider parity. It owns its RestClient/WebClient
transport and wire mapping, unlike the vendor SDK cores used by Spring AI 2.0.1's
OpenAI and Anthropic integrations.
Community project: This library is independently maintained and is not affiliated with, endorsed by, or an official project of OpenRouter or Spring AI.
Install only one OpenRouter Spring AI starter in an application. Combining this starter with another OpenRouter starter can make provider selection and bean creation order-dependent.
Chat Completions is the production default. The optional Responses request mode is experimental; applications should opt into it explicitly.
The repository is now Subhransu-De/openrouter-spring-ai; Maven coordinates and Java
packages are unchanged. Published 0.1.0-RC1 metadata retains its historical broken
module backlinks and cannot be changed in place. RC2 contains the corrected links.
Install and make a first call
Use the published de.subhransu:openrouter-spring-ai-starter:0.1.0-RC3 from Maven
Central. For a new application, create this pom.xml:
<project xmlns="http://maven.apache.org/POM/4.0.0"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>example</groupId> <artifactId>hello-openrouter</artifactId> <version>1.0.0</version> <properties><java.version>17</java.version></properties> <dependencies> <dependency> <groupId>de.subhransu</groupId> <artifactId>openrouter-spring-ai-starter</artifactId> <version>0.1.0-RC3</version> </dependency> </dependencies> <build><plugins><plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin></plugins></build> </project>
Or create build.gradle.kts:
plugins {
java
id("org.springframework.boot") version "4.1.1"
}
repositories { mavenCentral() }
java { sourceCompatibility = JavaVersion.VERSION_17 }
dependencies {
implementation(platform("org.springframework.boot:spring-boot-dependencies:4.1.1"))
implementation("de.subhransu:openrouter-spring-ai-starter:0.1.0-RC3")
}The starter includes Spring AI's ChatClient auto-configuration. Set these application
properties in src/main/resources/application.properties, supply OPENROUTER_API_KEY
and a suitable OPENROUTER_MODEL in the environment, and use the injected builder:
spring.ai.model.chat=openrouter spring.ai.model.embedding=none spring.ai.model.image=none spring.ai.openrouter.api-key=${OPENROUTER_API_KEY} spring.ai.openrouter.chat.model=${OPENROUTER_MODEL}
package example; import org.springframework.ai.chat.client.ChatClient; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; @SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } @Bean CommandLineRunner hello(ChatClient.Builder builder) { ChatClient client = builder.build(); return args -> System.out.println(client.prompt().user("Say hello.").call().content()); } }
Save the class as src/main/java/example/Application.java. Run mvn spring-boot:run
or gradle bootRun with JDK 17 or later. The first call uses the configured model and
makes a billable OpenRouter request. No clone or local library installation is needed.
For incremental text, use client.prompt().user("Say hello.").stream().content()
and subscribe to the returned Flux<String> for the application's lifetime.
| Property | Default / meaning |
|---|---|
spring.ai.model.chat, .embedding, .image |
Each defaults to openrouter; the example disables unused modalities. |
spring.ai.openrouter.api-key |
Required while an OpenRouter modality is enabled; no default credential. |
spring.ai.openrouter.base-url |
https://openrouter.ai/api/v1 |
spring.ai.openrouter.chat.model |
Unset; select a model explicitly. |
spring.ai.openrouter.chat.request-mode |
openai-chat-completions; openai-responses is experimental. |
spring.ai.openrouter.chat.include-usage |
Unset; do not configure it globally when composing Responses requests. |
spring.ai.openrouter.connection.timeout |
2m |
spring.ai.openrouter.connection.max-response-body-size |
64MB per response / SSE event, not per stream. |
Release compatibility
| Source / artifact | Java release target | Spring Boot | Spring AI |
|---|---|---|---|
RC1: 74da429 |
17 | 4.1.1 | 2.0.1 |
RC2: 23473f7 |
17 | 4.1.1 | 2.0.1 |
RC3: e336517 |
17 | 4.1.1 | 2.0.1 |
Unreleased main (0.1.0-SNAPSHOT) |
17 | 4.1.1 | 2.0.1 |
Baselines come from the tagged/root POMs and published RC3 parent POM. CI targets JDK 17, 21, and 25; this does not establish compatibility with arbitrary Boot or Spring AI versions. Use the matching BOM baseline in the consuming application.
RC3, released October 2, 2026,
adds strict function tools, explicit cache breakpoints, optional request/response extensions,
embedding metadata/dimension discovery, cumulative Responses state limits, PDF/audio/video
inputs, and chat audio output, among other changes in its release notes.
See the RC2 README
for the RC2 API. The sections below describe main, which matches RC3 until a later
change lands.
RC2 code that calls a ChatCompletionRequest or ResponsesRequest constructor no
longer compiles on RC3: those signatures are gone. Build the requests with
builder() instead (see Supported API and nullability).
Options: replacement and composition
Raw ChatModel.call(new Prompt(text, options)) and stream(...) replace the model's
defaults whenever prompt options are supplied. With no prompt options, model defaults
apply. A portable ChatOptions does not carry OpenRouter's request mode: supplying it
to a Responses-configured raw model selects Chat Completions again. Supply complete
OpenRouterChatOptions (including model and mode) or derive them from getOptions().
ChatClient instead composes model defaults with the effective options builder.
Null means inherit; non-null scalar values override (builder composition appends
stop/tool lists). In Spring AI 2.0.1, .options(builder) replaces the client-level
.defaultOptions(builder) customizer; it does not erase the model defaults.
client.prompt(Prompt) composes supplied prompt options with the client customizer.
For example, given an injected OpenRouterApi api, this synthetic mode switch fails
before HTTP because the model defaults retain includeUsage=true:
var chatModel = OpenRouterChatModel.builder() .openRouterApi(api) .defaultOptions(OpenRouterChatOptions.builder() .model("your-model").includeUsage(true).build()) .build(); ChatClient client = ChatClient.builder(chatModel).build(); client.prompt().user("Say hello.") .options(OpenRouterChatOptions.builder() .requestMode(OpenRouterRequestMode.OPENAI_RESPONSES) .includeUsage(null)) .call().content();
Setting includeUsage(false) also fails: Responses rejects any non-null value.
An inherited seed or stop sequence fails for the same reason. For clients that switch
modes, leave Chat Completions-only options unset in both model and client defaults;
set includeUsage(true) only on individual Chat Completions requests. Alternatively,
create separate models/clients with mode-appropriate defaults. Mutating a request to
null does not clear a value inherited during composition.
Embedding and image models merge supplied options with their defaults directly;
unset fields inherit. Their behavior is intentionally different from raw chat replacement.
The implemented Spring AI interfaces are ChatModel/StreamingChatModel,
EmbeddingModel, and ImageModel. OpenRouterImageModel.stream() is a provider
extension, and accepting a Document for embedding does not implement DocumentEmbeddingModel.
Supported API and nullability
The supported consumer API consists of the following types under
de.subhransu.openrouter.springai. Existing public visibility is unchanged.
| API | Supported use |
|---|---|
chat, embedding, image |
Models and their builders, options and option builders, routing/reasoning/format records, usage and generated-image metadata. Use the Spring AI model interfaces for calls and streams. |
OpenRouterIdentifiers |
Provider identifiers used to select this integration. |
api.OpenRouterApi, api.OpenRouterRequestMode |
Direct HTTP calls and client construction, including RestClient.Builder, WebClient.Builder, Jackson, attribution, timeouts, and response limits. |
api.dto |
Low-level request and response records used by OpenRouterApi. Build ChatCompletionRequest and ResponsesRequest with builder(); wire fields can be absent. Responses mode remains experimental. |
chat.OpenRouterToolFailurePolicy, chat.OpenRouterToolCallingManagers, chat.OpenRouterToolExecutionExceptionProcessor |
Custom tool-manager failure policy, a factory for policy-declaring managers, and failure rendering. The policy accessor must return the actual, non-null processor. Tool execution belongs to Spring AI's advisor. |
errors, chat.errors, api.errors.OpenRouterApiException |
Exception types, diagnostic records, category enums, and inspection interfaces. Missing diagnostics are nullable. OpenRouterHttpException is sealed; consumers cannot implement it. |
autoconfigure.*Properties and spring.ai.openrouter.* |
Boot property binding. Custom model, API, and tool beans use the existing auto-configuration backoff rules. |
Build low-level requests by naming the fields you set; unset fields are omitted from
the JSON body. build() throws NullPointerException when the required messages
(Chat Completions) or input (Responses) is missing:
ChatCompletionRequest request = ChatCompletionRequest.builder() .model("your-model") .messages(List.of(new ChatMessage("user", "Say hello.", null, null, null))) .temperature(0.2) .maxTokens(256) .build(); ChatCompletionResponse response = api.chatCompletion(request);
New OpenRouter request fields are added to the builders. The records' canonical constructors stay public because Java requires it, but their parameter lists grow with each new field, so code that calls them breaks when a field is added.
Mapper packages, internal, support.OptionSnapshots, error factories/classifiers,
OpenRouterExceptionMessage, OpenRouterErrorResponse, deserializers, runtime hints, and auto-configuration
implementation methods are implementation details even where Java visibility is public.
They are not supported extension points and may change between releases. Prefer the
consumer types above. This policy does not move classes or remove existing methods.
Consumer packages declare JSpecify @NullMarked; @Nullable marks optional values.
A nullable list or map does not imply nullable elements. Request lists and Spring AI
tool callback/context entries retain their non-null element contracts. JSON metadata
maps allow null values, and response arrays can contain null entries. Handle missing
response fields before dereferencing them, including usage, cost, and media metadata.
All maintained production packages, including implementation helpers and samples,
declare @NullMarked. Wire DTOs retain nullable fields and elements; model mappers
reject null choices, Responses output/message-content entries, and missing required
tool-call fields with a protocol exception before exposing them to Spring AI. Chat Completions tool
calls without IDs still execute: the Spring AI ID is empty and the follow-up wire ID
remains absent. Missing response IDs/models and usage retain Spring AI metadata
defaults (EmptyUsage when usage is absent); absent optional generation metadata
keys are omitted. The annotations themselves do not perform runtime validation.
Java method descriptors and the Java 17 baseline are unchanged. Existing Java callers continue to compile. Kotlin with strict JSpecify checking and null-aware Java analyzers may now reject unchecked dereferences of values that were already nullable at runtime. Use null checks or Kotlin safe calls. The consumer compilation tests cover optional options, response fields and elements, Spring AI option types, and a custom failure policy.
A null runtime option means "inherit the configured default", not "clear it". For
example, if defaults include seed(7), merging a runtime option with
requestMode(OPENAI_RESPONSES).seed(null) retains seed 7 and fails Responses validation.
Use defaults without that Chat Completions-only option when switching modes. Calling
defaults.mutate().seed(null).build() creates a separate defaults object with no seed;
it does not clear a seed when that object is merged back into the original defaults.
Builders, copies, merges, and model defaults snapshot option containers. JSON maps and
lists are copied recursively; opaque callbacks and application objects retain identity.
This does not promise arbitrary deep immutability or general thread safety. Existing
option setters and Boot property beans remain mutable. Do not mutate shared options
concurrently with calls. The ownership regressions for #18 remain in
OptionSnapshotTests and OpenRouterChatModelSnapshotTests.
Request-mode option contract
Both modes map outputSchema and responseFormat: Chat Completions uses
response_format, and Responses uses text.format. An explicit responseFormat
takes precedence over outputSchema. Portable schemas leave strict unset;
OpenRouterResponseFormat.jsonSchema(name, strict, schema) preserves an explicit
true or false. Schema enforcement depends on the selected provider. Function-tool strictness is
controlled independently with toolStrict, described below.
Configure structured-output defaults under the flattened chat namespace (no
options segment):
spring: ai: openrouter: chat: response-format: type: json-schema name: answer strict: true schema: | {"type":"object","properties":{"answer":{"type":"string"}},"required":["answer"],"additionalProperties":false}
response-format.type also accepts text and json-object. For a portable
schema, set spring.ai.openrouter.chat.output-schema to the JSON document instead;
it uses the schema name response and leaves strict unset. An explicit
response-format wins when both are configured. Omit strict to leave it unset,
or set it to true or false explicitly. With neither option configured, no
format is sent. JSON-schema formats require a schema; malformed JSON fails before
the request is sent. These properties use the same mapping as Java options for
calls and streams in both request modes.
Responses rejects explicitly set stopSequences, seed, repetitionPenalty, minP,
topA, and includeUsage with IllegalArgumentException before sending a request.
Unset these options or use Chat Completions. Responses usage is read from the response
without an opt-in toggle. maxCompletionTokens takes precedence over maxTokens and
maps to max_output_tokens. Other sampling, routing, metadata, image, and tool controls
map to their corresponding wire fields. requestMode selects the endpoint;
toolCallbacks supply tool definitions and toolContext stays on the client.
toolChoice accepts "auto", "none", "required", or a named function object:
{type: "function", function: {name: "lookup"}} or
{type: "function", name: "lookup"}. Both named shapes are converted to the selected
endpoint's form. Legacy {type: "auto"}, {type: "none"}, and
{type: "required"} objects are normalized to strings. Other shapes are rejected. These rules apply to calls and streams.
Optional request and response extensions
OpenRouterChatOptions.builder().extraBody(Map.of("logprobs", true, "top_logprobs", 0))
adds supported optional fields at the request root. This is a bounded extension API:
unknown keys and standard fields such as model, messages, input, stream,
provider, and tools are rejected, even when their typed option is unset.
| Extension keys | Chat Completions | Responses | Java / wire representation |
|---|---|---|---|
logit_bias, logprobs |
Supported | Rejected | Token-ID map / boolean at request root |
top_logprobs, prompt_cache_key |
Supported | Supported | Integer / nullable string at request root |
verbosity |
Supported | Rejected | String at request root; Responses text.verbosity is not exposed |
transforms |
Supported | Rejected | List of transform names at request root; [] disables middle-out |
Provider only, zdr, max_price |
Supported | Supported | List / boolean / object inside provider; hard routing restrictions |
Provider sort, preferred_min_throughput, preferred_max_latency |
Supported | Supported | String or structured sort / number or percentile object; routing preferences |
The matrix follows the OpenRouter parameter reference,
provider routing guide,
and Responses schema.
Model/provider support still applies; top_logprobs on Chat Completions requires
logprobs=true. The same mapping and validation apply to calls and streams.
OpenRouterExtensionTests covers serialization, endpoint rejection, routing, and
response preservation; OpenRouterExtensionPropertiesTests covers Boot binding.
OpenRouter applies its middle-out message transform by default to endpoints with a context of 8,192 tokens or less. A prompt longer than the context is then shortened and answered without an error. Send extraBody(Map.of("transforms", List.of())) to receive a context-length error instead. In Boot properties, spring.ai.openrouter.chat.extra-body.[transforms]= sends an empty list, and indexed entries such as [transforms][0]=middle-out send a list.
Provider extensions use OpenRouterChatOptions.builder().providerExtraBody(...).
OpenRouterProviderPreferences retains its existing constructor and string sort accessor. An extension
sort conflicts with a non-null typed sort and is rejected rather than overriding it.
For example, the extension map can contain Map.of("sort", Map.of("by", "latency", "partition", "none")).
Boot binds root extensions under spring.ai.openrouter.chat.extra-body and provider
extensions under spring.ai.openrouter.chat.provider-extra-body. Preserve wire key
spelling with bracket notation in properties:
spring.ai.openrouter.chat.extra-body.[logprobs]=true
spring.ai.openrouter.chat.extra-body.[top_logprobs]=0
spring.ai.openrouter.chat.provider-extra-body.[zdr]=true
spring.ai.openrouter.chat.provider-extra-body.[sort].by=latencyRoot extension maps merge by key: runtime entries replace default entries, including
explicit null values. Nested values replace whole objects; they do not merge recursively.
Typed provider options replace the default typed provider object, as before;
providerExtraBody merges independently by key, retaining default routing restrictions unless explicitly overridden. Maps and lists
are snapshotted recursively. Boolean false, numeric zero, and explicit nulls remain on
the wire; a null map inherits defaults. Extensions do not enable n, store, cache
breakpoints, web-search requests, or server-tool configuration.
Chat response DTOs retain unknown fields in extensions(), flattened on serialization.
Spring AI assistant metadata exposes openrouter.message.extensions,
openrouter.choice.extensions (including log probabilities), and
openrouter.tool_call.extensions (keyed by call ID). Response metadata exposes
openrouter.response.extensions. Streamed message annotations append in arrival order;
Choice logprobs.content and logprobs.refusal token arrays also accumulate;
other opaque fields use the latest value per key. Tool fields survive argument-fragment
aggregation, and assistant extension metadata is cumulative per choice and subscription.
These fields are inspection-only and are not automatically replayed as request fields.
Responses output annotations and opaque tool fields remain available in the existing
openrouter.responses.output_items snapshot through each item's rawItem() / wireValue().
The existing ordered reasoning replay policy is unchanged. Citation data is preserved
when returned; this does not add web-search or server-tool execution support.
Strict function tools
Set OpenRouterChatOptions.builder().toolStrict(true) or
spring.ai.openrouter.chat.tool-strict=true to request strict schemas for every
function tool. This applies to calls and streams in both modes: Chat Completions
sends tools[].function.strict; experimental Responses sends tools[].strict.
There are no per-tool overrides. During option composition, an omitted/null option
inherits the default; a non-null runtime option overrides it, including false. With no default, null
omits the wire flag, preserving provider defaults; false explicitly disables
strictness. Copying and composing options preserve these distinctions.
The library validates rather than adapts schemas when true. Supply an object
root, additionalProperties: false on every object, and a required array
containing every property exactly once (empty objects use empty properties and
required). Optional values must explicitly allow null and remain required,
for example "note": {"type": ["string", "null"]} with "note" in required.
The validator follows nested properties, array items, anyOf, definitions, and
local JSON-pointer $refs, including recursion. External/unresolved references,
root anyOf, and unsupported composition such as allOf/oneOf are rejected
before HTTP. Schemas are never rewritten; the callback retains its original schema.
Omitted/false strictness preserves existing schema handling.
Use a routed model/provider that supports strict function tools; this option does
not discover capabilities or guarantee support across OpenRouter. Validation checks
the common strict function contract,
not every provider-specific schema keyword, size limit, or model restriction.
Provider rejections propagate through normal API errors, without silently retrying
as non-strict. ToolCallingAdvisor still owns execution; response-format strictness
is unaffected.
Retries and Responses failures
Synchronous Responses failures carried over HTTP 200 use
OpenRouterTransientApiException or OpenRouterNonTransientApiException, replacing
the legacy OpenRouterApiException for this request mode. Inspect their shared
OpenRouterHttpException contract for sanitized error details and category;
the status is derived from the in-band error, and the endpoint is /responses.
Transient provider, rate-limit, and timeout failures qualify for Spring AI's
default retry policy. Authentication, billing, invalid requests, refusals, and
unknown failures do not.
A response that breaks the wire contract the library enforces raises OpenRouterProtocolException, a NonTransientAiException, in every request mode, whether the call is synchronous or streaming. This covers undecodable JSON, missing required fields, blank tool names, conflicting stream fragments, embedding count, index, or dimension mismatches, and invalid audio. Earlier releases raised IllegalStateException or IllegalArgumentException for some of these, so catch OpenRouterProtocolException instead. Invalid request options still raise IllegalArgumentException before the request is sent.
Chat Completions, Responses, and image model streams retry only actual HTTP 429
responses classified as rate-limit rejections, and only when the model's configured
RetryTemplate policy also permits the exception. HTTP 5xx responses, connection
failures, timeouts, authentication and validation failures, and errors inside an
accepted stream are not retried. No output does not prove that an upstream request
was not executed or billed. This deliberately narrower policy avoids replaying
ambiguous requests. Tool execution remains with ToolCallingAdvisor.
Streaming reuses the template's retry limit, backoff, exception predicate, and timeout,
including the template supplied by Boot's spring.ai.retry.* configuration. Without
a supplied template, Spring AI 2.0.1's default allows ten retries after the first
attempt, starting at 2 seconds with a multiplier of 5 and a 3-minute delay cap.
Retry-After can extend a delay up to 5 minutes; invalid or larger values fall back
to configured backoff. A delay that cannot fit within the remaining policy timeout
ends retries with the original typed exception. Delays are nonblocking, and
cancellation cancels pending retries. Blocking RetryListener callbacks are not
invoked. Use transport filters to count attempts; one model observation covers the
whole subscription and records the final response or error once.
The first decoded event closes retry eligibility, even if it contains only usage or
metadata or is later filtered by a mapper. Partially delivered streams are never
replayed. Direct OpenRouterApi streaming calls remain single-attempt. Garage's
configured template uses two retries with a 200 ms delay; the stricter HTTP 429
streaming eligibility rule still applies.
HTTP customization and timeout ownership
The supported transport extension points are Spring's RestClient.Builder request
interceptors and WebClient.Builder filters, supplied to OpenRouterApi.builder().
Boot auto-configuration uses the application-provided builders and clones them before
applying OpenRouter settings. Reusing an application singleton builder for an unrelated
client retains its original factory, base URL, and headers; OpenRouter credentials and
attribution are applied only to the OpenRouter client.
| Configuration | Blocking calls (all model families) | Streaming (chat, Responses, images) |
|---|---|---|
Programmatic OpenRouterApi.Builder.timeout(...) |
Does not configure connect/read timeouts; supply a configured request factory via restClientBuilder(...) |
Guards response-header/body waits and idle gaps between SSE events, including keepalives |
Boot spring.ai.openrouter.connection.timeout |
Composes connect/read timeouts with HttpClientSettings through the ClientHttpRequestFactoryBuilder bean |
Applies the same duration as the API's streaming idle guard |
Boot installs its factory on the cloned builder, replacing any factory installed directly
on that clone. Customize transport selection through ClientHttpRequestFactoryBuilder
and other settings through HttpClientSettings. To own the factory completely, provide
an OpenRouterApi bean; API auto-configuration backs off. Programmatic timeout(null)
disables the streaming guard, without changing the supplied connector or blocking factory.
There are no header or timeout fields in model request options. For request-scoped headers, install an interceptor that reads application-owned call context during a blocking invocation, or a filter that reads Reactor Context at subscription time. For example, this filter sends a synthetic correlation header:
WebClient.Builder transport = WebClient.builder() .filter((request, next) -> Mono.deferContextual(context -> { ClientRequest.Builder outgoing = ClientRequest.from(request); context.<String>getOrEmpty("correlation-id") .ifPresent(id -> outgoing.header("X-Correlation-ID", id)); return next.exchange(outgoing.build()); })); OpenRouterApi api = OpenRouterApi.builder() .apiKey(apiKey) .webClientBuilder(transport) .build(); api.imagesStream(request) .contextWrite(context -> context.put("correlation-id", "synthetic-123"));
Use org.springframework.web.reactive.function.client.ClientRequest and
reactor.core.publisher.Mono in this example. Blocking call context must be cleared in
finally; a thread-local does not propagate across reactive scheduler switches. Keep
interceptors/filters safe for concurrent use: cloning a builder does not deep-copy
their captured state. Do not mutate shared builders, factories, or default headers to
customize a single call. Restrict custom headers to application-approved names; keep
credentials and destinations fixed when constructing each client, and do not forward
authorization through URI-rewriting filters or cross-origin redirects.
Select a preconfigured API/model instance when calls need different connect/read or
stream-idle policies. A caller can also apply Reactor timeout(duration) to a returned
stream for a per-subscription signal deadline. That operator measures gaps in emitted
items (not wire keepalives), cancels its subscription on expiry, and does not reconfigure
transport timeouts or other subscriptions. It is not a total-stream deadline. Blocking
per-invocation timeout overrides are not provided by this library's portable API.
Automatic per-family client selection is deferred. Applications needing independent
chat, embedding, or image transports can construct those models with separate
OpenRouterApi instances using their existing model builders; Boot otherwise shares
one API. Connection-pool metrics, credential-provider abstractions, and typed successful
rate-limit snapshots are also deferred. An interceptor/filter may inspect selected
successful-response headers before returning the response unchanged; do not consume
its body or log credentials. HTTP errors separately expose parsed Retry-After through
OpenRouterHttpException.getRetryAfter(). Successful response headers are not copied
into model metadata, and inspecting them does not alter retry behavior.
Chat, Responses, and image SSE streams reject malformed JSON and non-object data
payloads with OpenRouterProtocolException and cancel the HTTP body. This also
applies after partial output. SSE comments and empty heartbeat events are ignored.
Chat and image streams terminate at [DONE]; Responses requires its own protocol
terminal event.
Response buffering limits
spring.ai.openrouter.connection.max-response-body-size (default 64MB) limits each
blocking response, each SSE event, and the complete JSON fallback for streamed image
requests. It does not cap the total size of an SSE stream. Streaming success decoding
uses Spring's codec limits and reports DataBufferLimitException when exceeded
(wrapped in WebClientResponseException for chat and Responses).
spring.ai.openrouter.connection.max-error-body-size (default 64KB) bounds HTTP error
body retention for both blocking and streaming requests. Oversized errors report
OpenRouterLimitExceededException with the endpoint, HTTP status, bounded sanitized
excerpt, and any recoverable error details. The observed size is a lower bound.
Both settings accept 1 through 2147483646 bytes; invalid values fail property binding.
Java callers can configure the same limits with OpenRouterApi.Builder.
Status
Implemented on main, with synthetic contract coverage (not a claim of current live provider success):
- Chat via OpenRouter's OpenAI-compatible
POST /chat/completionsendpoint - Synchronous calls and SSE streaming
- Tool calling via Spring AI 2.0's
ToolCallingAdvisoronChatClient(see below) - Streamed tool-call argument fragments (split by
indexacross chunks) merged into complete tool calls - Reasoning options, reasoning text, and opaque reasoning state preserved across tool continuations
- Usage accounting including cost, cached and reasoning tokens
- Model fallback lists, provider routing preferences, service tiers
- Spring Boot auto-configuration with full property binding
- Observability: Micrometer
gen_ai.client.operationobservations for calls and streams - Embeddings via
POST /embeddingsbehind Spring AI'sEmbeddingModel(spring.ai.openrouter.embedding.*properties, provider routing included) - Image inputs:
UserMessagemedia becomesimage_urlcontent parts (URLs pass through, byte-backed media is sent as base64 data URLs), in both chat-completions and responses mode - Image generation via OpenRouter's unified Image API (
POST /images) behind Spring AI'sImageModel, including reference images, SSE partial-image streaming, andspring.ai.openrouter.image.*properties - Image generation via chat completions and responses mode
(
modalities: ["image", "text"]+image_config), with generated images surfaced asAssistantMessagemedia in sync and streaming responses
Planned:
- The models catalogue endpoint
- OpenRouter server-side tools (web search plugin); returned annotations are already preserved
- Text-to-speech and transcription
PDF, WAV/MP3 audio and video chat inputs, plus streaming chat audio output, are implemented on main; these are not standalone speech/transcription interfaces. Reasoning details and optional response extensions are preserved as described below.
Configured means an option or scene is selected. Synthetic-tested means deterministic fixtures exercise the contract; this includes the sample suite's mocked Garage run described in the Garage demo. Live-verified requires a dated successful run against OpenRouter for its exact commit and request mode. CI makes no OpenRouter calls and holds no OpenRouter key, so live verification is a manual Garage run with your own key; a workflow definition or badge is not that evidence. No current all-capabilities live success is asserted here. Unsupported, incomplete, failed and partial outcomes remain explicit. For a dated example, the September 19, 2026 nightly run succeeded at commit 4203c88af0ba88cc320ff190297007c17a79babe with --text --embedding --vision --request-mode=both. That evidence applies to that commit and selection, excludes image generation, and does not certify later additions or registry entries marked unsupported in Responses.
Modules
| Module | What it is |
|---|---|
openrouter-spring-ai |
Core: API client, wire DTOs, mappers, chat/embedding/image models and options |
openrouter-spring-ai-autoconfigure |
Spring Boot auto-configuration and spring.ai.openrouter.* binding |
openrouter-spring-ai-starter |
The starter — the one dependency applications add |
openrouter-spring-ai-samples |
The Garage demo application (see below) |
Module names, property prefixes, and layering deliberately mirror Spring AI's official providers.
Tool calling
Spring AI 2.0 moved the tool-execution loop out of the chat models and into
ToolCallingAdvisor, which runs in the ChatClient advisor chain. This library follows
that design exactly (as do Spring AI's own OpenAI and Anthropic models):
- The model advertises and surfaces, never executes.
OpenRouterChatModelputs the tool definitions from yourToolCallbacks on the wire and returns tool-call responses as-is (with aTOOL_CALLSfinish reason), for bothcall()andstream(), in both chat-completions and responses mode. Your callbacks are never invoked by the model. ChatClient+ToolCallingAdvisorruns the loop. The advisor invokes yourToolCallbacks, appends the results to the conversation, and calls the provider again until a final answer arrives — for calls and streams alike.ChatClientauto-registers the advisor by default, soChatClient.builder(chatModel).build()is enough; opt out per request withadvisors(AdvisorParams.toolCallingAdvisorAutoRegister(false))to surface tool calls without executing them.
In Chat Completions streams, a tool's function name must arrive as a complete name.
Missing or blank names are ignored until a nonblank name arrives; identical repeated
names are accepted. Differing nonblank names (including split-name fragments) and
completed calls without a usable name fail with OpenRouterProtocolException before the
call is emitted. JSON argument fragments are still concatenated by tool index, and
calls require a tool-call finish reason before they can be emitted.
A synchronous Chat Completions tool call, or any Responses tool call, with a blank function name fails with OpenRouterProtocolException before a callback is selected. A blank Responses call_id fails the same way. Missing Chat Completions tool-call IDs remain accepted, and malformed JSON arguments still reach the tool, which reports them as a tool failure.
Option builders, copies, and model defaults own detached collection snapshots. Provider
routing lists, image input references, and nested JSON maps/lists (metadata, image
configuration, provider options, and tool choice) are read-only through getters. Use
mutate() to derive changed options. Tool-context maps/lists are also snapshotted;
callbacks and other opaque application objects remain shared by identity. Use JSON
maps/lists for container isolation; arbitrary mutable objects are not cloned. Null
values and collection order are preserved. Embedding builders return independent
results; explicit setters change only the option instance on which they are called.
Register tools on the request options (or the ChatClient), not as model default
options: the advisor executes with the options it sees on the prompt. Tools declared only
by bean name are resolved through the ToolCallbackResolver configured on the
ToolCallingManager during execution.
Model selection and replacement beans
The starter intentionally backs off for any application-declared ChatModel,
EmbeddingModel, or ImageModel of the corresponding modality. This includes another
provider's model and a custom OpenRouter model. Setting spring.ai.model.chat=openrouter
enables chat auto-configuration but does not override a replacement ChatModel bean.
The same rule applies to spring.ai.model.embedding and spring.ai.model.image.
This preserves existing replacement-bean behavior and requires no migration.
Each selector defaults independently to openrouter. Use none to disable a modality,
or another provider's identifier to select its auto-configuration. Disabling chat alone
leaves OpenRouter embeddings and images enabled and still requires an API key. Set all
three selectors to none to disable the shared OpenRouter API as well. Replacement model
beans alone do not disable that API.
When several provider starters are installed, set each selector explicitly. This policy
does not arbitrate between competing auto-configurations that match missing selectors;
their ordering must not be used to choose a provider. For multiple models of one modality,
declare the model beans yourself and inject them with @Qualifier using their bean names.
The starter's default bean names are openRouterChatModel, openRouterEmbeddingModel,
and openRouterImageModel. Selection properties do not remove application-declared beans.
A replacement ChatModel also disables OpenRouter's automatic tool-failure processor
and manager guard. Applications that declare their own chat models own the shared tool
failure policy described below.
Tool failure policy
When OpenRouter is selected and no replacement ChatModel is declared, the starter
supplies OpenRouterToolExecutionExceptionProcessor before Spring AI creates its
shared manager. It returns a stable sanitized error result. This processor also applies
to other providers using that shared manager. A declared ToolExecutionExceptionProcessor
bean takes precedence; spring.ai.tools.throw-exception-on-error=true retains Spring AI's
throwing policy instead.
A custom ToolCallingManager bean must declare its failure policy. To build Spring AI's
default manager with a known processor, use
de.subhransu.openrouter.springai.chat.OpenRouterToolCallingManagers:
@Bean ToolCallingManager toolCallingManager(ToolExecutionExceptionProcessor processor, ToolCallbackResolver resolver) { return OpenRouterToolCallingManagers.withFailurePolicy(processor, builder -> builder.toolCallbackResolver(resolver)); }
The customizer receives Spring AI's DefaultToolCallingManager.Builder. The factory sets
the processor after the customizer runs, so a processor set inside the customizer is
replaced. A plain ToolCallingManager.builder().build() bean fails at startup because
Spring AI does not expose which processor it uses.
Custom and delegating implementations can instead implement
de.subhransu.openrouter.springai.chat.OpenRouterToolFailurePolicy and return the processor
actually used by execution from toolExecutionExceptionProcessor(). Delegates must use that
same processor. The contract applies to both calls and streams; it declares application
responsibility and cannot prove that a custom implementation honors its declaration.
Either way, the processor must be an application-declared bean or an
OpenRouterToolExecutionExceptionProcessor. To keep Spring AI's throwing behavior in a
custom manager, declare DefaultToolExecutionExceptionProcessor.builder().alwaysThrow(true).build()
as a bean and pass that bean to the manager; an inline instance fails validation.
Managers without a verifiable policy fail validation. The existing
spring.ai.openrouter.chat.allow-unsafe-tool-failure-results=true opt-out skips manager
validation and transfers failure-result redaction responsibility to the application; it does
not disable the automatically supplied processor. Declaring an unrelated processor bean
does not approve a manager that uses a different processor.
Explicit prompt-cache breakpoints
For Chat Completions, attach a List<OpenRouterCacheBreakpoint> to a system or user
message's metadata. Each exclusive endIndex splits the original text into a content
block with cache_control; the remaining text and image media stay in their original
order. Offsets use Java String.length() units, must increase, and cannot split a
surrogate pair. For example (synthetic content):
String reference = "Reference material to reuse.\n"; UserMessage message = UserMessage.builder() .text(reference + "Answer this turn's question.") .metadata(Map.of(OpenRouterCacheBreakpoint.METADATA_KEY, List.of(new OpenRouterCacheBreakpoint(reference.length())))) .build(); chatModel.call(new Prompt(message));
The default Ttl.FIVE_MINUTES emits {"type":"ephemeral"}; Ttl.ONE_HOUR
adds "ttl":"1h". Both calls and streams use these boundaries. There are no cache
model defaults or Boot properties: placement belongs to individual messages, so there
is no option precedence. Keep the typed metadata and unchanged text in conversation
history, including tool continuations. Custom history stores must restore the typed
breakpoints; generic maps are rejected. Recompute offsets when editing text.
The integration supports at most four breakpoints per request, with all one-hour boundaries before five-minute boundaries. Assistant messages, tool results, tool definitions, and image blocks are not supported breakpoint targets. Invalid metadata, offsets, ordering, or placement fail explicitly.
| Request protocol / provider | Supported placement and lifetime |
|---|---|
| Chat Completions / Claude-compatible providers | System and user text blocks; five minutes or one hour. |
| Chat Completions / supported Alibaba models | Text blocks; use five minutes. Model and endpoint support varies. |
| Chat Completions / supported Gemini models | Text blocks; use five minutes. Only the final boundary is used; a boundary in the first system message caches the normalized system prompt, including its trailing text. Put dynamic text in a later user message. |
| Chat Completions / other routes | Provider-dependent; no guarantee of explicit caching or TTL preservation. Select a supporting route. |
| Responses | This metadata is rejected. Its per-block prompt_cache_breakpoint has different semantics and is not implemented here. |
Provider/model eligibility and minimum prompt sizes are enforced upstream; the library
does not infer capabilities from model names or fallback routing. Consult
OpenRouter's prompt caching guide
for current restrictions. This explicit content API is narrower than Spring AI's
Anthropic caching strategies: it does not automatically select message or tool
boundaries. Provider implicit caching remains available without these markers.
Request-level automatic caching, prompt_cache_key, and cache usage accounting are
separate capabilities.
Reasoning conversation state
Assistant message metadata carries openrouter.reasoning (text) and
openrouter.reasoning_details (an ordered list of opaque JSON nodes) in Chat Completions
mode. Responses mode retains complete reasoning output items under
openrouter.responses.reasoning_items, including encrypted content and unknown fields.
The accompanying openrouter.responses.output_items snapshot preserves their positions
relative to text and function calls during replay.
Request mappers replay this state when the assistant message is included in conversation
history. Keep the original assistant message and its metadata when adding tool results.
Responses rejects history before sending a request if the assistant text, tool calls,
or openrouter.refusal differ from the saved output snapshot, or reasoning metadata has no
output snapshot. Custom history storage must retain both metadata fields. Editing or removing
content while keeping the reasoning metadata is rejected. To redact history, start a new
conversation with the edited messages and without the old reasoning state. Do not carry
encrypted reasoning or output snapshots into that new conversation, since they may retain
the original content. The assistant media history restriction also applies to generated images
retained in output snapshots, even if the message's media list was cleared.
Streaming assembles consecutive text and summary detail fragments, while retaining
encrypted and unknown detail types as opaque items. Assistant metadata contains
cumulative snapshots per choice and subscription,
so Spring AI message aggregation retains the complete reasoning state. Text content remains
incremental. The existing generation metadata key openrouter.reasoning remains available
for streamed reasoning deltas and synchronous reasoning text.
Chat Completions stream state limits
Chat Completions streaming has a separate per-subscription retained-metadata admission
budget, including streams without tools. Configure OpenRouterChatModel.Builder
with streamingStateMaxBytes and streamingStateMaxChoices, or Boot properties
spring.ai.openrouter.chat.streaming-state.max-size and
spring.ai.openrouter.chat.streaming-state.max-choices. Defaults are 1 MiB and 128
simultaneously active choices. Both values must be positive. These are model-wide
settings, not per-request options; they do not affect synchronous calls or Responses.
Before retaining metadata, the mapper charges the UTF-8 JSON size of each nonempty
incoming metadata map, including reasoning, reasoning details, refusals, annotations,
logprobs, and opaque extensions. Repeated or replacement metadata is charged again.
Each active choice also reserves 2,000 bytes for its 500-character diagnostic
excerpt. A finishing choice releases all its charges after its final snapshot is built;
response-level extension charges last until the subscription ends. This conservative
admission budget bounds retained data, not exact JVM heap use. Ordinary text deltas
consume no additional budget and remain incremental. Audio assembly, individual SSE
events, and tool-call aggregation retain their separate limits. Exceeding the budget
cancels upstream with OpenRouterLimitExceededException, using CHAT_STATE_BYTES or
CHAT_STATE_CHOICES; replay metadata is never silently truncated. Earlier emitted
snapshots retained by the caller are outside this budget.
Responses stream state limits
Responses streaming reuses the model builder's toolCallAggregationMaxBytes,
toolCallAggregationMaxChunks, and toolCallAggregationMaxDuration settings and the Boot
properties under spring.ai.openrouter.chat.tool-call-aggregation:
| Property | Default | Responses scope |
|---|---|---|
max-size |
1MB (1,048,576 bytes) |
Cumulative serialized UTF-8 JSON bytes admitted from the first state-bearing event through the terminal event |
max-chunks |
1024 |
Events in that interval, and separately the number of retained output items or items in a response snapshot |
max-duration |
2m |
Absolute elapsed time from the first state-bearing event; activity and keepalives do not reset it |
State-bearing events include output items, text and function-argument fragments, reasoning, refusals, and nonempty response output snapshots. Once started, the budget counts every event, including text, keepalives, and repeated item/terminal snapshots. Byte accounting is therefore conservative: it bounds admitted state, not exact JVM heap usage. Equality is allowed for byte, chunk, and item limits; the duration expires at its deadline. Limits apply independently per subscription to the single Responses output sequence and include all output indexes. They are model-level settings, not per-request options. Synchronous Responses and Chat Completions behavior are unchanged.
Exceeding a limit raises OpenRouterLimitExceededException with a RESPONSES_STATE_*
limit and /responses endpoint before the violating event can release tools. State and the
deadline subscription are released on completion, error, or cancellation. Normal completed
rounds retain their reasoning replay order. These cumulative limits are separate from the
connection's per-event codec size and idle timeout: small events or keepalives cannot evade
them. Increase the aggregation settings explicitly for larger expected Responses rounds.
Refusals and incomplete output
Both request modes expose provider refusal explanations under openrouter.refusal in
assistant message and generation metadata, separately from answer text. Check this key
to distinguish a refusal from ordinary empty output; a refusal can still have STOP
as its finish reason. Streaming metadata contains cumulative refusal text, with repeated
done events and terminal snapshots reconciled rather than concatenated.
Keep the original assistant message and its metadata in conversation history so follow-up
requests replay the refusal explanation, including refusal-only assistant turns.
Responses normalizes max_output_tokens to LENGTH and content_filter to
CONTENT_FILTER for both synchronous and streaming calls. Unknown incomplete reasons
pass through unchanged; absent reasons fall back to the response status. Generation
metadata retains the unnormalized reason as openrouter.native_finish_reason, the
status as openrouter.responses.status, and the typed incomplete details as
openrouter.responses.incomplete_details. Responses text preserves whitespace-only
parts and messages. Streams reconcile text-done, item-done, and terminal snapshots per
output item and content part, emitting only missing suffixes. Later text waits for earlier
parts to finish so recovered suffixes retain output order. A snapshot that contradicts a
received prefix or extends earlier text after later text was emitted raises
OpenRouterProtocolException. Missing text indexes default to zero.
Terminal snapshots also supply generated images that were not emitted by item-done events. Image IDs identify repeated items; absent IDs fall back to output indexes. An image event with neither an ID nor an index is rejected because identical bytes may belong to distinct images. Repeated identities must retain the same image result and format. Newly recovered images follow terminal output order after any images already emitted.
Responses streams require response.completed, response.incomplete, response.failed,
or a terminal error event. [DONE] or EOF alone raises OpenRouterTruncatedResponseException,
even for an empty or text-only stream. The legacy response.done shape shown in the
OpenRouter basic-usage guide
is explicitly unsupported and raises OpenRouterProtocolException; it does not map final
metadata. Chat Completions and image streams continue to accept [DONE].
Responses calls require a final completed or incomplete status and an output array,
which may be empty. Streaming terminal events also require a response snapshot whose
status matches the event. Missing or contradictory terminal structure fails before
buffered tool calls can execute. Non-null error envelopes raise typed provider exceptions
even over HTTP 200 and without status: failed. Optional fields may be absent and unknown
fields are ignored, but invalid typed fields fail decoding rather than discarding response
status or output. Valid empty text, refusals, tool calls, media, and incomplete text remain
supported.
A completed status or stop finish reason does not prove that the output ended before the token limit. When it matters, compare the output token usage with the configured maxCompletionTokens.
Embeddings
OpenRouterEmbeddingModel is auto-configured next to the chat model and implements Spring
AI's EmbeddingModel, so it plugs into vector stores and RAG advisors unchanged:
spring: ai: model: embedding: openrouter openrouter: api-key: ${OPENROUTER_API_KEY} embedding: model: openai/text-embedding-3-small dimensions: 256 # optional, model-dependent
float[] vector = embeddingModel.embed("The quick brown fox");
Provider routing (spring.ai.openrouter.embedding.provider.*) works the same way as for
chat. Only float embeddings are decoded; requesting encoding_format: base64 fails fast.
Document embedding keeps raw text by default (metadata-mode: none). To include metadata,
set spring.ai.openrouter.embedding.metadata-mode: embed, or use
OpenRouterEmbeddingModel.builder().metadataMode(MetadataMode.EMBED) in Java. Both
embed(Document) and batched document embedding use the document's content formatter;
EMBED honors its excluded embedding metadata keys. ALL and INFERENCE are also supported.
NONE bypasses formatting and preserves the original text. Changing the policy changes
embedding input, so existing vector collections may need re-embedding.
dimensions() returns positive configured dimensions without an API call. Otherwise it
caches the first successful discovery per model instance, with concurrent callers sharing
that discovery. Failed or invalid responses are not cached. The cache belongs to the
model's copied default configuration (including model, dimensions and provider routing);
per-request overrides never update it. Build a new model for a different default
configuration; modifying the options originally supplied to the builder does not change
an existing model or its cached dimensions.
Image inputs
Attach image media to a UserMessage and it is sent as OpenRouter image_url content
parts. Plain URLs pass through; classpath/byte resources are inlined as base64 data URLs:
UserMessage message = UserMessage.builder() .text("What is in this image?") .media(Media.builder() .mimeType(MimeTypeUtils.IMAGE_PNG) .data(URI.create("https://example.com/cat.png")) .build()) .build();
PDF, audio and video inputs
Both synchronous and streaming calls accept these UserMessage attachments:
| Media | MIME types | Chat Completions | Responses | Source |
|---|---|---|---|---|
application/pdf |
file with file_data |
input_file with file_data or file_url |
HTTP(S) URL, bytes or base64 data URL | |
| Audio | audio/wav, audio/mpeg |
input_audio with raw base64 and wav/mp3 format |
Same | Bytes or base64 data URL; remote URLs rejected |
| Video | video/mp4, video/mpeg, video/mov, video/webm |
video_url object |
input_video with string video_url |
HTTP(S) URL, bytes or base64 data URL |
UserMessage message = UserMessage.builder() .text("Summarize the attached document") .media(Media.builder() .mimeType(MimeTypeUtils.parseMimeType("application/pdf")) .name("report.pdf") .data(URI.create("https://example.com/report.pdf")) .build()) .build();
PDF filenames come from Media.getName(); set .name(...) to retain an original
filename. Audio/video names are not sent. Other file types, audio formats, MIME
parameters and mismatched data-URL MIME types are rejected explicitly. This does
not add Anthropic Messages support or uploaded file IDs. For generated audio, see
Chat audio output.
Each new inline attachment must contain 1 byte through 20 MiB of decoded data. The limit is checked before encoding bytes or decoding a base64 data URL. Empty and invalid base64 content is rejected. Provider request, file, duration and attachment-count limits still apply, including to remote URLs; the local limit is not a guarantee of provider acceptance. Existing image behavior is unchanged.
The integration never fetches attachment URLs. Spring AI materializes resources
when constructing Media, before this integration can enforce its limit; callers
must bound resource reads themselves or supply bounded bytes. Mapping does not
modify caller-owned bytes or messages. Reusing unchanged messages replays the same
attachments in order, after the message text, including across tool continuations.
A remote URL is replayed as a URL and may serve different content later.
Select a model/provider supporting the modality and format. OpenRouter validates routing compatibility and returns provider errors; the library does not maintain a static model allowlist. Video URL support is provider-specific (for example, Gemini on AI Studio accepts YouTube URLs). See the multimodal guide and endpoint schemas. No new Boot properties or options are required; attachments use the existing message API.
Image generation
OpenRouterImageModel implements Spring AI's ImageModel over OpenRouter's unified
Image API (POST /images):
spring: ai: model: image: openrouter openrouter: api-key: ${OPENROUTER_API_KEY} image: model: bytedance-seed/seedream-4.5 aspect-ratio: "16:9" quality: high output-format: webp
ImageResponse response = imageModel.call(new ImagePrompt("a red panda wearing sunglasses")); String base64 = response.getResult().getOutput().getB64Json();
The OpenRouter-native knobs (resolution, aspect-ratio, quality, output-format,
background, output-compression, seed) are available on OpenRouterImageOptions,
along with inputReferences for image-to-image work and providerOptions passthrough.
Generic Spring AI ImageOptions map model and n directly and translate paired
width/height into pixel size (a lone dimension is rejected after merging defaults).
responseFormat must be omitted or exactly b64_json; any other non-null format,
including url, and any non-null style fail with IllegalArgumentException before
HTTP transport in both call and stream. These portable settings have no wire fields.
Use OpenRouterImageOptions.fromOptions(...) to convert generic defaults; generic
per-request options override mapped fields while retaining unset native defaults,
including quality, outputFormat, and providerOptions. Native outputFormat
selects image encoding (for example, webp), not URL versus base64 delivery.
OpenRouterImageModel.stream(ImagePrompt) exposes OpenRouter's SSE image streaming:
partial previews arrive first (see OpenRouterImageGenerationMetadata.partialImageIndex()),
then completed images with usage and cost. The stream ends at [DONE], not at the
first completed image. A stream must deliver at least one completed image; previews
followed by [DONE], or [DONE] alone, fail with OpenRouterTruncatedResponseException.
Unknown event types are ignored for forward compatibility, while missing, null, or blank
types fail with OpenRouterProtocolException. Unknown events never produce images or
satisfy completion. Cancellation stops the stream without recording a protocol failure.
An SSE connection that closes without [DONE] fails as a truncated response, even if
it delivered a completed image. n is an upper bound;
providers may return fewer images, and support for multiple images and native streaming
depends on the endpoint. Check the OpenRouter image API capabilities
before combining them.
Image JSON responses must contain a data array. An empty array remains a valid
empty result for blocking calls. The streaming JSON fallback requires at least one
image and raises OpenRouterTruncatedResponseException for an empty array. Each
entry and each completed SSE event must contain nonblank b64_json or url. The
JSON streaming fallback preserves both forms. Missing required content raises
OpenRouterProtocolException; HTTP 200 error envelopes raise structured
OpenRouterApiException errors before success conversion.
Synchronous Chat Completions choices require a message object, but its text may be
empty (including tool, refusal, and media messages). Usage-only chat stream chunks
and image partial previews remain supported.
Image-capable chat models work too: set
OpenRouterChatOptions.builder().modalities(List.of("image", "text")) (optionally with
imageConfig) and generated images arrive as AssistantMessage media — in sync calls
and streams alike, in both chat-completions and responses request modes.
Generated media can be attached directly to a new UserMessage for image edits or
follow-up questions. Responses base64 results are exposed as complete data URLs;
output_format, when supplied, determines the MIME type (PNG by default), while an
existing data URL retains its own MIME type.
Chat Completions preserves images when an AssistantMessage is replayed in conversation
history. Responses mode rejects assistant media history explicitly: attach the media to
a UserMessage instead, retaining any assistant text/tool history separately. This applies
to both synchronous and streaming requests. Image understanding still requires a model
that supports image input.
Chat audio output
Use an audio-output-capable OpenRouter model with streaming Chat Completions. Voices and supported encodings depend on the model:
var options = OpenRouterChatOptions.builder() .model("your-audio-output-model") .modalities(List.of("text", "audio")) .audio(new OpenRouterAudioOptions("alloy", "pcm16")) .build(); chatModel.stream(new Prompt("Say hello.", options)) .subscribe(response -> response.getResults().forEach(generation -> generation.getOutput().getMedia().forEach(media -> { byte[] completeAudio = media.getDataAsByteArray(); // Save or play using a decoder appropriate for the requested format. })));
Boot equivalents are spring.ai.openrouter.chat.modalities=text,audio,
spring.ai.openrouter.chat.audio.voice=alloy, and
spring.ai.openrouter.chat.audio.format=pcm16. Formats are wav, mp3, flac,
opus, and pcm16; PCM bytes are raw audio, not a WAV file. Sample rate and other
playback parameters must match the selected provider. No transcoding is performed.
call() and Responses mode reject output audio; there is no blocking adapter.
Each delta.audio.data value is decoded independently from base64, then its bytes
are appended per choice. One complete Spring AI Media is emitted when the choice
completes, and no partial media is emitted. A choice completes in one of two ways:
- It receives a
stopfinish reason. The media arrives with that event. - Its last audio delta contains only
expires_atand no finish reason ever arrives. Some providers, including OpenAI audio models, end audio this way. The media then arrives in one extra response after the stream ends normally with[DONE]. That response has empty text, no usage, and no finish reason.
Without a finish reason, a response cut short by max_tokens cannot be told apart
from a complete one. Set a limit large enough for the expected audio.
Normal text remains incremental;
the assembled transcript is exposed once in the final assistant metadata under
openrouter.audio, alongside format and, when supplied, id and expires_at.
The transcript is not appended to text, so simultaneous text and speech do not
produce duplicate text. Repeated terminal events for completed audio choices do
not emit a second transcript or media object. Wait for successful stream completion
before treating the entire request as successful: a later transport/provider error
still fails the stream.
Retained decoded audio plus transcript/identifier characters (counted as two bytes
each) are limited to 16 MiB across unfinished choices per subscription, with at
most 128 audio choices per stream. Buffer capacity and temporary decoding/final
copies add bounded overhead. Buffers are released on finish, cancellation, or error.
A stream that ends before [DONE], a choice with neither completion signal, audio or
text after the expires_at marker, invalid base64, conflicting identifiers/formats, and
non-stop audio finishes fail explicitly. Each data value must encode a complete
byte fragment; arbitrary splits inside a base64 value are unsupported. Audio mixed
with tool calls in one choice and message.audio snapshots are unsupported. Except for
truncation and size limits, these failures raise OpenRouterProtocolException.
Assistant audio replay is rejected in both request modes, including identifier-only metadata. To continue using only its transcript, explicitly create a new text-only assistant message. This discards audio continuation state; audio IDs and expiry metadata are preserved for callers, not automatically replayed to the provider.
Observability
The starter includes Spring AI's standard observation auto-configuration for chat,
embedding, and image models. OpenRouter model calls emit gen_ai.client.operation
observations with the openrouter provider, operation, request model, response model
where available, and token-usage context. When a MeterRegistry is present, chat and
embedding usage is also recorded as gen_ai.client.token.usage.
Chat and embedding response usage is available as OpenRouterUsage; image responses
expose it in metadata under openrouter.usage. It retains provider cost, cached and
reasoning token counts, and the native usage DTO. Cached tokens are also available
through Spring AI's portable Usage.getCacheReadInputTokens(). Missing detail counts
remain null, distinct from an explicit zero; unavailable cache-write counts remain
null. Streaming chat model observations retain the latest provider usage snapshot
in both request modes, including cost and detailed counts.
Chat and image streams record request-mapping and transport errors on their observation. Each subscription stops its observation on completion, error, or cancellation.
The starter provides the Spring AI handlers, but it does not choose monitoring backends for the application:
- Metrics require a
MeterRegistry. Spring Boot Actuator supplies the Boot observation wiring and a registry; add the registry/exporter for your monitoring system to publish them externally. - Traces require a Micrometer
Tracerbridge and tracing exporter. - Prompt, completion, image-prompt, and error logging are opt-in through Spring AI's
standard properties. Content logging does not require an exporter; when a
Traceris present its handlers become tracing-aware, and error logging requires that tracer.
spring: ai: chat: observations: log-prompt: false log-completion: false include-error-logging: false image: observations: log-prompt: false
Enabling content logging can expose sensitive prompt or model output data. Applications
may supply their own model observation handlers or
ChatModelObservationConvention, EmbeddingModelObservationConvention, and
ImageModelObservationConvention beans. Boot backs off a standard handler when the
application supplies the same handler type, and the OpenRouter models use the custom
conventions.
The Garage demo
openrouter-spring-ai-samples is a small story: a garage foreman model inspects a customer's car, delegates one job to a specialist model, and writes a service record. A normal run stays easy to read; a full run turns it into a capability tour across both Chat Completions and experimental Responses (with registry-declared limitations). Together they exercise system and user messages, sync chat, streaming, tool calling with mixed parameter schemas, real file I/O side effects, model-to-model delegation, model fallback lists, provider routing preferences, service tier, reasoning options, usage and cost metadata, and request metadata. Its modality bays cover the non-chat surfaces: an embeddings triage matcher, a digital inspection bay that reads a bundled dashboard photo (image input, both request modes), and a paint bay that generates images through the Image API (sync and streaming) and chat-completions modalities.
The modality bays correlate completed chat, embedding, and image observations with the scene operation. Evidence includes timing, errors, modality-specific counts and sizes, and available usage and cost, with the same sanitization applied to telemetry snapshots and persisted reports. Image streams publish observation evidence after completion, error, or cancellation. Calls and stream subscriptions must start inside the sample's operation scope.
Every run asserts its own structural outcome (service record written, every required tool actually invoked, usage metadata present, non-empty final answer, and streaming signals when requested) and reports a failed run otherwise. These assertions check tool execution and response contracts independently of the model's prose. The service-story reasoning check accepts reasoning text or positive reasoning-token usage from any Foreman tool-loop round; the final answer need not repeat that evidence.
Run the Garage
The Garage is a Spring MVC application. Build and start it from the repository root; -am builds the sibling modules without a prior install:
mvn -B -pl openrouter-spring-ai-samples -am -DskipTests package java -jar openrouter-spring-ai-samples/target/openrouter-spring-ai-samples-0.1.0-SNAPSHOT.jar
The equivalent Gradle command (CI uses Gradle 9.7.1) is:
gradle --no-daemon :openrouter-spring-ai-samples:bootJar java -jar openrouter-spring-ai-samples/build/libs/openrouter-spring-ai-samples-0.1.0-SNAPSHOT.jar
The filename follows the source POM revision, not the published starter version. The Garage listens on 127.0.0.1:8080; set GARAGE_ADDRESS and GARAGE_PORT to change that. Anyone who can reach the port can start runs that make billable API calls, so keep it on the loopback address unless your own access control sits in front of it. Live scenes need OPENROUTER_API_KEY in the environment; offline contracts do not.
Start a run and read its evidence
| Endpoint | Purpose |
|---|---|
GET /api/scenes |
Lists the scenes, whether each is offline, and the feature ids its evidence covers. |
POST /api/runs |
Starts a run. Returns 202 Accepted with a Location header for the run. |
GET /api/runs/{id} |
Shows the run status (RUNNING, PASSED, FAILED, or ERROR), scene outcomes, incomplete features, recorded cost, and links to its files. A failed scene names a reason: truncated, no-endpoint, provider-error, protocol-error, transport-error, or check-failed. |
GET /api/runs/{id}/evidence |
Returns the run's garage-run.json evidence bundle. |
GET /api/runs/{id}/report |
Returns the run's capability-report.md. |
POST /api/sweeps |
Starts a model compatibility sweep. Returns 202 Accepted like a run. |
GET /api/runs/{id}/sweeps/{embedding|image} |
Returns a sweep result document. |
The Garage records how each model call ended. A failed scene takes its reason from the first call that raised an error, then from the failure itself, then from a call cut off at its token limit. Otherwise the scene failed its own check. Each modality bay takes its reason from its own call, and the scene reports the first failed bay.
Runs execute in the background, one at a time, because a run's evidence collectors are shared. Starting a second run while one is active returns 409 Conflict with a link to the active run. The evidence and report endpoints also return 409 until the run finishes. Run records last until the application stops; the files stay in the output directory.
curl -si -X POST http://127.0.0.1:8080/api/runs \ -H 'Content-Type: application/json' \ -d '{"offlineContracts": true}' curl -s http://127.0.0.1:8080/api/runs/<id> curl -s http://127.0.0.1:8080/api/runs/<id>/report
Invalid selections return 400 Bad Request with a problem detail that names the conflict. Misspelled request fields are rejected rather than ignored. A live selection without an API key returns 422 Unprocessable Content.
Select what a run covers
Omit a request field to keep its garage.* default. An empty list clears a list default, such as "fallbacks": [] or "order": []. Every field applies to that run only.
| Field | Meaning |
|---|---|
capabilities |
Any of text, embedding, vision, and image. |
scenes |
Narrows a capability suite to these scene ids. Selected modalities require modality-bays in the list. |
full |
true runs every scene, embeddings, vision, and all image surfaces. It cannot be narrowed with scenes. |
offlineContracts |
true runs only the local recovery and dyno contracts, without an API key. |
requestModes |
chat, responses, or both; overrides a suite's default modes. |
topic |
The customer and car request to inspect. |
models |
foreman, specialist, embedding, vision, image, and a fallbacks list. |
image |
surface is sync (the default), streaming, chat, or all; quality is optional. |
limits |
maxCompletionTokens and specialistMaxCompletionTokens, both positive. Defaults are 4096 and 2048, which leave room for reasoning models. |
reasoningEffort |
Text reasoning effort. |
provider |
sort and requireParameters apply to every request. The order, ignore, and quantizations lists apply only to the routing-lane scene's full provider preferences. |
| Selection | Request modes | Selected work |
|---|---|---|
| Empty body | Chat Completions | service-story |
{"requestModes":["chat"]} |
Chat Completions | service-story |
{"requestModes":["responses"]} |
Responses | service-story |
{"capabilities":["text"]} |
Both | Text, tools, streaming, structured-output and offline text contracts |
{"capabilities":["text"],"requestModes":["chat"]} |
Chat Completions | Same text suite, narrowed to chat |
{"capabilities":["text"],"requestModes":["responses"]} |
Responses | Same suite; unsupported registry rows remain explicit |
{"full":true} |
Both | Every scene, embeddings, vision and all image surfaces |
{"capabilities":["embedding"]} |
Chat Completions selection; mode-independent API | Embeddings only |
{"capabilities":["vision"]} |
Both | Image input only; add "requestModes":["chat"] to narrow it |
{"capabilities":["image"]} |
Chat Completions selection; mode-independent Image API | Synchronous image generation only |
The Garage deliberately selects both modes for the text and vision capabilities and for full; the library and the plain service-story demo still default to Chat Completions. Explicit requestModes override suite defaults. {"full":true,"requestModes":["responses"]} is rejected because its chat-image surface requires Chat Completions; use individual capabilities instead. The current Garage registry does not verify Responses structured output, streamed tool aggregation or recovery contracts, even where the library has synthetic coverage. Embeddings and image generation are checked once per selection, not duplicated across modes. GET /api/scenes lists the same registry used by generated reports; report rows alone do not mean their features were executed.
A sweep request takes embeddingModels entries as model[@providerTag] and imageModels entries as model[@providerTag][?key=value&...], where the image keys are resolution, quality, aspect-ratio, and output-format. It also accepts imageQuality and provider. Sweep results preserve input order, so match each result to the corresponding request entry.
garage.* properties bind once at startup, and unknown garage.* properties fail during binding. Request fields override those defaults for one run without changing them for the next. With no explicit selection, garage.stream=true adds streaming-dispatch to the service-story demo; explicit scene, capability, full, or offline selections take precedence over that default.
Model selection is independent of capabilities; a capability does not imply a free model. The Garage records what a run cost and reports it, but enforces no ceiling of its own. Cap spending with a credit limit on the OpenRouter API key instead, which is the only control that can stop a request before it is billed. Garage enables usage reporting per Chat Completions request rather than as a global chat default, so ChatClient can also use Responses mode. Structured-output probes require providers to support all requested parameters, including the JSON schema.
Test the Garage without an API key
The samples tests come in two kinds, and neither needs a key or network access. Unit tests check the Garage's own code with test doubles: request validation, per-run settings, scene logic, evidence, and reports. Integration tests, in the garage.integration package, run the Garage through its HTTP API against a local mock of the OpenRouter API. Only the base URL differs from a live run: the real HTTP clients, SSE decoding, mappers, tool loops, and evidence checks all run. Both kinds run in mvn verify and gradle check.
The integration tests cover:
- A
fullrun that must pass every scene in both request modes, with the request shape each modality sends and the attribution headers. - Sad paths that must fail only the targeted scene for the intended reason. Examples include a model that answers without calling its tools, malformed or unknown tool calls, a reused tool-call ID, missing usage evidence, an unavailable specialist, a fallback that never happens, schema-violating structured output, a stream that ends early or fails, a reply cut off at its token limit, a request no OpenRouter endpoint can serve, a tool call with a blank name, undecodable images, and an embedding count mismatch. Each sad path also checks the failed scene's reason code.
- Edge cases that must still pass, such as parallel tool calls, SSE keepalive comments, a rate limit that clears on retry, SVG images, an image stream answered with a single JSON body, and an image stream of partial images. Reasoning in the Gemini, Anthropic, and OpenAI formats must reach the next tool round with its encrypted data and signatures unchanged, in both request modes.
- Sweeps, one-run-at-a-time scheduling, per-run settings on the wire, and that evidence and reports never contain the topic, prompts, model replies, or the API key.
- Library surfaces the Garage scenes do not send: PDF, audio, and video input in every supported format, streamed audio output, provider errors, a provider slower than the connection timeout, and a slow reply that whitespace keepalives keep within it.
The mock is WireMock, serving the corpus in openrouter-spring-ai-samples/src/test/resources/openrouter-mock. Default stubs in mappings answer the happy paths; a test loads one directory from scenarios to override them for a single sad or edge case. Stubs select a reply from the request body: the scene in metadata.sceneId, whether the request streams, and which tool results it already carries. A request that no stub matches fails the test. The corpus is synthetic; its structure mirrors OpenRouter's response format, including field names, usage details, stream event order, and error bodies. It also mirrors how OpenRouter delivers responses: successful JSON replies start with whitespace keepalive lines, streams start with keepalive comments and arrive split at arbitrary byte boundaries, Responses bodies echo the request settings, and Gemini image models answer a streaming image request with one JSON body. A mocked run shows that the library and the Garage handle these responses; it does not show that a model or provider behaves the same way.
Evidence and privacy
Each scene run writes capability-report.md, garage-run.json, and a bundle README.md to its own directory under garage.output-dir. Garage diagnostic JSON, Markdown, and sweep files retain only allowlisted fields and fixed labels, numeric measurements, booleans, and generated operation identifiers. Free-form text, model/provider names, paths, tool payloads, exception messages, and unknown objects are omitted or redacted; raw diagnostic payload retention is not supported. Authored service records and generated media are separate application outputs and may contain customer or model content. These report protections do not sanitize console logs.
Tool-loop evidence requires model-selected tools and correlated tool results sent in a follow-up request. The service story must complete inspection, specialist delegation, priority scoring, and service-record tools; a text-only answer cannot certify it. Structured-output probes test responseFormat and outputSchema independently. Feature reports retain each selected request mode's status. Mixed outcomes are partial, and a failed, missing, or incomplete required mode fails the run. Unsupported modes remain explicit; embeddings and image generation are checked once because those APIs are mode-independent.
Maven and Gradle builds
Maven and Gradle build the same three published thin library JARs. Versions and BOM baselines come from the root Maven POM, and CI builds and tests both build systems across the supported Java versions. The executable samples application is intentionally separate.
Build quality checks
Focused mutation testing
On JDK 25, run either opt-in command from the repository root:
mvn -B -pl openrouter-spring-ai -am -Pmutation test-compile org.pitest:pitest-maven:mutationCoverage gradle --no-daemon :openrouter-spring-ai:mutation
Both use the root POM's PIT and JUnit adapter versions, DEFAULTS mutators,
an 80% mutation threshold, and failure on an empty mutation set. Ordinary builds
do not run PIT, and its tooling dependencies are absent from published application
classpaths. HTML and XML reports are in openrouter-spring-ai/target/pit-reports
for Maven and openrouter-spring-ai/build/reports/pitest for Gradle.
The shared mutation.targets list includes nested classes and covers:
OpenRouterRetryAfterandOpenRouterHttpExceptionFactory, which now owns retry-header parsing and HTTP error classification. PIT filters compiler-generated record methods, so the record alone would not exercise parsing.ReasoningDetailsMerger, for ordered fragment merging and opaque metadata retention.OpenRouterStreamingToolCallAggregator, for fragmented arguments, limits, cancellation, and per-choice state.OpenRouterImageResponseValidator, for malformed image payloads and provider-error precedence, exercised by synthetic synchronous and streaming-fallback contracts.
All core tests remain available for coverage selection. Samples and live-provider
calls are outside this gate. Two workers are the default. Override them with
-Dmutation.threads=4 in Maven or -Pmutation.threads=4 in Gradle. Use the corresponding mutation.targets
property for a focused investigation. Do not combine PIT workers with Maven -T
or parallel JUnit execution.
CI runs mutation analysis once per pull request on Temurin 25 with a 20-minute
timeout and retains generated reports for 14 days, including failed runs.
The workflow's manual trigger runs only this job and accepts two or four workers
with either Maven or Gradle. Pull requests always use Maven.
For a worker comparison, dispatch it twice against the same commit with warm
dependency caches. Job logs include elapsed time and peak combined JVM resident
memory sampled once per second; compare mutation outcomes and timeouts as well
as runtime before changing the default worker count.
Review SURVIVED and NO_COVERAGE entries in the XML or HTML report, reproduce
the affected behavior with a synthetic test, and add an assertion for the observable
contract. Keep equivalent mutations documented; do not lower the threshold or remove
targets to make the gate pass. STRONGER is reserved for a later scheduled expansion.
The initial validation used PIT 1.30.0, its JUnit adapter 1.2.3, JUnit 6.1.3,
and Temurin 25 while compiling production classes for Java 17. Maven and Gradle
both detected the same 235 of 273 mutations at fd7a3df, an 86% score after
strengthening behavioral assertions from the 70% baseline. A separate synthetic
boundary fixture failed the 80% gate when its assertion was weakened, and both
project commands failed with an unmatched target.
The worker comparison at that same commit used Ubuntu 24.04, Temurin 25.0.4, the same mutation scope, and matching dependency-cache misses:
| Workers | Elapsed | Peak combined JVM RSS | Killed | Timed out | Survived / uncovered |
|---|---|---|---|---|---|
| 2 | 6m 19s | 2.57 GiB | 211 | 24 | 34 / 4 |
| 4 | 5m 59s | 2.90 GiB | 212 | 23 | 34 / 4 |
Both detected the same mutations; one changed from timeout to assertion failure. Four workers saved only 20 seconds and used 13% more memory, so two are the default. Allow roughly seven minutes and 3 GiB of JVM memory for this measured scope, plus runner and build-tool overhead. The 20-minute job timeout leaves room for setup, downloads, and runner variation. PIT counts mutation timeouts as detections; compare their identities when reviewing later runs rather than relying on the score alone.
No mutations are excluded. Remaining survivors include equivalent date guards whose parser fallback still rejects the input, a redundant reasoning-object guard, and per-choice limits also enforced by the shared budget. Uncovered single-byte writes and overflow protection, stale-timer races, and terminal-state cleanup remain coverage limitations. Do not treat every survivor as equivalent; inspect its observable effect before adding tests or proposing a narrow exclusion.
Other quality checks
For GitHub Actions changes, install actionlint 1.7.12 and ShellCheck, then run
actionlint -color from the repository root. CI's Workflow validation job checks
all tracked workflows on every pull request (including forks) and main push,
with ShellCheck enabled for embedded shell scripts. Synthetic invalid input also
verifies dependency validation and file/line shell diagnostics.
The library and samples compile with --release 17. Run mvn -B verify and
gradle --no-daemon check for tests and static analysis. Checkstyle runs on JDK 21+
only. Library sources use Spring formatting; samples retain their existing layout
and enforce EqualsHashCode, FallThrough, EmptyStatement, and
StringLiteralEquality through config/checkstyle/checkstyle-samples.xml.
Both builds use the POM's pmd.version and apply config/pmd/pmd-common.xml to
maintained production and test sources, including samples. This retains the existing
resource, empty-catch, fall-through, crypto-key, unused-member, and ImmutableField
checks. Maven analyzes only the main and test source directories; Gradle excludes
generated Spring AOT source sets. Three sample classes suppress only
duplicate literals to keep registry rows and test inputs explicit. Sample CPD remains deferred because scenario and
fixture duplication is intentional; Gradle has no CPD task. Sample formatter and
Enforcer exclusions are unchanged.
The additional blocking PMD rules are scoped by source path in the shared rulesets, so Maven's combined analysis and Gradle's separate main/test tasks enforce the same policy:
| Ruleset | Canonical rule reference | Policy |
|---|---|---|
pmd-main.xml |
category/java/design.xml/CognitiveComplexity |
Library production methods and constructors fail at 25 or more, using reportLevel=25. |
pmd-main.xml |
category/java/design.xml/CyclomaticComplexity |
Library production methods fail at 15 or more; class totals fail at 80 or more. |
pmd-main.xml, pmd-test.xml |
category/java/bestpractices.xml/PreserveStackTrace |
Library production and all test sources retain exception causes. |
pmd-main.xml, pmd-test.xml |
category/java/codestyle.xml/UnnecessaryModifier |
Library production and all test sources omit redundant modifiers. |
pmd-test.xml |
category/java/bestpractices.xml/JUnitJupiterTestShouldBePackagePrivate |
Test classes and methods use package access where JUnit permits it. |
pmd-review.xml |
category/java/design.xml/ExcessivePublicCount |
Advisory library production report with minimum=45; never blocks verify or check. |
PMD 7.27.0 calls the cognitive property reportLevel, not methodReportLevel.
The proposed thresholds were checked against the baseline and synthetic boundary
fixtures. Production complexity does not apply to test or sample sources.
Two method-specific exceptions retain existing behavior: Retries.invoke unwraps
Spring's retry wrapper and rethrows its original cause, and
OpenRouterErrorClassifier.category(int) keeps the flat HTTP status lookup together.
The exceptions name only PreserveStackTrace and CyclomaticComplexity, respectively.
Qodana's WeakerAccess check below owns declaration access; PMD does not infer
safe API access changes.
Run the separate advisory report with mvn -B test-compile pmd:pmd@pmd-review or
gradle --no-daemon pmdPublicApiReview. Maven writes each module's report under
target/pmd-review/; Gradle writes build/reports/pmd/pmdPublicApiReview.xml.
The baseline has no findings at 45, which is retained as a review threshold for
future public API growth. Published methods are retained. Advisory findings neither replace the
blocking report nor change its failure policy.
On JDK 25+, run mvn -B -Pnullaway clean compile or
gradle --no-daemon -Pnullaway clean compileJava to enforce production null contracts.
CI checks both build systems with NullAway errors and JSpecify generic
checking enabled. Tests remain outside this checker so invalid-input fixtures can
exercise runtime validation. Normal builds still support JDK 17 and 21; analysis
and normal compilation both produce Java 17 bytecode. Error Prone and NullAway
are compiler-only dependencies and are absent from published runtime dependency
graphs; JSpecify remains the existing public annotation dependency.
On JDK 25, mvn -B -Pmodernizer-java25 verify also runs Modernizer with an
explicit Java 25 analysis target on every module's production and test bytecode.
The profile activates automatically on JDK 25+. The JDK 25 Maven CI leg is the
shared Modernizer enforcement point for both build systems; Gradle check does
not invoke it. Modernizer checks its known API catalog, not every newer Java
feature. Compiler release checking still protects Java 17 compatibility.
Run mvn -B -Psecurity -pl openrouter-spring-ai-autoconfigure -am -DskipTests verify
for FindSecBugs analysis of compiled core and autoconfiguration production classes.
Tests, samples, and the dependency-only starter are excluded from security analysis.
The opt-in profile uses maximum effort, reports all confidence levels (Low threshold),
and fails on any selected SECURITY finding or analysis failure. Reports are written to
each analyzed module's target/findsecbugs.xml and target/spotbugs/security/spotbugs.html.
The security filter and detector plugin exist only inside this profile's execution;
ordinary SpotBugs analysis without -Psecurity remains
unfiltered and writes its separate target/spotbugsXml.xml report.
The method-specific exclusions in config/spotbugs/security-exclude.xml cover ASCII-validated
URI scheme comparison and tool log sanitization that the detector does not recognize.
Tool failure log messages replace CR and LF in tool names with underscores, with a
regression test for both messages; original exceptions remain available in local debug
diagnostics and observations.
CI's single FindSecBugs security job on JDK 25 is the shared enforcement point for
Maven and Gradle contributors; Gradle check does not run FindSecBugs. Compilation
still targets Java 17. The job runs the Maven security profile directly against
production library classes.
FindSecBugs overlaps CodeQL's security-and-quality suite on injection and other
security patterns, but provides a local bytecode gate without a code-scanning service.
CodeQL retains its broader source/data-flow analysis and independent workflow.
The security job fails builds immediately; making its status a required branch check
is deferred until its CI stability is established. This change does not alter branch
protection or replace CodeQL.
The required SpotBugs correctness gate runs once per build tool on CI's JDK 25, with Java 17 bytecode. Run it locally with either command:
mvn -B -Pcorrectness -pl openrouter-spring-ai-autoconfigure -am -DskipTests verify gradle --no-daemon spotbugsCorrectness
It selects NP_NULL_ON_SOME_PATH and NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE
for nullable dereferences, OBL_UNSATISFIED_OBLIGATION for resource cleanup,
and DC_DOUBLECHECK for unsafe double-checked publication. Any selected finding,
analysis error, missing dependency class, or empty required scope fails the gate.
Both tools analyze core and autoconfiguration production classes with their compile
dependency classpaths. Tests, samples, and the dependency-only starter are outside
the initial scope. Add the starter to both configurations if it gains executable code.
Maven writes target/spotbugs-correctness.xml plus target/spotbugs/correctness/spotbugs.html. Gradle writes
build/reports/spotbugs/<policy>/spotbugs.xml and spotbugs.html in each analyzed module.
The root POM pins the engine version; it is not a library runtime dependency.
Maven profiles can be combined as -Psecurity,correctness. Their filters,
extensions, reports, and skip properties are separate. Analysis runs during package;
the non-forking SpotBugs verify goal enforces findings during Maven verify, avoiding
the check goal's fork losing execution-specific configuration. Ordinary Gradle check
does not run these scans; the dedicated CI jobs enforce correctness and security.
Exceptions belong in the policy's config/spotbugs/*-exclude.xml, scoped to a detector
and specific method with a contract explanation. No package exclusions or generated
baseline are accepted. Correctness currently excludes two helper contracts SpotBugs
cannot infer; existing tests and NullAway cover those contracts. Repeated nullable
record accessors use local values so their guards remain visible to the analyzer.
NullAway checks source null contracts independently and does not inherit these exclusions.
CI's Qodana declaration access job runs IntelliJ's WeakerAccess inspection ("Declaration access can be weaker") with the free jetbrains/qodana-jvm-community linter, pinned in qodana.yaml. It needs no Qodana Cloud token. The inspection profile in .qodana/weaker-access.yaml enables only this inspection. Any finding fails the job and appears as an annotation on the pull request; the full report is uploaded as the qodana-report artifact. The job analyzes production sources of the core and autoconfiguration modules. Tests, samples, and the dependency-only starter are excluded.
The inspection only sees callers inside this repository, so it ignores the supported API listed under Supported API and nullability. Mapper packages, internal, support, the error factories, classifiers, messages, wire responses, and deserializers in errors, and the remaining auto-configuration and api classes are checked. Narrow a reported declaration. When a framework or reflection needs the wider access, annotate the declaration with @SuppressWarnings("WeakerAccess") and a comment that gives the reason. Run the check locally with Docker from the repository root:
docker run --rm -v "$PWD:/data/project" -v "$PWD/../qodana-results:/data/results" jetbrains/qodana-jvm-community:2026.2
The Java 17-compatible recursive list snapshot and content joining simplifications
are applied. Math.clamp, List.getFirst/getLast, and pattern switches in
assistant content and terminal-event handling remain deferred until the minimum
Java version changes. Existing null handling, collection guards, and terminal
error semantics remain in place.
mvn -B verify and gradle --no-daemon check enforce JaCoCo coverage independently
for each library module, with at least 90% line and 80% branch coverage. The root
POM defines both floors; Gradle reads them from that POM. Reports and gates analyze
all production classes without configured exclusions, retaining JaCoCo's automatic
generated-code filtering. The parent aggregator and samples have no percentage gate.
The starter currently contains only package metadata, so it has no coverage percentage.
Adding production classes brings it under the same policy, including the check for
missing execution data. Package descriptors do not require execution data.
HTML and XML reports are in each module's target/site/jacoco for Maven or
build/reports/jacoco/test for Gradle. CI uploads these reports and synthetic test
results on verification failure, retaining artifacts for seven days without rerunning
tests. mvn -B -DskipTests package and gradle --no-daemon assemble still package
without coverage verification. Explicit Maven -DskipTests verification, used for
static security analysis, also skips coverage enforcement.
The library's own code passes every gate, so a normal build cannot show that a gate is still switched on. mvn -B -N -Pgate-its verify runs maven-invoker-plugin over the projects in src/it. Each project inherits the real parent POM, breaks one gate on purpose, and must fail for that reason: coverage-below-floor (JaCoCo line floor), coverage-branch-below-floor (JaCoCo branch floor), pmd-lost-cause (PreserveStackTrace), spotbugs-correctness (all four correctness detectors), and findsecbugs-sql-injection (SQL_INJECTION_JDBC). A verify.groovy script in each project checks the rule, so a build that fails for another reason still fails the run. The Java 25 Maven verification job runs this profile. When you change a gate's configuration, add or update the matching project.
Coverage percentages supplement the behavioral tests for fragment ordering, cancellation, terminal errors, reasoning replay, and tool aggregation.
来源:GitHub · github.com
