Monday, March 9, 2026

OmniFaces 5.1 released

OmniFaces 5.1 is released! This release brings two new additions and a handful of fixes.

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>omnifaces</artifactId>
    <version>5.1</version>
</dependency>

As per What's new in OmniFaces? in the showcase:

New: <o:compositeConverter>

Have you ever needed to chain multiple converters on a single input? Until now you'd have to create a custom converter class that delegates to several others. With <o:compositeConverter> you can do it declaratively:

<h:inputText value="#{bean.value}">
    <o:compositeConverter converterIds="trimConverter, sanitizeConverter, entityConverter" />
</h:inputText>

The converterIds attribute takes a comma-separated list of converter IDs (registered via @FacesConverter or in faces-config.xml). The execution order follows a natural symmetry:

  • getAsObject (decode): executes converters left-to-right: 1st → 2nd → 3rd
  • getAsString (encode): executes converters right-to-left: 3rd → 2nd → 1st

The output of each converter is passed as the input to the next. This makes it trivial to compose small, single-responsibility converters without any glue code.

New: #{o:flagEmoji(countryCode)}

A small but a handy one. The new #{o:flagEmoji(countryCode)} EL function converts an ISO 3166-1 alpha-2 country code to its corresponding Unicode flag emoji:

#{o:flagEmoji('NL')}  →  πŸ‡³πŸ‡±
#{o:flagEmoji('US')}  →  πŸ‡ΊπŸ‡Έ
#{o:flagEmoji('BR')}  →  πŸ‡§πŸ‡·

The function is case-insensitive. It returns null for empty input (so EL will basically render it as empty string) and throws IllegalArgumentException for anything that is not a valid 2-letter alphabetic code.

Very useful whenever you want to display a country flag next to a locale selector, a phone prefix dropdown, or any other country-aware component. Never anymore fiddling with a boatload of flag icon files in your project or relying on a 3rd party flag icon library. You can now control it like a font.

Fixes

<o:validateMultiple>: The invalidateAll attribute was missing from the VDL documentation. Fixed. (#927)

CompressableResponseFilter: The IOException was being unnecessarily wrapped in UnsupportedOperationException before being rethrown, making it difficult to filter "connection reset" errors from server logs. It is now rethrown as-is. (#928)

<o:socket> on WildFly: The SocketEndpoint#onClose() failed to clean up socket sessions in memory and hence leaked memory (only on WildFly). This was a regression of #913 (introduced in 5.0 / 4.7.1 / 3.14.12 / 2.7.30). (#931)

All these fixes are also available in 4.7.2 / 3.14.13 / 2.7.31.

Friday, March 6, 2026

OmniHai goes online

OmniHai 1.3 is out. After 1.1 gave the library ears to transcribe audio and 1.2 gave it a voice to speak, 1.3 lets it step outside and look around. Web search is now a first-class citizen in the API, alongside token usage tracking, an AIServiceWrapper, and a handful of internal improvements.

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>omnihai</artifactId>
    <version>1.3</version>
</dependency>

Web Search

AI models are great at reasoning over things they already know, but their knowledge has a cutoff date. Web search bridges this by allowing the model retrieve up-to-date information from the internet before formulating its answer. OmniHai 1.3 adds dedicated webSearch() method to AIService for exactly this purpose.

The simplest form is a single method call:

String answer = service.webSearch("What is the current stock price of Nvidia?");

That's it. The model searches the web, sources its answer, and returns a response based on current information rather than whatever it was trained on.

If you need results scoped to a specific geographic location, e.g. local news, weather forecasts, available restaurants, or store prices, then pass a Location:

Location miami = new Location("US", "Florida", "Miami");
String weather = service.webSearch("What is the weather like today?", miami);

Location takes a country code, region, and city, all optional. You can pass Location.GLOBAL if you want web search enabled but without any geographical restriction, which is also the default when you call the webSearch() method without location argument.

Structured Web Search Output

Like the regular chat() methods, webSearch() also supports typed responses. Define a record that represents the shape of the data you want, and pass it as class argument:

public record StockPrice(String ticker, BigDecimal price, String currencyCode) {}

StockPrice tsla = service.webSearch("What is the current stock price of Tesla?", StockPrice.class);

Here's another example:

public record Link(String title, String url) {}
public record Links(List items) {}

Links results = service.webSearch("Latest 5 news headlines about Jakarta EE", Links.class);
results.items().forEach(link -> System.out.println(link));

Example output:

Link[title=Java News Roundup: Jakarta EE 12, Spring Shell, Open Liberty ..., url=https://www.infoq.com/news/2026/02/java-news-roundup-jan26-2026]
Link[title=Jakarta EE 12 - The Eclipse Foundation, url=https://jakarta.ee/zh/release/12]
Link[title=Jakarta EE 12 M2 — Welcome to the Data Age, url=https://www.linkedin.com/pulse/jakarta-ee-12-m2-welcome-data-age-otavio-santana-zkgie]
Link[title=Jakarta EE 2025: a year of growth, innovation, and global engagement, url=https://blogs.eclipse.org/post/tatjana-obradovic/jakarta-ee-2025-year-growth-innovation-and-global-engagement]
Link[title=The Eclipse Foundation Releases the 2025 Jakarta EE Developer Survey Report, url=https://newsroom.eclipse.org/news/announcements/eclipse-foundation-releases-2025-jakarta-ee-developer-survey-report]

Of course, the webSearch() has also an async counterpart: webSearchAsync().

Web Search inside Chat

Sometimes you want web search as part of a larger chat flow rather than a standalone query. For those cases, you can use ChatOptions.Builder.webSearch() or webSearch(Location) methods, or the withWebSearch(location) copy method, so you can for example mix live web data into a memory-enabled conversation without leaving the chat API:

ChatOptions options = ChatOptions.newBuilder()
    .systemPrompt("You are a helpful travel assistant.")
    .withMemory()
    .webSearch()
    .build();

String response = service.chat("What are the current visa requirements for Dutch citizens visiting Japan?", options);
String followUp = service.chat("And what about travelling to South Korea from there?", options);

You can also derive a web-search-enabled or -disabled copy from an existing options instance without rebuilding from scratch:

ChatOptions withGlobalSearch = options.withWebSearch(Location.GLOBAL);
ChatOptions withLocalSearch = options.withWebSearch(new Location("CW", null, "Willemstad"));
ChatOptions withoutSearch = options.withWebSearch(null); // disables web search

Provider Support

Web search is supported on OpenAI (via the Responses API), Google AI, Anthropic (Claude 4 and later), xAI, Azure AI, and OpenRouter. OpenRouter handles this slightly differently from the rest: rather than a dedicated tool call, it activates web search by appending :online to the model name (e.g. openai/gpt-4o:online). OmniHai handles that detail internally with help of the new AIServiceWrapper decorator (more on this later); you just call webSearch() and it works. xAI always had it implicitly enabled when the model (Grok) realizes that the caller is asking for real time information (e.g. "current weather" or "current stock price"), so not really much of a change, except for that you can now force it to perform a web search when using non-obvious prompts. Mistral supports web search but only via a separate Agents API rather than the Chat Completions API, so OmniHai can't do much. If the underlying provider or model does not support web search, an UnsupportedOperationException is thrown, consistent with how other unsupported capabilities are handled in the library.

Token Usage Tracking

Every AI call costs tokens, and until now OmniHai gave you no visibility into how many. That changes in 1.3 with the introduction of ChatUsage, a record that reports the input, output, and reasoning token counts for each call:

ChatOptions options = ChatOptions.newBuilder()
    .systemPrompt("You are a helpful assistant.")
    .build();

String response = service.chat("Explain the visitor pattern in one paragraph.", options);
ChatUsage usage = options.getLastUsage();
System.out.printf("Tokens in: %d, out: %d, total: %d%n", usage.inputTokens(), usage.outputTokens(), usage.totalTokens());

reasoningTokens() is also available for providers and models that report internal thinking separately (such as OpenAI o-series, Anthropic extended thinking models, and Grok reasoning models). It is always a subset of outputTokens(), so totalTokens() does not add it separately to avoid double-counting. A value of -1 on any field means the AI provider did not report that number.

ChatUsage is stored on the ChatOptions instance itself, which brings up an important subtlety. The three shared constants ChatOptions.DEFAULT, ChatOptions.CREATIVE, and ChatOptions.DETERMINISTIC are immutable. If you wish to record usage on your instance, call copy() first to get a mutable instance with the same settings:

ChatOptions options = ChatOptions.DEFAULT.copy();
service.chat("Hello!", options);
ChatUsage usage = options.getLastUsage();

Any ChatOptions instance you build yourself via newBuilder() is always mutable and can track usage directly.

AIServiceWrapper

The new AIServiceWrapper is an abstract decorator base class that makes it straightforward to wrap any AIService implementation and intercept specific methods. All methods delegate to the wrapped service by default, so you only override what you actually care about.

A practical example is a provider fallback: try the primary service, and if it responds with a rate limit or is temporarily unavailable, then transparently retry on a backup provider instead of propagating the exception to the caller.

public class FallbackAIService extends AIServiceWrapper {

    private final AIService fallback;

    public FallbackAIService(AIService primary, AIService fallback) {
        super(primary);
        this.fallback = fallback;
    }

    @Override
    public CompletableFuture<String> chatAsync(ChatInput input, ChatOptions options) throws AIException {
        return super.chatAsync(input, options).exceptionallyCompose(completionException -> {
            var cause = completionException.getCause();

            if (cause instanceof AIRateLimitExceededException || cause instanceof AIServiceUnavailableException) {
                return fallback.chatAsync(input, options);
            }

            return CompletableFuture.failedFuture(completionException);
        });
    }
}

Wire it up by injecting two services and composing them:

@Inject @AI(apiKey = "#{keys.openai}")
private AIService gpt;

@Inject @AI(provider = ANTHROPIC, apiKey = "#{keys.anthropic}")
private AIService claude;

AIService resilient = new FallbackAIService(gpt, claude);
String response = resilient.chat("Explain the Jakarta EE security model.");

From the caller's perspective it is just an AIService. The fallback logic is entirely self-contained in the wrapper. You can add overloads for transcribe, generateImage, or any other method you want covered, or leave them delegating to the primary and let the caller handle those exceptions as normal.

Give It a Try

As always, feedback and contributions are welcome on GitHub. If you run into any issues, open an issue. Pull requests are welcome too.

Monday, March 2, 2026

OmniPersistence and OptimusFaces finally reach 1.0

After roughly ten years of 0.x releases, both OmniPersistence and OptimusFaces have finally reached 1.0. Both have been in active use in several production apps since 2015 (primarily with Hibernate). This post gives an refreshing overview of what they do and what went into the 1.0 releases.

OmniPersistence 1.0

OmniPersistence reduces boilerplate in the Jakarta Persistence layer. It provides a rich base service class, declarative soft-delete, field-level auditing, structured pagination with typed search criteria, and provider and database detection. It works with Hibernate, EclipseLink and OpenJPA on any Jakarta EE compatible runtime.

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>omnipersistence</artifactId>
    <version>1.0</version>
</dependency>

It requires a minimum of Java 17 and Jakarta EE 10.

Entity model hierarchy

OmniPersistence ships with six base entity classes. Just pick the one that covers what your entity needs:

All four identity methods equals, hashCode, compareTo and toString default to the database ID. In case you want to base them on a business key instead, simply override identityGetters() once and all four are consistent:

@Entity
public class Phone extends GeneratedIdEntity<Long> {

    private Type type;
    private String number;
    private Person owner;

    @Override
    protected Stream<Function<Phone, Object>> identityGetters() {
        return Stream.of(Phone::getType, Phone::getNumber);
    }
}

BaseEntityService

Extend BaseEntityService<I, E> to get a full CRUD service for your entity. It works as an EJB or a CDI bean:

@ApplicationScoped // Or @Stateless if you're still on EJB
public class PersonService extends BaseEntityService<Long, Person> {
    // The @PersistenceContext is already injected. Nothing else needed.
}

Note: if you're still on EJBs, it might be wise to migrate to CDI, as development on the EJB spec has basically stalled and EJB is in long term going to be discommended in favor of CDI.

The inherited BaseEntityService API offers a lot of helpful shortcut methods for lookups, CRUD and JPQL:

// READ
Optional<Person> found = personService.findById(id);
Person person = personService.getById(id); // or null
List<Person> all = personService.list();
List<Person> subset = personService.list("WHERE e.verified = true");

// WRITE
Long id = personService.persist(newPerson);
Person updated = personService.update(person);
Person saved = personService.save(person); // persist-or-update
personService.delete(person);

Short JPQL fragments are auto-expanded to SELECT e FROM EntityName e <fragment>, so the alias e is always predefined.

Pagination with Page and search criteria

Page is an immutable value object that bundles offset, limit, ordering and search criteria. Pass it to getPage() and get back a PartialResultList that also carries the total count when requested:

Map<String, Object> criteria = new LinkedHashMap<>();
criteria.put("lastName", Like.contains("smith"));
criteria.put("age", Between.range(18, 65));
criteria.put("status", Not.value("BANNED"));
criteria.put("role", Role.ADMIN);

Page page = Page.with()
    .range(0, 10)
    .orderBy("lastName", true)
    .allMatch(criteria)
    .build();

PartialResultList<Person> result = personService.getPage(page, true); // true = include count as well
List<Person> list = result;
int count = result.getEstimatedTotalNumberOfResults(); // or -1 if count was not included

The available search criteria wrappers are:

  • Like - case-insensitive pattern matching (startsWith, endsWith, contains)
  • Between - range queries
  • Order - comparison operators (<, >, <=, >=)
  • Not - negation wrapper around any value or criteria
  • Bool, Numeric, Enumerated, IgnoreCase - specialised type handling
  • A plain value produces an exact equality predicate

Conditions can be grouped with allMatch() (AND) or anyMatch() (OR) and mixed freely.

In case you need stable performance on large datasets without SQL OFFSET, cursor-based (keyset) pagination is also available.

Page firstPage = Page.with().range(0, 10).build();
PartialResultList<Person> firstList = personService.getPage(firstPage, true);
Page secondPage = Page.with().range(firstList.getLast(), firstPage.getLimit(), false).build(); // false = not in reversed direction
PartialResultList<Person> secondList = personService.getPage(secondPage, true);

With large offsets this is much faster than e.g. Page hundredthPage = Page.with().range(990, 10).build();.

Soft delete and auditing

Mark a boolean field with @SoftDeletable and BaseEntityService will automatically exclude soft-deleted rows from all read methods and expose dedicated softDelete, softUndelete and listSoftDeleted methods:

@Entity
public class Comment extends GeneratedIdEntity<Long> {

    private String text;

    @SoftDeletable
    private boolean deleted;
}
commentService.softDelete(comment);
commentService.softUndelete(comment);
List<Comment> gone = commentService.listSoftDeleted();

Field-level change auditing fires a CDI event for every @Audit-annotated field that is modified during a transaction:

@Entity
@EntityListeners(AuditListener.class)
public class Config extends GeneratedIdEntity<Long> {

    private String key;

    @Audit
    private String value;
}
public void onAuditedChange(@Observes AuditedChange change) {
    log.info("{}.{}: {} -> {}",
        change.getEntityName(), change.getPropertyName(),
        change.getOldValue(), change.getNewValue());
}

Provider and database detection

OmniPersistence detects the active Jakarta Persistence provider and the underlying database at startup and makes them available via getProvider() and getDatabase() on BaseEntityService. This is used internally to emit provider-correct SQL, for example because string casting syntax and @ElementCollection pagination count subquery SQL syntax differs across providers and databases. In case you need it in your own service code, you can simply call them directly:

public void someMethod() {
    if (getProvider() == Provider.HIBERNATE) {
        // ...
    }

    if (getDatabase() == Database.POSTGRESQL) {
        // ...
    }
}

Supported databases as of 1.0: H2, MySQL, PostgreSQL, SQL Server and DB2.

What changed for OmniPersistence 1.0

The most impactful change is the decoupling from EJB. The initial 0.x versions required the service class to be a @Stateless EJB. It wasn't possible to make them an @ApplicationScoped CDI bean because some internals related to auditing (preupdate/postupdate) which rely on presence of EJB's SessionContext (in order to access the currently used EntityManager which could have been customized by the custom BaseEntityService implementation) would break. These internals have been improved in OmniPersistence 1.0 and you can finally freely choose between EJB or CDI.

The most visible API change is that Consumer<Map<String, Object>> parameters were replaced by direct Map<String, Object> parameters. The indirection was introduced to avoid the verbosity of new HashMap() at every call site. With Java 9's Map.of(), there is no longer a reason for it.

Other notable changes: explicit SQL Server and DB2 support was added to Database enum, compatibility with current versions of Hibernate, EclipseLink and OpenJPA was improved (in particular around @OneToMany and @ElementCollection pagination and filtering), and a comprehensive set of missing Javadocs and unit/integration tests was filled in.

OptimusFaces 1.0

OptimusFaces combines OmniFaces and PrimeFaces with OmniPersistence. The goal is to make it a breeze to create lazy-loaded, searchable, sortable and filterable <p:dataTable> based on a Jakarta Persistence model and a generic entity service.

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>optimusfaces</artifactId>
    <version>1.0</version>
</dependency>

Requires a minimum of Java 17, Jakarta EE 10 Web Profile, OmniFaces 4.0, PrimeFaces 15.0.0 and OmniPersistence 1.0.

PagedDataModel

PagedDataModel is the backing model for <op:dataTable>. It extends from PrimeFaces LazyDataModel. You create one in a CDI bean via the fluent builder:

@Named
@ViewScoped
public class Persons implements Serializable {

    private PagedDataModel<Person> model;

    @Inject
    private PersonService personService;

    @PostConstruct
    public void init() {
        model = PagedDataModel.lazy(personService)
            .criteria(this::getCriteria)
            .build();
    }

    private Map<Getter<Person>, Object> getCriteria() {
        Map<Getter<Person>, Object> criteria = new LinkedHashMap<>();
        criteria.put(Person::isActive, true);
        return criteria;
    }

    public PagedDataModel<Person> getModel() {
        return model;
    }
}

The criteria supplier is re-evaluated on every table interaction triggering LazyDataModel#load(), so backing bean fields can drive it dynamically from custom input fields outside the table (even though the <op:dataTable> already offers built-in filtering via searchable="true"). Here's an example:

private Map<Getter<Person>, Object> getCriteria() {
    Map<Getter<Person>, Object> criteria = new LinkedHashMap<>();

    if (searchName != null && !searchName.isBlank()) {
        criteria.put(Person::getName, Like.startsWith(searchName));
    }

    if (searchStatus != null) {
        criteria.put(Person::getStatus, searchStatus);
    }

    if (searchCreatedAfter != null) {
        criteria.put(Person::getCreated, Order.greaterThanOrEqualTo(searchCreatedAfter));
    }

    return criteria;
}

In case you already have a small list in memory, PagedDataModel.nonLazy(list) will apply sorting and filtering in-memory instead.

<op:dataTable> and <op:column>

Wire the model to the view with <op:dataTable> and declare columns with <op:column field="...">. Column ids, headers, sorting and filtering are all derived automatically from merely the field name:

<op:dataTable id="persons" value="#{persons.model}"
    searchable="true" exportable="true" selectable="true">

    <op:column field="firstName" />
    <op:column field="lastName" />
    <op:column field="dateOfBirth">
        <f:convertDateTime type="localDate" pattern="yyyy-MM-dd" />
    </op:column>
    <op:column field="address.city" />
    <op:column field="phones.number" />
    <op:column field="groups" filterMode="contains" />

</op:dataTable>

searchable="true" adds a global filter bar above the table. exportable="true" adds a column toggler and a CSV/PDF/XLSX export button. selectable="true" adds checkboxes and selected rows are available as model.getSelection().

The field attribute on <op:column> understands dot-notation across Jakarta Persistence relationship types. Thanks to the NestedBaseEntityELResolver address.city navigates a @ManyToOne, phones.number renders each element of a @OneToMany collection on a separate line, and groups on an @ElementCollection renders each element inline. Sorting and filtering on database side works for @ManyToOne and @OneToOne. For collections it depends on the Jakarta Persistence provider.

In case the generated cell output is not sufficient, you can fully customise it:

<op:column field="lastName">
    <ui:define name="cell">
        <h:link value="#{item.firstName} #{item.lastName}" outcome="person">
            <f:param name="id" value="#{item.id}" />
        </h:link>
    </ui:define>
</op:column>

Bookmarkability and stateless support

In plain PrimeFaces, a lazy <p:dataTable> requires a @ViewScoped backing bean. The reason is that the model instance needs to survive across postbacks, including its wrapped data. @ViewScoped achieves this by keeping the bean instance alive in the view map between requests, tracked by identifiers in the Jakarta Faces View State. When the bean is @RequestScoped the model is brand new on every request, its wrapped data is null at the point decode runs, and pagination, sorting and selection postbacks silently fail.

This also means that stateless Jakarta Faces views (<f:view transient="true">), which disable Jakarta Faces state saving entirely on a per-view basis, and therefore inherently break @ViewScoped beans and require @RequestScoped beans, are simply not an option when you need a paginable/sortable/filterable/selectable lazy PrimeFaces data table.

OptimusFaces solves this differently. After every table interaction, LazyPagedDataModel#updateQueryStringIfNecessary() collects the current table state and emits a JavaScript callback toOptimusFaces.Util.updateQueryString(). That function calls window.history.replaceState() to update the browser URL without a page reload and loops over all JSF forms to update the action URL. The URL now always reflects the exact current table state: page number, sort column, sort direction, active column filters and row selection.

On the next request, whether it is a postback, a browser refresh or a bookmarked URL, ExtendedDataTable takes over. It overrides preDecode() and detects that the lazy model has no wrapped data yet. Instead of letting decode fail, it calls LazyPagedDataModel#preloadPage(), which reads the table state back from the URL query string parameters and loads the correct page from the data store before decode proceeds. JSF state is never needed for this. The query string is the state.

The practical consequence is that <op:dataTable> works fully with @RequestScoped backing beans and stateless Jakarta Faces views, and the table is fully bookmarkable and shareable at the same time.

@Named
@RequestScoped // Works fine with both regular and stateless (transient) views.
public class Persons {

    private PagedDataModel<Person> model;

    @Inject
    private PersonService personService;

    @PostConstruct
    public void init() {
        model = PagedDataModel.lazy(personService).build();
    }

    public PagedDataModel<Person> getModel() {
        return model;
    }
}

Parameter names are derived from the table id. In case you have multiple tables on the same view, set a queryParameterPrefix on each to keep their parameters separate.

Ajax event marker classes

OptimusFaces uses predefined PFS (PrimeFaces Selectors) classes that components can use to opt in to automatic updates when specific table events fire:

  • updateOnDataTablePage - updated on every pagination change
  • updateOnDataTableSort - updated on sort
  • updateOnDataTableFilter - updated when the filter or global search changes
  • updateOnDataTableSelect - updated on row selection
<p:outputPanel styleClass="updateOnDataTableFilter">
    Found #{persons.model.rowCount} persons
</p:outputPanel>

What changed for OptimusFaces 1.0

Sort and filter metadata are now cached inside LazyPagedDataModel to avoid repeated map lookups on every render. SQL Server and DB2 were added to the integration test matrix. A TomEE 10.1.x/OpenWebBeans issue with <c:set scope="application"> inside column.xhtml (which caused a LinkageError on the second column inclusion due to a duplicate CDI proxy class definition) was fixed by removing the explicit scope from those tags. OpenJPA's pagination misbehaviour for @OneToMany fetch joins is now worked around with a postponed-fetch strategy that issues a secondary WHERE id IN (...) query instead of relying on a single JOIN FETCH that OpenJPA would incorrectly LIMIT before aggregating rows per entity.

Testing matrix

OptimusFaces runs its integration tests against three Jakarta Persistence providers:

  • Hibernate 7 as provided by WildFly
  • EclipseLink 5 as provided by GlassFish
  • OpenJPA 4 as provided by TomEE

And five databases:

  • H2 (embedded)
  • MySQL 8
  • PostgreSQL 15
  • SQL Server 2022
  • DB2 12

Not every combination of provider and database is exercised, but the 15-environment matrix each running 31 test cases on 19 XHTML files is wide enough to shake out a number of interesting provider-specific and database-specific bugs that would otherwise only surface in production.

A word on AI assistance

Getting both libraries to finally make it to 1.0 involved a lot of work that had been pending for a long time: missing Javadocs across both codebases, additional unit and integration tests, hardening against current versions of Hibernate, EclipseLink and OpenJPA, and fixing a number of subtle EclipseLink/OpenJPA-specific bugs around @OneToMany pagination, @ElementCollection filtering and nested correlated subqueries. I simply didn't want to release a 1.0 with halfbaked documentation or subtle EclipseLink/OpenJPA-specific bugs but never really got the time to get there so they stayed 0.x for fairly a long time. A substantial part of pending work was finally done with help of Claude Code.

AI-assisted development has come far enough to be genuinely useful for this kind of systematic, high-context work: completing patterns across many files, catching edge cases in tricky SQL generation code, and writing correct Javadoc for APIs as never seen before. Also the GitHub READMEs were thoroughly updated by Claude based on all it knows about the projects, including a section which carefully compares OmniPersistence to Jakarta Data.

Links

Monday, February 23, 2026

OmniHai finds its voice

OmniHai 1.2 is out. After 1.1 gave the library ears and the ability to transcribe audio, 1.2 completes the picture by giving it a voice. Text-to-Speech (TTS) joins the API alongside some improvements around file handling and conversation history to make life easier.

Text-to-Speech

Audio generation is now a first-class citizen alongside audio transcription. The simplest form is a single generateAudio() method call that returns raw audio bytes:

byte[] audio = service.generateAudio("Welcome to OmniHai 1.2!");

If you want to stream the result directly to a file without buffering it in memory, pass a Path instead:

service.generateAudio("Welcome to OmniHai 1.2!", Path.of("/path/to/welcome.mp3"));

Of course also available as async method: generateAudioAsync().

So far, only OpenAI and Google AI are supported. Other providers are not supported simply because they do not offer any HTTP based API endpoint yet (Anthropic, Mistral, Meta AI, Azure, OpenRouter, Ollama), or do not offer an unified API which is compatible with all TTS models (HuggingFace), or only expose it through WebSocket streaming (xAI), which does not fit the request/response model OmniHai is built on. Support will be added as providers introduce one.

Customization is available through the new GenerateAudioOptions builder, which exposes voice, speed, and output format. Here's an example for OpenAI GPT:

var options = GenerateAudioOptions.newBuilder()
    .voice("nova")
    .speed(1.25)
    .outputFormat("opus")
    .build();

gpt.generateAudio("Welcome to OmniHai 1.2!", Path.of("/path/to/welcome.opus"), options);

One thing worth mentioning about Google AI: Gemini returns raw PCM audio rather than a proper audio container. OmniHai transparently adds a WAV header before handing back the result, so callers get consistent, playable audio regardless of which provider is used.

Transcribing from a Path

The existing transcription API only accepted byte arrays, which meant loading the entire audio file into memory before making the API call. In 1.2, transcribe() and transcribeAsync() now also accept a Path:

String transcript = service.transcribe(Path.of("/recordings/meeting.mp3"));

For providers that support a files API the file is streamed directly from disk, no intermediate copy and no heap pressure. The byte array overload from 1.1 still works exactly as before.

Path-backed File Attachments

The same improvement extends to chat attachments. ChatInput.Builder#attach() now accepts Path arguments alongside the existing byte array support:

var input = ChatInput.newBuilder()
    .message("Summarize this contract.")
    .attach(Path.of("/path/to/contract.pdf"))
    .build();

var summary = service.chat(input);

MIME type detection still reads only the magic bytes rather than assuming it based on the file extension, and the upload itself streams the content from disk. For large PDFs or images this is a meaningful difference, and it requires no change to calling code beyond passing a path instead of a byte array.

History Initialisation

Conversation memory has always lived in ChatOptions, but there was no way to seed it with a prior exchange. In 1.2 the ChatOptions.Builder gains a history() method that accepts an existing message list:

// At the end of a session, persist the history
List<Message> saved = options.getHistory();

// On the next session, restore it
var options = ChatOptions.newBuilder()
    .systemPrompt("You are a helpful assistant.")
    .withMemory()
    .history(saved)
    .build();

var response = service.chat("Where were we?", options);

This makes it straightforward to persist a conversation to a database, load it back on the next session, and hand it straight to the service without any manual message reconstruction.

Getting 1.2

Add the following dependency to your project and you are ready to go:

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>omnihai</artifactId>
    <version>1.2</version>
</dependency>

Feedback and contributions are welcome on the GitHub repository.

Thursday, February 12, 2026

OmniHai grows ears

OmniHai 1.1 is here! This release brings audio transcription, smarter conversation memory, automatic file cleanup, gzip compression, and a pile of hardening across the board.

If you missed the earlier posts: OmniHai is a lightweight Java utility library that provides a unified API across multiple AI providers for Jakarta EE and MicroProfile applications. Check out the introduction, streaming & custom handlers, and 1.0 release posts for the full backstory.

Here are the Maven coordinates:

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>omnihai</artifactId>
    <version>1.1</version>
</dependency>

Audio Transcription

OmniHai now transcribes audio. Just pass in the bytes:

byte[] audio = Files.readAllBytes(Path.of("meeting.wav"));
String transcription = service.transcribe(audio);

That's it. Supported formats include WAV, MP3, MP4, FLAC, AAC, AIFF, OGG, and WebM. The async variant transcribeAsync() is also available, like all other methods in AIService.

Providers with a native transcription API (OpenAI, Mistral, Hugging Face) use it directly for best accuracy. All other providers fall back to a chat-based approach where the audio is attached to a carefully crafted transcription prompt. This means transcription works everywhere, even on providers that don't have a dedicated endpoint for it. Integration tests are also caught up, and they all pass.

A new AIAudioHandler interface joins the existing AITextHandler and AIImageHandler for customization. The default handler produces a verbatim plain-text transcription, but you might want something different. For example: a medical or legal transcription handler that includes domain-specific terminology hints in the prompt, a handler that adds speaker labels and timestamps, or one that outputs SRT/VTT subtitle format instead of plain text. You can plug in your own via @AI(audioHandler = MyAudioHandler.class) or programmatically through AIStrategy. Speaking of which, AIStrategy now has convenient factory methods:

AIStrategy strategy = AIStrategy.of(MyTextHandler.class);
AIStrategy strategy = AIStrategy.of(MyTextHandler.class, MyImageHandler.class, MyAudioHandler.class);

Smarter Conversation Memory

As a reminder: OmniHai's conversation memory is fully caller-owned. There's no server-side session state, no database, no memory leaks, no lifecycle management to worry about. History lives in your ChatOptions instance, not in the service. You control it, you scope it, you discard it. This remains one of OmniHai's key design advantages.

In 1.0, memory kept everything. That's fine for short conversations, but eventually you'll hit the provider's context window. In 1.1, history is maintained as a sliding window with a default of 20 messages (10 conversational turns). Oldest messages are automatically evicted when the limit is exceeded:

ChatOptions options = ChatOptions.newBuilder()
    .withMemory(50) // Keep up to 50 messages (25 turns)
    .build();

File attachments are now tracked in history too. When you upload files in a memory-enabled chat, their references are preserved across turns so the AI can continue referencing previously uploaded documents:

ChatOptions options = ChatOptions.newBuilder()
    .withMemory()
    .build();

ChatInput input = ChatInput.newBuilder()
    .message("Analyze this PDF")
    .attach(Files.readAllBytes(Path.of("report.pdf")))
    .build();

String analysis = service.chat(input, options);
String followUp = service.chat("What's on page 2?", options); // AI still has access to the PDF

When messages slide out of the window, their associated file references are evicted as well. File tracking in history requires the AI provider to support a files API, which is currently the case for OpenAI(-compatible) providers, Anthropic, and Google AI.

Automatic File Cleanup

This one's a behind-the-scenes improvement that you don't have to think about, and that's the point ;) When you upload files via the chat API, they end up on the provider's servers. Some providers automatically clean up these after a day or two, or support expiration metadata, but there are providers which don't support expiration let alone automatic clean up. So uploaded files might accumulate forever and who knows what happens. OmniHai now handles this: uploaded files are automatically cleaned up in the background after 2 days in a fire-and-forget task. Only files uploaded by OmniHai are touched. No configuration needed.

By the way, the fire-and-forget task will automatically use the Jakarta EE container managed ExecutorService if available, or else the MicroProfile managed one, or else fall back to standard Java SE Executors.newSingleThreadExecutor (for e.g. Tomcat).

Gzip Compression

All HTTP responses from AI providers are now transparently decompressed when gzip-encoded. OmniHai sends Accept-Encoding: gzip on every request and handles the decompression automatically. This reduces bandwidth usage, which is particularly nice for those verbose JSON responses that AI providers love to return.

Under the Hood

Beyond the headline features, 1.1 includes a bunch of improvements:

  • ChatOptions#withSystemPrompt() creates a copy of existing options with a different system prompt, useful for reusing a base configuration across different use cases.
  • Hardened file upload handling across providers, especially for Mistral compatibility.
  • The OpenRouterAITextHandler was dropped entirely as improved file upload handling in the base OpenAITextHandler made it redundant.
  • Updated the default OpenAI model version.
  • Various javadoc fixes and additional unit/integration tests.

Give It a Try

As always, feedback and contributions are welcome on GitHub. If you run into any issues, open an issue. Pull requests are welcome too.

Wednesday, February 4, 2026

OmniHai 1.0 released!

After two milestones of a lightweight Java library providing one API across multiple AI providers, 1.0-M1: One API, any AI and 1.0-M2: Real-time AI, Your Way, today the library graduates to its first stable release. And comes with a new name: OmniHai.

Why "OmniHai"?

The rename from OmniAI to OmniHai was necessary because "OmniAI" was already used by several other products, making it difficult to discover, search for, and distinguish. The new name keeps "AI" clearly audible and visible, "Hai" sounds like AI, while being more memorable, more brandable, and actually findable on search engines. Also, "Hai" is Japanese for "yes", which felt fitting, one yes to any AI provider.

The Maven coordinates are now:

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>omnihai</artifactId>
    <version>1.0</version>
</dependency>

What's New in 1.0

Since the second milestone previous week, five major features were added: structured outputs, file attachments, conversation memory, proofreading, and MicroProfile Config support.

Structured Outputs

This is probably the most impactful addition. Instead of parsing AI responses as free-text strings, you can now get typed Java objects directly:

record ProductReview(String sentiment, int rating, List<String> pros, List<String> cons) {}

ProductReview review = service.chat("Analyze this review: " + reviewText, ProductReview.class);

Under the hood, OmniHai generates a JSON schema from your record (or bean) class, instructs the AI to return conforming JSON, and deserializes the response back. The JsonSchemaHelper supports primitive types, strings, enums, temporals, collections, arrays, maps, nested types, and Optional fields. You can if necessary also take manual control:

JsonObject schema = JsonSchemaHelper.buildJsonSchema(ProductReview.class);
ChatOptions options = ChatOptions.newBuilder().jsonSchema(schema).build();
String json = service.chat("Analyze this review: " + reviewText, options);
ProductReview review = JsonSchemaHelper.fromJson(json, ProductReview.class);

The content moderation internals were also refactored to use structured outputs, making ModerationResult parsing more robust across providers.

File Attachments

Chat input now supports attaching any file: images, PDFs, Word documents, audio, and more:

byte[] document = Files.readAllBytes(Path.of("report.pdf"));
byte[] image = Files.readAllBytes(Path.of("chart.png"));

ChatInput input = ChatInput.newBuilder()
    .message("Compare these files")
    .attach(document, image)
    .build();

String response = service.chat(input);

The in M2 introduced ChatInput.Builder#images(byte[]...) method is replaced by the more general attach(byte[]...) method that handles any file type the AI provider supports.

Conversation Memory

Multi-turn conversations are now a first-class feature. Enable memory on ChatOptions and OmniHai tracks the full conversation history for you:

ChatOptions options = ChatOptions.newBuilder()
    .systemPrompt("You are a helpful assistant.")
    .withMemory()
    .build();

String response1 = service.chat("My name is Bob.", options);
String response2 = service.chat("What is my name?", options); // AI remembers: "Bob"

// Access conversation history programmatically
List<Message> history = options.getHistory();

The key design decision here is that history lives in ChatOptions, not in the service. There is no server-side session state, no memory leaks, no lifecycle management. The caller owns the conversation. This aligns with the library's philosophy of being a utility for the AI developer (or framework), not a whole framework.

Proofreading

A small but useful addition: AI-powered grammar and spelling correction:

String corrected = service.proofread(text);

The AIService#proofread(String) uses a deterministic temperature to ensure consistent, reliable corrections while preserving the original meaning, tone, and style. Of course also available as proofreadAsync(String).

MicroProfile Config Support

Alongside the existing Jakarta EL expressions (#{...} and ${...}), the @AI qualifier now also resolves MicroProfile Config expressions ${config:...}:

@Inject
@AI(provider = AIProvider.OPENAI, apiKey = "${config:openai.api-key}")
private AIService gpt;

This makes OmniHai a natural fit not only for Jakarta EE runtimes, but also for MicroProfile runtimes such as Quarkus. On MicroProfile, secrets can live in microprofile-config.properties, environment variables, or any custom ConfigSource.

Under the Hood

Beyond the headline features, the 1.0 release includes:

  • DefaultAITextHandler and DefaultAIImageHandler replacing the previous abstract base classes, reducing boilerplate for custom providers
  • Improved Attachment model decoupled from OpenAI-specific assumptions
  • Comprehensive package-info Javadoc for all packages
  • Extensive unit tests (total 472, many generated with help of my assistant Claude) covering models, helpers, MIME detection, and expression resolvers
  • More integration tests (total 165), covering all text and image handling features of all 10 AI providers
  • Bug fixes and hardening based on those tests

Size

The library grew from about 70 KB in M1 to about 110 KB in M2 to about 155 KB in 1.0 final. Still at least 35x smaller than LangChain4J per provider module. The dependency story remains the same: only Jakarta JSON-P is required; CDI, EL, and MP Config are optional.

The Road Here

Three releases in roughly a month. The M1 established the core API with 8 providers. The M2 added chat streaming and custom handlers. This final release fills the remaining gaps for a production-ready library: structured outputs for type-safe responses, file attachments for multi-modal input, conversation memory for multi-turn interactions, and MicroProfile compatibility.

OmniHai is a sharp chef's knife, it does a few things very well. If you need RAG pipelines, agent frameworks, or vector stores, look at LangChain4J or Spring AI. If you need multi-provider chat, text analysis, and content moderation in Jakarta EE or MicroProfile with minimal dependencies, OmniHai is arguably the better choice.

Wednesday, January 28, 2026

Real-time AI, Your Way

Update: OmniAI has been renamed to OmniHai. See the 1.0 release post for details.

OmniAI OmniHai 1.0-M2 is here. This milestone brings streaming support for real-time chat experiences and custom handlers for ultimate flexibility.

Since the first milestone, I've not only added 2 new built-in AI providers, Mistral and Hugging Face, but I've also been working on two major features that were on my roadmap: streaming responses and the ability to customize how OmniHai interacts with AI providers. Let's dive in.

Streaming: Token by Token

Remember those "..." typing indicators while waiting for AI to think? With streaming, your users can now watch the response appear in real-time, token by token. This isn't just a nicer UX, it makes your application feel alive. You can use AIService#chatStream() to achieve this.

service.chatStream(message, token -> {
    System.out.print(token); // Called for each token.
}).exceptionally(e -> {
    System.out.println("\n\nError: " + e.getMessage()); // Handle error.
    return null;
}).thenRun(() -> {
    System.out.println("\n\n"); // Handle completion.
});

Under the hood, OmniAI OmniHai uses Server-Sent Events (SSE) to receive the stream from the AI provider. Each token triggers your callback, and you can display it immediately. No buffering, no waiting.

Streaming works with OpenAI, Anthropic, Google AI, xAI, and other providers extending from OpenAI. You can check support programmatically with AIService#supportsStreaming().

Custom Handlers: Your API, Your Rules

Every AI provider has its quirks. Maybe you need to add custom headers, track usage metrics, or parse responses differently. 1.0-M2 has extracted all handlers from the AI service implementations into common interfaces and reusable base implementations, allowing a clean way to customize how requests are built and responses are parsed.

There are two handler types:

Here's a simple example that adds user tracking to every OpenAI request:

public class TrackingTextHandler extends OpenAITextHandler {

    @Override
    public JsonObject buildChatPayload(AIService service, ChatInput input, ChatOptions options, boolean streaming) {
        var payload = super.buildChatPayload(service, input, options, streaming);

        return Json.createObjectBuilder(payload)
            .add("user", getCurrentUserIdHash())
            .add("metadata", Json.createObjectBuilder()
                .add("session", getCurrentSessionIdHash()))
            .build();
    }
}

Wire it up with CDI:

@Inject
@AI(provider = OPENAI, apiKey = "#{keys.openai}", textHandler = TrackingTextHandler.class)
private AIService trackedService;

Or programmatically (note that a null handler in the strategy will let the service fall back to the provider's default one):

AIStrategy strategy = (new AIStrategy(TrackingTextHandler.class, null);
AIService service = AIConfig.of(AIProvider.OPENAI, apiKey).withStrategy(strategy).createService();

Handlers give you full control over:

  • Request payload construction
  • Response parsing (including custom JSON paths)
  • Streaming event processing
  • System prompt templates for summarization, translation, etc.

Each provider has a built-in handler (OpenAITextHandler, AnthropicAITextHandler, GoogleAITextHandler, etc.) that you can extend to override only what you need.

Installation

The second milestone is already available at Maven and it's only 110 KB (e.g. LangChain4J is well over 2MB!):

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>omniai</artifactId>
    <version>1.0-M2</version>
</dependency>

It only requires Jakarta JSON-P and optionally Jakarta CDI and Jakarta EL as dependencies, which are readily available on any Jakarta EE compatible runtime.

In case you're using a non-Jakarta EE runtime, such as Tomcat, you'll have to manually provide JSON-P and CDI implementations.

Demo

Here's a minimal Jakarta Faces based "Chat with AI!" demo which extends the previous demo with the new streaming feature.

The session scoped backing bean, modified to use chat streaming:

src/main/java/com/example/Chat.java

package com.example;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;

import jakarta.enterprise.context.SessionScoped;
import jakarta.faces.push.Push;
import jakarta.faces.push.PushContext;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.json.Json;

import org.omnifaces.ai.AIService;
import org.omnifaces.ai.cdi.AI;

@Named
@SessionScoped
public class Chat implements Serializable {

    private static final long serialVersionUID = 1L;

    public record Message(Type type, String content, String id) implements Serializable {
        public enum Type {
            sent, received, stream;
        }

        public String toJson() {
            return Json.createObjectBuilder().add("type", type.name()).add("content", content).add("id", id()).build().toString();
        }
    };

    @Inject @AI(apiKey = "your-openai-api-key") // Get a free one here: https://platform.openai.com/api-keys
    private AIService gpt;

    @Inject @Push
    private PushContext push;

    private String message;
    private List<Message> messages = new ArrayList<>();

    public void onload() { // Any ungrouped/aborted stream events need to be collapsed in case page is refreshed; this is not necessary if bean is view scoped instead of session scoped.
        var grouped = messages.stream().collect(groupingBy(Message::id, LinkedHashMap::new, toList()));

        for (var entry : grouped.entrySet()) {
            if (entry.getValue().size() > 1) {
                messages.removeAll(entry.getValue());
                addMessage(Message.Type.received, concatContent(entry.getValue()), entry.getKey());
            }
        }
    }

    public void send() {
        addMessage(Message.Type.sent, message, UUID.randomUUID().toString());

        var id = UUID.randomUUID().toString();

        gpt.chatStream(message, token -> {
            addMessage(Message.Type.stream, token, id);
        }).exceptionally(e -> {
            addMessage(Message.Type.stream, "[response aborted, please retry]", id);
            e.printStackTrace();
            return null;
        }).thenRun(() -> {
            var streamed = messages.stream().filter(m -> id.equals(m.id())).toList();
            messages.removeAll(streamed);

            if (streamed.isEmpty()) {
                addMessage(Message.Type.received, "[no response]", id);
            } else {
                addMessage(Message.Type.received, concatContent(streamed), id);
            }
        });

        message = null;
    }

    private static String concatContent(List<Message> messages) {
        return messages.stream().map(Message::content).collect(joining()).strip();
    }

    private void addMessage(Message.Type type, String content) {
        var message = new Message(type, content);
        messages.add(message);
        push.send(message.toJson());
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public List<Message> getMessages() {
        return messages;
    }
}

The simple XHTML (slightly modified to add the <f:viewAction> and id to the message <div>):

src/main/webapp/chat.xhtml

<!DOCTYPE html>
<html lang="en"
    xmlns:f="jakarta.faces.core"
    xmlns:h="jakarta.faces.html"
    xmlns:ui="jakarta.faces.facelets"
    xmlns:pt="jakarta.faces.passthrough"
>
    <f:metadata>
        <f:viewAction action="#{chat.onload}" />
    </f:metadata>
    <h:head>
        <title>Chat with AI!</title>
        <h:outputStylesheet name="chat.css" />
        <h:outputScript name="chat.js" />
    </h:head>
    <h:body>
        <h:form id="form">
            <h:inputTextarea id="message" value="#{chat.message}" required="true" pt:placeholder="Ask anything" pt:autofocus="true" />
            <h:commandButton id="send" value="Send" action="#{chat.send}">
                <f:ajax execute="@form" render="message" onevent="chat.onsend" />
            </h:commandButton>
        </h:form>
        <h:panelGroup id="chat" layout="block">
            <ui:repeat value="#{chat.messages}" var="message">
                <div id="id#{message.id()}" class="message #{message.type()}">#{message.content()}</div>
            </ui:repeat>
            <script>chat.scrollToBottom();</script>
        </h:panelGroup>
        <h:form id="websocket">
            <f:websocket channel="push" scope="session" onmessage="chat.onmessage" />
        </h:form>
    </h:body>
</html>

The quick'n'dirty CSS (still the same):

src/main/webapp/resources/chat.css

:root {
    --width: 500px;
}
body {
    font-family: sans-serif;
    width: var(--width);
    margin: 0 auto;
}
#form {
    position: absolute; bottom: 0;
    display: flex; gap: 1em; 
    width: calc(var(--width) - 1em);
    margin: 1em 0; padding: 1em;
    border-radius: 1em; box-shadow: 0 0 1em 0 #aaa;
}
#form textarea {
    flex: 1;
    height: 4em;
    padding: .75em;
    resize: none;
}
#form textarea, #form input {
    border: 1px solid #ccc; border-radius: .75em;
} 
#chat {
    display: flex; flex-direction: column; gap: 1em;
    max-height: calc(100vh - 10em); overflow: auto;
    padding: 1em;
}
#chat .message {
    width: 66%;
    padding: 1em;
    border-radius: 1em;
    white-space: pre-wrap;
}
#chat .message.sent {
    align-self: flex-end;
    border: 1px solid #aca;
}
#chat .message.received {
    border: 1px solid #aac;
}
#chat .progress {
    min-height: 1em;
}
#chat .progress::after {
    content: "";
    animation: dots 1.5s steps(4, end) infinite;
}
@keyframes dots {
    0%  { content: ""; }
    25% { content: "."; }
    50% { content: ".."; }
    75% { content: "..."; }
}

The jQuery-less JS (only the onmessage has been rewritten to support chat streaming):

src/main/webapp/resources/chat.js

window.chat = {
    onsend: (event) => {
        if (event.status == "success") {
            document.getElementById("form:message").focus();
        }
    },
    onmessage: (json) => {
        chat.hideProgress();
        const message = JSON.parse(json);
        let div = document.querySelector(`#id${message.id}`);
        if (!div) {
            div = document.createElement("div");
            div.id = `id${message.id}`;
            div.className = `message ${message.type === "stream" ? "received" : message.type}`;
            document.getElementById("chat").appendChild(div);
        }
        if (message.type == "stream") {
            div.textContent += message.content;
        } else {
            div.textContent = message.content;
        }
        if (message.type == "sent") {
            chat.showProgress();
        }
        chat.scrollToBottom();
    },
    showProgress: () => {
        if (!document.querySelector(".progress")) {
            document.getElementById("chat").insertAdjacentHTML("beforeend", '<div class="progress"></div>');
            chat.scrollToBottom();
        }
    },
    hideProgress: () => {
        document.querySelectorAll(".progress").forEach(el => el.remove());
    },
    scrollToBottom: () => {
        const chat = document.getElementById("chat");
        chat.scrollTo({
            top: chat.scrollHeight,
            behavior: "smooth"
        });
    }
};

Don't forget to create a (empty) src/main/webapp/WEB-INF/beans.xml file and enable websocket endpoint in web.xml:

src/main/webapp/WEB-INF/web.xml

<context-param>
    <param-name>jakarta.faces.ENABLE_WEBSOCKET_ENDPOINT</param-name>
    <param-value>true</param-value>
</context-param>

Now your chat shows the AI "typing" in real-time rather than appearing all at once after a long wait.

Real APIs, Real Tests

How do you test a library that talks to external AI services? You test it against the real thing.

OmniHai's integration tests hit actual AI provider APIs. No mocks. No fakes. When OpenAI, Anthropic, or Google changes something, we know immediately.

As you can see in OpenAIServiceTextHandlerIT example,

@EnabledIfEnvironmentVariable(named = OpenAIServiceTextHandlerIT.API_KEY_ENV_NAME, matches = ".+")
class OpenAIServiceTextHandlerIT extends BaseAIServiceTextHandlerIT {

    protected static final String API_KEY_ENV_NAME = "OPENAI_API_KEY";

    @Override
    protected AIProvider getProvider() {
        return AIProvider.OPENAI;
    }

    @Override
    protected String getApiKeyEnvName() {
        return API_KEY_ENV_NAME;
    }
}

... these tests only run when API keys are present as environment variables. This keeps CI clean for contributors without keys while allowing full validation when needed. See also DEVELOPERS.md how to configure these keys.

One challenge with real API testing is rate limits. Hit one, and your entire test suite might fail. OmniHai handles this with a custom JUnit extension, the FailFastOnRateLimitExtension:

public class FailFastOnRateLimitExtension implements BeforeEachCallback, TestExecutionExceptionHandler {

    private static final ConcurrentMap<AIProvider, AtomicBoolean> RATE_LIMIT_HITS = new ConcurrentHashMap<>();

    @Override
    public void beforeEach(ExtensionContext context) throws Exception {
        var provider = getProvider(context);
        if (RATE_LIMIT_HITS.computeIfAbsent(provider, p -> new AtomicBoolean(false)).get()) {
            throw new TestAbortedException("Rate limit hit for " + provider + "; skipping remaining tests for this provider, we better retry later.");
        }
    }

    @Override
    public void handleTestExecutionException(ExtensionContext context, Throwable throwable) throws Throwable {
        if (throwable instanceof AIRateLimitExceededException) {
            RATE_LIMIT_HITS.computeIfAbsent(getProvider(context), p -> new AtomicBoolean(false)).set(true);
        }
        throw throwable;
    }

    private static AIProvider getProvider(ExtensionContext context) {
        if (!(context.getRequiredTestInstance() instanceof AIServiceIT instance)) {
            throw new IllegalStateException("FailFastOnRateLimitExtension must be used on subclasses of AIServiceIT");
        }
        return instance.getProvider();
    }
}

Once a rate limit is hit for a provider, remaining tests for that provider are skipped. No wasted quota, no DOS, no cascading failures.

Tests also include a 1-second delay between calls and verify actual response content, not just that an API was called, but that translations preserve markup, that language detection returns correct ISO codes, and that summarizations stay within word limits.

Give it a try

As always, feedback and contributions are welcome on GitHub!

Tuesday, January 20, 2026

One API, any AI

Update: OmniAI has been renamed to OmniHai. See the 1.0 release post for details.

Tired of vendor lock-in with AI APIs? I built OmniAI OmniHai. One API to rule them all.

Interacting with AI providers in Java typically means choosing between heavyweight frameworks or writing repetitive boilerplate for each provider's API. After playing around with the idea for months I'm proud to finally introduce OmniHai as a third option: a lightweight utility library that provides a unified API across multiple providers with minimal dependencies (just JSON-P and optionally CDI+EL).

The major goal is to simplify interacting with a range of AI providers by providing a single, consistent Java API. It achieves that by interacting with their REST API endpoints directly using plain vanilla java.net.http.HttpClient.

@Inject @AI(apiKey = "#{config.openaiApiKey}")
private AIService gpt;

public void chat() {
    // Synchronous
    String response = gpt.chat("Hello!");

    // Asynchronous
    CompletableFuture<String> futureResponse = gpt.chatAsync("Hello!");
    
    // ...
}

As you see, you can simply inject and configure it using the @AI qualifier. The AIProvider offers several AI providers each having their own AIService implementation. Currently: OpenAI GPT (default), Anthropic Claude, Google AI, xAI Grok, Meta Llama, Azure OpenAI, OpenRouter and Ollama (plus a Custom option). The AIService class provides a couple of handy utility methods for commonly used real world interactions with the AI. The initial version has the following methods:

  • chat - just plain chat, optionally with a prompt/context (e.g. "Helpful Jakarta EE specialist")
  • summarize - summarize a long text with max amount of words
  • extractKeyPoints - extract specified number of key points from a long text
  • detectLanguage - detect language of the given text as ISO 639-1 language code
  • translate - translate text from source language to target language while preserving any markup and placeholders
  • moderateContent - moderates text to detect violations by category
  • analyzeImage - analyzes an image and generates a description based on a prompt
  • generateAltText - generate alt text of an image suitable for accessibility purposes
  • generateImage - generates an image based on a prompt

Each method has also an async variant available returning a CompletableFuture.

Here are a couple more @AI configuration examples:

// With hardcoded key (bad idea but ok for local testing ;) )
@Inject @AI(provider = ANTHROPIC, apiKey = "sk-ant-api01-A1Bcdefg2HiJkL3-MNOpqrS45TUvW67x8yZ")
private AIService claude;

// With EL for dynamic configuration
@Inject @AI(provider = OPENAI, apiKey = "#{initParam['com.example.OPENAI_KEY']}")
private AIService gpt;

// With default provider (OpenAI) and custom system prompt
@Inject @AI(apiKey = "#{keys.openai}", prompt = "You are a helpful assistant specialized in Jakarta EE.")
private AIService jakartaExpert;

// With different model than default
@Inject @AI(provider = XAI, apiKey = "#{keys.xai}", model = "grok-2-image-1212")
private AIService imageGenerator;

// With custom endpoint
@Inject @AI(provider = OLLAMA, endpoint = "http://localhost:12345")
private AIService localAi;

Without CDI here's how you would do it:

// Provider and key
AIService claude = AIConfig.of(ANTHROPIC, yourAnthropicApiKey).createService();

// Custom model
AIService imageGenerator = AIConfig.of(XAI, yourXaiApiKey).withModel("grok-2-image-1212").createService();

Need diverse perspectives? OmniAI OmniHai makes it easy to query multiple providers and combine their responses:

@Inject @AI(apiKey = "#{config.openaiApiKey}")
private AIService gpt;

@Inject @AI(provider = GOOGLE, apiKey = "#{config.googleApiKey}")
private AIService gemini;

@Inject @AI(provider = XAI, apiKey = "#{config.xaiApiKey}")
private AIService grok;

public String getConsensusAnswer(String question) {
    var responses = Stream.of(gpt, gemini, grok)
        .parallel()
        .map(ai -> ai.chat(question))
        .toList();

    return gpt.summarize(String.join("\n\n", responses), 200);
}

This pattern is useful for reducing bias, cross-validating answers, or getting a balanced summary from multiple AI perspectives.

Installation

The first milestone is already available at Maven and it's only 70.67KB (e.g. LangChain4J is well over 2MB!):

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>omniai</artifactId>
    <version>1.0-M1</version>
</dependency>

It only requires Jakarta JSON-P and optionally Jakarta CDI and Jakarta EL as dependencies, which are readily available on any Jakarta EE compatible runtime.

In case you're using a non-Jakarta EE runtime, such as Tomcat, you'll have to manually provide JSON-P and CDI implementations.

Demo

Here's a minimalistic Jakarta Faces based "Chat with AI!" demo.

The session scoped backing bean:

src/main/java/com/example/Chat.java

package com.example;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;

import jakarta.enterprise.context.SessionScoped;
import jakarta.faces.push.Push;
import jakarta.faces.push.PushContext;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.json.Json;

import org.omnifaces.ai.AIService;
import org.omnifaces.ai.cdi.AI;

@Named
@SessionScoped
public class Chat implements Serializable {

    private static final long serialVersionUID = 1L;

    public record Message(Type type, String content) implements Serializable {
        public enum Type {
            sent, received;
        }

        public String toJson() {
            return Json.createObjectBuilder().add("type", type.name()).add("content", content).build().toString();
        }
    };

    @Inject @AI(apiKey = "your-openai-api-key") // Get a free one here: https://platform.openai.com/api-keys
    private AIService gpt;

    @Inject @Push
    private PushContext push;

    private String message;
    private List<Message> messages = new ArrayList<>();

    public void send() {
        addMessage(Message.Type.sent, message);
        gpt.chatAsync(message).thenAccept(response -> addMessage(Message.Type.received, response));
        message = null;
    }

    private void addMessage(Message.Type type, String content) {
        var message = new Message(type, content);
        messages.add(message);
        push.send(message.toJson());
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public List<Message> getMessages() {
        return messages;
    }
}

The simple XHTML:

src/main/webapp/chat.xhtml

<!DOCTYPE html>
<html lang="en"
    xmlns:f="jakarta.faces.core"
    xmlns:h="jakarta.faces.html"
    xmlns:ui="jakarta.faces.facelets"
    xmlns:pt="jakarta.faces.passthrough"
>
    <h:head>
        <title>Chat with AI!</title>
        <h:outputStylesheet name="chat.css" />
        <h:outputScript name="chat.js" />
    </h:head>
    <h:body>
        <h:form id="form">
            <h:inputTextarea id="message" value="#{chat.message}" required="true" pt:placeholder="Ask anything" pt:autofocus="true" />
            <h:commandButton id="send" value="Send" action="#{chat.send}">
                <f:ajax execute="@form" render="message" onevent="chat.onsend" />
            </h:commandButton>
        </h:form>
        <h:panelGroup id="chat" layout="block">
            <ui:repeat value="#{chat.messages}" var="message">
                <div class="message #{message.type()}">#{message.content()}</div>
            </ui:repeat>
            <script>chat.scrollToBottom();</script>
        </h:panelGroup>
        <h:form id="websocket">
            <f:websocket channel="push" scope="session" onmessage="chat.onmessage" />
        </h:form>
    </h:body>
</html>

The quick'n'dirty CSS:

src/main/webapp/resources/chat.css

:root {
    --width: 500px;
}
body {
    font-family: sans-serif;
    width: var(--width);
    margin: 0 auto;
}
#form {
    position: absolute; bottom: 0;
    display: flex; gap: 1em; 
    width: calc(var(--width) - 1em);
    margin: 1em 0; padding: 1em;
    border-radius: 1em; box-shadow: 0 0 1em 0 #aaa;
}
#form textarea {
    flex: 1;
    height: 4em;
    padding: .75em;
    resize: none;
}
#form textarea, #form input {
    border: 1px solid #ccc; border-radius: .75em;
} 
#chat {
    display: flex; flex-direction: column; gap: 1em;
    max-height: calc(100vh - 10em); overflow: auto;
    padding: 1em;
}
#chat .message {
    width: 66%;
    padding: 1em;
    border-radius: 1em;
    white-space: pre-wrap;
}
#chat .message.sent {
    align-self: flex-end;
    border: 1px solid #aca;
}
#chat .message.received {
    border: 1px solid #aac;
}
#chat .progress {
    min-height: 1em;
}
#chat .progress::after {
    content: "";
    animation: dots 1.5s steps(4, end) infinite;
}
@keyframes dots {
    0%  { content: ""; }
    25% { content: "."; }
    50% { content: ".."; }
    75% { content: "..."; }
}

