Showing posts with label WebSocket. Show all posts
Showing posts with label WebSocket. Show all posts

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 :)

Friday, April 1, 2016

OmniFaces 2.3 brings JSF and WebSockets together!

OmniFaces 2.3 has been released!

This version brings a fairly important new tag to the world: <o:socket> for WebSocket based server-side push. Not only because it integrates JSR-356 WebSocket API seamlessly into JSF API with help of the CDI API, but also because it is the base for JSF 2.3's upcoming new <f:websocket> tag. Basically, OmniFaces offers you the opportunity to benefit of it long before JSF 2.3 itself is released (not before 2017). Afterwards, any migration should be a piece of cake.

Next to the <o:socket> and a lot of utility methods and functions, there's another new tag <o:skipValidators> and a further improved @ViewScoped which now also really physically destroys the associated JSF view state during unload (and thus not only the view scoped bean itself as it did before).

Note: whilst perhaps confusing, OmniFaces version 2.3 is still targeted at JSF 2.2. All OmniFaces 2.x versions are targeted at JSF 2.2. Only the future OmniFaces 3.x will be targeted at JSF 2.3.

Installation

Non-Maven users: download OmniFaces 2.3 JAR and drop it in /WEB-INF/lib the usual way, replacing the older version if any.

Maven users: use <version>2.3</version>.

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

For users who don't want to use CDI, there's the CDI-less 1.13 with all 2.3 enhancements and fixes (but no brand new 2.x additions nor the o:socket!).

Easy scoped and user-targeted push

Besides "easy to use", an important design decision of <o:socket> was being able to send push messages from the server side to a specific scope (application, session or view) and/or to a specific user.

Example 1

The below application scoped socket example will receive the same push message from the application in all pages having the same socket channel.

<o:socket channel="top10" onmessage="top10Listener" />
function top10Listener(top10) {
    console.log(top10); // Do your thing with HTML DOM.
}
@Startup @Singleton
@ConcurrencyManagement(BEAN)
public class SomeTop10Manager {

    private List<Some> top10;

    @PersistenceContext
    private EntityManager entityManager;

    @Inject
    private BeanManager beanManager;

    @PostConstruct
    @Schedule(hour="*", minute="*/1", second="0", persistent=false)
    public void load() {
        List<Some> top10 = entityManager.createNamedQuery("Some.top10", Some.class).getResultList();

        if (!top10.equals(this.top10)) {
            this.top10 = top10;
            beanManager.fireEvent(new SomeTop10Event(top10));
        }
    }

    public List<Some> list() {
        return top10;
    }

}
@ApplicationScoped
public class SomeTop10Observer {

    @Inject @Push
    private PushContext top10;

    public void onTop10Change(@Observes SomeTop10Event event) {
        top10.send(event.getTop10());
    }

}

Example 2

The below user-targeted (implicitly session scoped) socket example will receive the same push message in all pages having the same socket channel and user. This example assumes that the logged-in user is available via an EL variable as #{user} which has an id property representing its identifier.

<o:socket channel="chat" user="#{user.id}" onmessage="chatListener" />
function chatListener(message) {
    console.log(message); // Do your thing with HTML DOM.
}
<h:form>
    <p:inputText value="#{someChatBacking.message}" required="true" />
    <p:selectOneMenu value="#{someChatBacking.recipientUser}" required="true">
        <f:selectItems value="#{someSocketObserver.onlineUsers}" var="user" itemLabel="#{user.name}" />
    </p:selectOneMenu>
    <p:commandButton value="send" action="#{someChatBacking.send}" />
</h:form>
@Named
@RequestScoped
public class SomeChatBacking {

    @Inject @Push
    private PushContext chat;

    private String message;
    private User recipientUser;

    public void send() {
        Set<Future<Void>> sent = chat.send(message, recipientUser.getId());

        // TIPS:
        // sent.size() represents amount of open web sockets at send time.
        // future.get() will return null if message is successfully delivered.
        // future.get() will throw ExecutionException if message delivery failed.
        // You can observe @Opened/@Closed websockets with CDI. See o:socket showcase/javadoc.
    }

    // ...
}

Example 3

The below conditionally connected view scoped socket example will receive the push message as result of a user-initiated asynchronous task only in the current view.

<h:form>
    <p:commandButton widgetVar="asyncCommand" value="submit"
        action="#{someTaskBacking.start}" onclick="startAsync()" />
</h:form>
<o:socket channel="async" scope="view" onmessage="asyncListener" connected="false" />
function startAsync() {
    PF("asyncCommand").disable();
    OmniFaces.Push.open("async");
}
function asyncListener(result) {
    OmniFaces.Push.close("async");
    console.log(result); // Do your thing with HTML DOM.
    PF("asyncCommand").enable();
}
@Named
@RequestScoped
public class SomeTaskBacking {

    @Inject
    private SomeTaskService service;

    @Inject @Push
    private PushContext async;