The jQuery-less JS:

src/main/webapp/resources/chat.js

window.chat = {
    onsend: (event) => {
        if (event.status == "success") {
            document.getElementById("form:message").focus();
        }
    },
    onmessage: (json) => {
        chat.hideProgress();
        const message = JSON.parse(json);
        document.getElementById("chat").insertAdjacentHTML("beforeend", `<div class="message ${message.type}">${message.content}</div>`);
        if (message.type == "sent") {
            chat.showProgress();
        }
        chat.scrollToBottom();
    },
    showProgress: () => {
        if (!document.querySelector(".progress")) {
            document.getElementById("chat").insertAdjacentHTML("beforeend", '<div class="progress"></div>');
            chat.scrollToBottom();
        }
    },
    hideProgress: () => {
        document.querySelectorAll(".progress").forEach(el => el.remove());
    },
    scrollToBottom: () => {
        const chat = document.getElementById("chat");
        chat.scrollTo({
            top: chat.scrollHeight,
            behavior: "smooth"
        });
    }
};

Don't forget to create a (empty) src/main/webapp/WEB-INF/beans.xml file and enable websocket endpoint in web.xml:

src/main/webapp/WEB-INF/web.xml

<context-param>
    <param-name>jakarta.faces.ENABLE_WEBSOCKET_ENDPOINT</param-name>
    <param-value>true</param-value>
</context-param>

Here's what it looks like (xAI was used in this convo, hence real-time information):

Chat with AI!

(yes, Markdown formatting open for interpretation; CommonMark is a great library ;) )

Was AI involved in development?

Absolutely! I used Claude Code as consultant and assistant in my Linux terminal. It's a great tool for consulting and generating boilerplate code, especially filling in the Javadocs and creating unit tests. It has almost completely replaced Google and Stack Overflow for me with regard to investigating and brainstorming. Claude works so much better with consulting because it has full access to the entire project and can therefore form a full context. As to brainstorming, Claude came up with sensible real world use cases to kick off the AIService interface. Claude generated example implementations of OpenAI, Anthropic, Azure, Bedrock and Ollama. I threw away the Bedrock (AWS) one because it was a horrible mess full of AWS-specific stuff which I couldn't really verify/test (you better pick the AWS SDK for that and I didn't like having that as a dependency).

I had to refactor a lot of duplicate code in these example implementations and pull a new BaseAIService out of it (true, I could have instructed Claude beforehand to please respect the DRY and KISS principles, but alas, tokens are not free yet). Oh yes I also asked Claude to please replace all these hardcoded JSON string templates (!) and hardcoded JSON parsing/tokenization (!!) by clean JSON-P code. Then I architected the AIProvider enum, AIConfig record, @AI annotation and all these helpful AIException subclasses. And I added a couple more providers and implementations, most of which just extend from OpenAIService, except for the Google one, which is newly implemented, because its API is not OpenAI compatible. Then I instructed Claude to add asynchronous method variants to the AIService and let the synchronous methods invoke them by default, after which I improved the exception handling. Oh I also used ChatGPT to review the OpenAIService implementation, Gemini to review the GoogleAIService implementation, and Grok to review the XAIService implementation. They were very helpful in validating the impl of their own service :)

I estimate about 20% of actual OmniHai code is still raw AI generated, the rest is polished/refactored/refined/expanded. Javadocs are for about 70% generated and for the rest rewritten/clarified. The project's README at GitHub was for about 90% generated. I explicitly asked Claude for the comparisons with LangChain4J, Spring AI and Jakarta Agentic, and they are very spot on! In short: OmniHai is intentionally minimal, a sharp chef's knife rather than a full kitchen. It's ideal when you need multi-provider chat, text analysis, or content moderation on Jakarta EE without the dependency overhead.

I started this project 5 days ago, by the way.

Give it a spin and let me know what you think!

As you might have noticed, it's released as a M1 version (milestone/beta). That's because I haven't been able to fully test all AI providers yet, especially image generation (which still isn't free these days). Only the Claude, xAI and OpenRouter ones are 100% tested (including image generation with xAI). Note that OpenRouter is basically an OpenAI-compatible service offering over 600+ different models of which 30+ are free. For OpenAI and Google AI only chat, moderation and image analysis have been tested, so image generation is pending. Azure and Meta are not yet tested. Technically they just extend from OpenAI which is already tested, but the interpretation of the predefined system prompts may differ per AI agent and hence the prompts should be validated and perhaps polished. And finally Ollama, which is supposed to be installed at localhost, also needs testing as its current buildXyzPayload methods are still for a big part Claude-generated. I'll have to look closer at it the upcoming time.

If you run into any issues or have ideas for improvements, feel free to open an issue on GitHub. Pull requests are of course also welcome, whether it's a bug fix, a new provider implementation, or just clearer documentation.

In the long run I'll of course keep enhancing it. For example, I have in mind to pull the text analysis, text translation, content moderation, etc strategies from the BaseAIService so that they are easier individually decoratable via e.g. @AI(..., textAnalyzer = MyTextAnalyzer.class). Just in case you wish to tweak only a small part of the impl rather than extending from a whole AIService impl. Also I'd like to add a @AI(serviceClass = MyAIServiceImpl.class, ...) as alternative to AIProvider. And I need to add support for streaming chat so a "live response" experience can be achieved. And so forth :)