    public void start() {
        service.someLongRunningAsyncTask(result -> async.send(result));
    }

}
@Stateless
public class SomeTaskService {

    @Asynchronous
    public void someLongRunningAsyncTask(Consumer<Object> callback) {
        // ...
        // ... (some long process)
        // ...

        callback.accept(result);
    }

}

Complex UI updates

The only disadvantage from the JSF perspective is that the target JSF view state isn't anywhere available at the moment the push message is being sent. So, no partial rendering based on JSF view could be performed. That's why it isn't possible to perform complex UI updates as easily as with JSF ajax. If you're not that fluent with e.g. jQuery, then you could combine <o:socket> with <o:commandScript> which in turn uses ajax to obtain the desired rendering based on the message.

<o:socket ... onmessage="commandScriptName" />
<o:commandScript name="commandScriptName" action="#{bean.pushed}" render="result" />

If you pass a Map<String, V> or a JavaBean as push message object, then all entries/properties will transparently be available as request parameters in the command script action method.

Session/view expired

Oh, if you ever wanted to immediately warn the user about an expired session, then you could now use onclose of a session scoped <o:socket> for that. If it returns code 1000 while you didn't manually close the socket, then it means that session has been expired on the server side. How useful! This also works on view scoped sockets (but indicates "View expired" instead and should actually be a very rare case if it isn't caused by an expired session).

<o:socket ... scope="session" onclose="sessionCloseListener" />
function sessionCloseListener(code) {
    if (code == 1000) {
        alert("Session has expired! Page will be reloaded.");
        window.location.reload(true); // Or just go to some login/home page.
    }
}

For an elaborate documentation and several live demos, checkout the <o:socket> showcase.

Skip validators

Contributed by Michele Mariotti, <o:skipValidators> allows developers to entirely skip JSF validation (required, <f:validateXxx>, custom validators, etc) when executing a specific UICommand or ClientBehaviorHolder action.

For example, when adding a new row to the data table, you'd like to not immediately validate all empty rows.

<h:form>
    <h:dataTable value="#{bean.items}" var="item">
        <h:column>
            <h:inputText value="#{item.value}" required="true" />
        </h:column>
    </h:dataTable>
    <h:commandButton value="add new row" action="#{bean.add}">
        <o:skipValidators />
    </h:commandButton>
    <h:commandButton value="save all data" action="#{bean.save}" />
    <h:messages />
</h:form>

Under the covers, it will basically during PreValidateEvent remove and remember all validators and re-attach them during the PostValidateEvent.

Only converters will still run. Conversion failures can still be suppressed with help of the existing <o:ignoreValidationFailed>.

Further improved @ViewScoped

The OmniFaces CDI @ViewScoped annotation has been further improved to also physically destroy the associated JSF view state in session during an unload event. I.e. during an unload it does not only destroy view scoped beans in order to eagerly free memory, but it will now also immediately push out unused JSF view states from the session. This is important in order to prevent "older" JSF view states from expiring too soon as they are basically held in a LRU map.

A good use case is described in OmniFaces issue 208:

  1. Set number of physical views to 3 (in Mojarra, use com.sun.faces.numberOfLogicalViews context param and in MyFaces use org.apache.myfaces.NUMBER_OF_VIEWS_IN_SESSION context param).
  2. Create a page which immediately initializes an OmniFaces @ViewScoped bean during load, and has a form which submits to it. For clarity, add @PostConstruct and @PreDestroy methods which log a debug line.
  3. Open this page in a tab and keep it open all time.
  4. Open the same page in another tab and then immediately close this tab.
  5. Open the same page in another tab and then immediately close this tab.
  6. Open the same page in another tab and then immediately close this tab.
  7. Submit the form in the first tab.

Previously, this would have failed with ViewExpiredException, but since OmniFaces 2.3 not anymore. Currently, a fairly convoluted hack has to be used, as the way to explicitly destroy a physical view is implementation specific. Both MyFaces and Mojarra required a different way. Currently, it only works when partial state saving is kept turned on (as by default). Those who still use full state saving should better ask themselves if it's still fruitful to keep using full state saving in JSF 2.2. During JSF 2.0/2.1 there have been reasons to keep using full state saving, but all of them are workarounds for partial state saving bugs which have long been fixed in JSF 2.2.

While at it I noticed in MyFaces org.apache.myfaces.spi.ViewScopeProvider source code a TODO comment saying that something should be provided to cleanup the session when a view is discarded. This is exactly what we want! It would be even nicer if there was a standard API which explicitly destroys the view state. Perhaps a new removeState or destroyState method in ResponseStateManager. That would make things simpler, also for the JSF implementation itself.

Maven download stats

Here are the Maven download stats after previous release:

  • November 2015: 5906
  • December 2015: 6068
  • January 2016: 6135
  • February 2016: 6635
  • March 2016: 7265

Below is the version pie of March 2016: