Showing posts with label AI. Show all posts
Showing posts with label AI. Show all posts

Thursday, August 20, 2026

OmniHai gets motion

OmniHai 1.7 has been released! In 1.1 OmniHai grew ears and in 1.6 it got hands. In 1.7 it gets motion, in both directions: hand it a recording and ask what happens in it, or describe a scene and get one back. Relatively a lot of smaller things have been added around that, such as classification against your own labels, audio generation through OpenRouter, and a modality check which looks up what a model can do instead of guessing it from the name.

New: analyzeVideo()

How do you ask an AI when the goal happens in a match recording? Until now you had to cut the video into frames yourself and send those as images, which loses the timing and makes you pay per frame. OmniHai 1.7 sends the video itself:

String answer = service.analyzeVideo(Path.of("match.mp4"), "When does the goal happen?");

The video may also be passed as a byte[], and there is an async variant analyzeVideoAsync(), as with every other operation.

An AI does not watch a video the way you do. It samples frames from it, by default one per second, and reads those. On a long recording that is a lot of frames for a question about one minute of it. You can therefore state which part to sample and how densely, with AnalyzeVideoOptions:

String answer = service.analyzeVideo(Path.of("match.mp4"), "When does the goal happen?",
    AnalyzeVideoOptions.newBuilder()
        .fps(2)
        .startOffset(Duration.ofMinutes(1))
        .endOffset(Duration.ofMinutes(2))
        .build());

Video input is currently only accepted by Gemini (Google AI), by Muse Spark (Meta AI; note that Meta AI is now finally available for public whereas it was previously preview only) and by the video capable models which OpenRouter routes. The sampling options of AnalyzeVideoOptions are honored by Gemini only.

The other providers do not accept a video at all: OpenAI, Azure, Anthropic, xAI, Mistral and Ollama. This is not OmniHai holding something back. Their APIs simply do not support video analysis, so supportsModality() continues to answer false for VIDEO_ANALYSIS on those providers, so you can pick a service which can do it before you call.

Gemini does not take a video in the request itself. It must first be uploaded, and the AI provider then needs a moment to process it before it can be referred to, which on a larger file is longer than one round trip. OmniHai waits for that state to become active and only then asks the question, so you do not have to poll it yourself. A video which the provider rejects fails with an AIException stating why. OpenRouter takes the video as a plain data URI only and has nowhere to put them, so be careful with large videos.

New: generateVideo()

Making a video does not fit in one call. The AI provider answers within seconds with a job id and then takes minutes to actually produce the video, and it never calls you back. A method which just returns the video would sit and wait for those minutes, which you cannot do in a request. So generateVideo() hands you a handle on the job as soon as it is accepted:

VideoGeneration video = service.generateVideo("Sunrise over the colorful houses of Willemstad");
String jobId = video.jobId();

The handle is serializable and carries that job id, so you can submit in one request and look at it again in a later one, after a restart or on another node. Keep the id, and pick the job up again with findVideoGeneration():

VideoGeneration video = service.findVideoGeneration(jobId);

if (video.refresh().status() == VideoGeneration.Status.COMPLETED) {
    video.writeTo(Path.of("curacao.mp4"));
}

status() is a plain getter which does no I/O at all, so it costs you nothing to call it from a render pass. refresh() is the one which asks the AI provider, exactly one request per call, on your own schedule.

When you do want to wait, generateVideoAsync() does the polling for you and completes when the video is there:

CompletableFuture<VideoGeneration> pending = service.generateVideoAsync("Sunrise over the colorful houses of Willemstad");

pending.thenAccept(video -> video.writeTo(Path.of("curacao.mp4")));

Asking for a path writes the video there for you, asynchronously or not:

CompletableFuture<Void> written = service.generateVideoAsync("Sunrise over the colorful houses of Willemstad", Path.of("curacao.mp4"));

service.generateVideo("Sunrise over the colorful houses of Willemstad", Path.of("curacao.mp4"));

That last one blocks for as long as it takes, so it is for a batch job rather than for a request. The polling stops as soon as you stop watching, so a handle nobody looks at costs nothing. A job which never finishes fails after five minutes rather than keeping you there, which you can raise with GenerateVideoOptions, together with the aspect ratio, the resolution and the duration.

Video generation is offered by Google with Veo, by xAI with Grok Imagine, and by OpenRouter, which routes the generators of several labs. OpenAI and Azure both retire Sora without a successor to route to, so supportsModality() continues to answer false for VIDEO_GENERATION there, as it does on Anthropic, Mistral, Meta, Hugging Face and Ollama.

The generated video is hosted by the AI provider for about a day and is then deleted, upon which the status becomes EXPIRED. Some of them host it on a separate host and hand out a pre-signed URL for it; OmniHai downloads such a URL without your API key, which has no business leaving the endpoint it belongs to.

New: classify() and classifyAll()

Which queue does this support ticket belong in? You could ask that in a chat prompt, but then you have to parse whatever came back, and "This looks like a billing issue." is not a queue name. AIService now has a method for it:

ClassificationResult result = service.classify(ticket, "billing", "shipping", "technical");
route(result.label());

The labels you offer are the only values the AI may answer with. This uses structured outputs under the covers: a schema, which is a description of the exact shape the answer must have, is sent along with the question, and the AI provider holds the model to it. So label() is never anything else than one of your labels, and you can switch on it without a default branch for surprises. You do not write that schema yourself here; classify() derives it from the labels you pass in.

The AI must pick one even when nothing fits well, which is what confidence() is for. It runs from 0.0 to 1.0 and tells a certainty apart from a guess:

if (result.confidence() < 0.7) {
    queueForReview(ticket);
}

A ticket can of course be about two things at once. classifyAll() scores every label on its own merit and returns them with the best fitting one first:

List<ClassificationResult> results = service.classifyAll(ticket, "billing", "shipping", "technical");
List<String> tags = results.stream().filter(r -> r.confidence() > 0.5).map(ClassificationResult::label).toList();

The scores are not divided among the labels here, so several of them may score high, or none of them. A text which belongs to none of your labels scores low on all of them, which is the answer you want and which classify() cannot give you.

New: audio generation through OpenRouter

generateAudio() has been in OmniHai since 1.2, on OpenAI and Google. OpenRouter now joins them, by a different route. It does have an audio/speech endpoint, but that one serves a model catalog of its own which the model listing does not enumerate, so there is no telling which model to ask. Audio comes out of an audio capable chat model instead, which streams it back in chunks. OmniHai collects those and assembles a playable WAV file from it, so the call is the same one you already know:

service.generateAudio("Hello world", Path.of("hello.wav"));

The audio is written straight to the given path while it streams in, so a long text does not have to fit in memory first. Hugging Face still cannot do this; the text-to-speech models there are served by third parties with their own API rather than via Hugging Face's own API.

Modality lookup instead of name matching

A modality is a kind of input or output: text, image, audio, video. supportsModality() tells you whether a service can handle one, so you can check up front rather than by catching:

if (service.supportsModality(AIModality.VIDEO_ANALYSIS)) {
    return service.analyzeVideo(video, "Summarize this");
}

Most providers publish one model family per endpoint, so the answer follows from the predefined model names and versions. Aggregators do not; OpenRouter and Hugging Face route hundreds of models from many labs, and a name says nothing about them. Of the OpenRouter models which accept video, not one carries "video" in its name. Both of them do publish the input and output modalities per model, which OmniHai now looks up instead of guessing or even hardcoding.

That listing is fetched at most once a day per endpoint and shared by every service instance on it, so the first call blocks on one HTTP request and the rest are answered from memory. When it cannot be obtained, matching the model name is the fallback, and the last known listing is kept rather than cleared.

It stays a hint, not a guarantee. A published listing goes stale, a name matched guess is a guess, and a provider may still refuse a call it advertises, for this key, this region or this moment. The only guarantee is to make the call and handle its failure. The actual calls do namely not precheck the supportsModality().

Smaller things

analyzeImage() and generateAltText() now also accept a Path, as the video and audio methods already did.

ModerationOptions now also accepts a List of categories instead of only String... varargs.

Installation

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

Maven users: use the following coordinates.

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

Tuesday, August 11, 2026

OmniHai gets hands

OmniHai 1.6 is out!

Version 1.0 gave it structure, version 1.1 ears, 1.2 a voice, 1.3 let it browse, 1.4 taught it to count the cost, and 1.5 gave it a backbone. Version 1.6 gives it hands! The AI can now act as an agent and reach out to call your own methods before it answers, and it works on every supported provider.

What an agent is

Half the industry is currently selling you "agents". Strip the marketing and an agent is an AI with access to tools. That is all it is.

A "tool" is basically one of your own methods, plus a sentence in natural language which clearly says what it does. That sentence is all the AI gets. It never sees the method body, and it cannot run anything at all, because a model produces text and nothing else. A tool call is therefore just the AI writing down a name and some arguments, and your own code deciding what to do with that.

This makes the tool description the actual API. The AI picks a tool by reading the description, not by matching a type signature, so a vague description is a bug like any other. "Looks up a single order by id" gets picked when someone asks about order 42. "Handle orders" does not. So describe carefully in natural language.

Add a list of tools to the system prompt and ask a question. The AI either answers or it asks for a tool to be called. When it asks for a tool to be called, you call it and you hand back what it returned in natural language, and you repeat that until it answers. The AI never runs anything itself. It only asks, and your own code decides.

So there is no autonomy in it, and no intelligence beyond the model you already had. What you add is a list of methods and a loop. Once your AI can call your methods, you have an agent, and you can stop wondering whether you need a whole framework for it.

Tools

Annotate a method with @AITool, describe its parameters with @AIToolParam, and hand the object over. It's nicer if you have a dedicated CDI bean for this:

@ApplicationScoped
public class OrderTools {

    @Inject
    private OrderService orders;

    @ReadOnly
    @AITool("Looks up a single order by id")
    public String findOrder(@AIToolParam("The order id") long orderId) {
        return orders.findById(orderId).map(Order::toSummary).orElse("No order found with that id.");
    }

    @ReadOnly
    @AITool("Lists the orders placed by a customer")
    public String listOrders(@AIToolParam("The customer email") String email) {
        var summaries = orders.listByEmail(email).stream().map(Order::toSummary).collect(joining("\n"));
        return summaries.isEmpty() ? "No orders found for that email." : summaries;
    }

    @AITool("Refunds an order and pays the money back to the customer")
    public Refund refundOrder(@AIToolParam("The order id") long orderId) {
        return orders.refund(orderId);
    }

}
@Inject
@AI(apiKey = "#{keys.openai}", tools = OrderTools.class)
private AIService agent;

public String handle(String question) {
    return agent.chat(question);
}

Each turn the AI either names a tool or answers. A named tool is invoked with its arguments converted to the declared parameter types, the return value is fed back, and the next turn begins. A tool may return anything; the AI is handed its toString(), so you need to ensure that your tool returns something which reads as an answer in natural language or even JSON-y. A String or Record, or a properly implemented toString() does nicely, a bare com.example.Bean@hashcode does not.

There is no classpath scanning. Only the classes you explicitly pass to tools are scanned for methods annotated @AITool (and cached for performance). Exposing your own code to model output must to be a deliberate act.

@ReadOnly is explained later in "Grouping tools".

How it works

OmniHai does not use the provider's native function calling to register tools. It uses the same provider-enforced structured outputs which chat(message, DesiredOutput.class) already supported since 1.0. The response schema constrains the AI to either name one of your tools or answer, and the tool name is an enumeration of exactly the tools you registered.

Two things travel along with every request. The first one is the manifest: a plain text list of your tools, appended to the system prompt. The system prompt is the block of instructions which is sent ahead of your question on every call. For the OrderTools above it looks exactly like this:

Available tools:
- OrderTools_findOrder(orderId: The order id) -> Looks up a single order by id
- OrderTools_listOrders(email: The customer email) -> Lists the orders placed by a customer
- OrderTools_refundOrder(orderId: The order id) -> Refunds an order and pays the money back to the customer

Call exactly one tool per turn, or answer directly once you have enough information. Never invent data a tool did not return. There is nobody to ask for more information, so when a tool needs a value you do not have, obtain it from another tool first.

This is the entire documentation the AI gets. The tool name is derived from the class and the method, so findOrder() of OrderTools becomes OrderTools_findOrder, which keeps two classes with a findOrder() of their own apart. Every parameter shows up as its name plus your @AIToolParam description. Your @AITool description follows after the arrow. Nothing else of your class is exposed; not the return type, not the other methods, and certainly not the bodies.

The second one is the structured output. This is a JSON schema which the provider enforces on its own output, generated from the registry:

{
    "type": "object",
    "properties": {
        "tool": {
            "type": "string",
            "enum": ["OrderTools_findOrder", "OrderTools_listOrders", "OrderTools_refundOrder", "ANSWER"]
        },
        "arguments": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "name": { "type": "string" },
                    "value": { "type": "string" }
                },
                "required": ["name", "value"]
            }
        },
        "answer": { "type": "string" }
    },
    "required": ["tool", "arguments", "answer"]
}

The enum is the interesting part. It holds your three tools plus ANSWER, and nothing else. The AI cannot call a tool which is not in the registry, simply because there is no way for it to say so. That is a different thing than asking it nicely in the prompt: the name is not representable at all.

The arguments arrive as name/value pairs instead of as a free-form object. That is on purpose. The strict schema modes of most providers require every property of an object to be declared up front, and those properties differ per tool. A list of pairs is one single shape which fits every tool. OmniHai converts each value to the declared Java type of the parameter, so orderId arrives in your method as a long.

A turn is one request plus one response. Ask "Where is order 42?" and the first turn comes back like this:

{
    "tool": "OrderTools_findOrder",
    "arguments": [{ "name": "orderId", "value": "42" }],
    "answer": ""
}

OmniHai invokes the method and feeds the returned value back as the message of the next turn:

You called OrderTools_findOrder(orderId=42) and it returned: Order 42, in transit, shipped on 2026-08-03, tracking NL1234567890
Do not call it again with the same arguments. Answer the question now if this is enough, otherwise call another tool.

The second turn then comes back with ANSWER as tool and the prose in answer, and the loop ends.

{
    "tool": "ANSWER",
    "arguments": [],
    "answer": "Order 42 is still in transit. It shipped on 2026-08-03 and you can follow it with tracking code NL1234567890."
}

Two turns, two provider calls, one method invocation on your own thread.

This has one big advantage: there is no per-provider wire format to maintain. Tools behave identically on OpenAI, Anthropic, Google, xAI, Mistral, Meta, Azure, OpenRouter, Hugging Face and Ollama. The provider's own function calling API is never touched, so a provider which adds one tomorrow needs no work here.

Declaring tools programmatically

Outside CDI, or when the tools depend on the request rather than on the injection point, you can compose everything yourself and pass a concrete instance of the tools:

AIService ai = AIConfig.of(AIProvider.OPENAI, System.getenv("OPENAI_API_KEY")).createService();
AIService agent = ai.withTools(orderTools);

String answer = agent.chat(question);

Not every tool wants an annotation either. ToolRegistry is the programmatic counterpart, in the same way AIConfig is the programmatic counterpart of the @AI qualifier. A method reference cannot carry a name, a description or its parameter names, because those are erased, so you state them:

ToolRegistry tools = ToolRegistry.newBuilder()
    .add("FIND_ORDER_BY_ID", "Looks up a single order by id", orders::findById, ToolParam.of(long.class, "orderId", "The order id"))
    .add("LIST_OPEN_ORDERS", "Lists all open orders", orders::listOpen)
    .add(shippingTools)
    .build();

AIService agent = ai.withTools(tools);

A tool taking no arguments needs nothing beyond its description, so a plain method reference is enough. Annotated objects and lambdas mix freely in one registry.

Grouping tools

The object you hand over is already a group, and AIService#withTools() accepts varargs, so a class is a toolset. For subsets which cut across classes, such as read-only versus mutating, you declare your own tag with the @AIToolGroup meta-annotation:

@AIToolGroup
@Retention(RUNTIME)
@Target(METHOD)
public @interface ReadOnly {}
@Inject
@AI(apiKey = "#{keys.openai}", tools = OrderTools.class, toolGroup = ReadOnly.class)
private AIService tier1;

@Inject
@AI(apiKey = "#{keys.openai}", tools = { OrderTools.class, ShippingTools.class })
private AIService supervisor;

Programmatically that is ai.withTools(ReadOnly.class, orderTools) respectively ai.withTools(orderTools, shippingTools).

Narrowing applies to the generated schema, not to a check afterwards. The tier-1 agent cannot name refundOrder at all, because that token is not in the grammar the model decodes against. That is a stronger guarantee than a permission check which runs after the AI already asked.

Who is asking

Grouping decides which tools exist. It does not decide which rows they may hand back. The AI picks the arguments, and it picks them from everything it has read: your question, and every tool result before it. So orderId is not a value you chose, it is a value which arrived from outside, exactly like a request parameter. A lookup by id or by email which does not check who is asking lets any customer read any customer's orders, and the OrderTools above only reads well because OrderService is assumed to do that check.

Keep the check there and not in the tool. The tool is an adapter: it turns a name/value pair into a method call and a result into a sentence. The rule about who may see an order belongs to the thing which owns orders, or you write it again in every backing bean and every REST resource, and the tool is the one which gets forgotten.

@ApplicationScoped
public class OrderService {

    @Inject
    private SecurityContext security;

    @Inject
    private OrderRepository repository;

    public Optional<Order> findById(long orderId) {
        return repository.findById(orderId).filter(order -> mayRead(order.getCustomer().getEmail()));
    }

    public List<Order> listByEmail(String email) {
        return mayRead(email) ? repository.listByEmail(email) : emptyList();
    }

    @Transactional
    public Refund refund(long orderId) {
        if (!security.isCallerInRole("SUPPORT")) {
            throw new SecurityException("Refunding requires the SUPPORT role.");
        }
        return repository.refund(orderId);
    }

    private boolean mayRead(String email) {
        var caller = security.getCallerPrincipal();
        return security.isCallerInRole("SUPPORT") || (caller != null && (caller.getName().equalsIgnoreCase(email));
    }

}

Return nothing rather than throw. The tool then answers "No order found with that id.", which is true for this caller and does not confirm that the order exists for somebody else. The security context is in scope because the synchronous chat invokes the tool on your own thread, which is covered further below.

Dropping a parameter the caller has no business choosing is stronger still. A listMyOrders() which takes the email from the principal leaves nothing in the schema to aim at, in the same way that a tool outside the group leaves nothing in the enumeration to name. The email-taking variant then belongs in a support-only tool group.

Scopes and composition

@Inject
@AI(apiKey = "#{keys.openai}", tools = OrderTools.class, maxToolCalls = 4, maxAttempts = 3)
private AIService agent;

The tool classes named on the qualifier are resolved as CDI beans, which must be normal-scoped so that they observe their own scope and their own interceptors on every call. A @Dependent one is rejected at injection time instead of being silently pinned to the lifecycle of the injection point. Handing withTools an object yourself as in AIService agent = ai.withTools(orderTools) resolves nothing; that object is used as it is, and whether it is a bean at all is your own business.

The maxAttempts attribute is new too. It composes the in 1.5 introduced RetryingAIService around the produced service, and tool calling is composed around that, so a retry re-attempts a single provider call rather than replaying the whole loop and every side effect it already caused.

Bounding the loop

The tool call cap bounds latency and spend. It defaults to five, after which the AI must answer, so a conversation takes at most six provider calls.

The turn after the last permitted call is offered no tool at all. Its schema enumerates only the answer, so a model which keeps reaching for tools is denied the tokens to name one. Asking it politely to stop is not enough, as a weaker model simply ignores prose. AIToolIterationException is what is left as the backstop for a provider which does not enforce the schema. Asking for a type forces the typed answer at the cap instead of throwing, as that call carries your own schema and offers no tool to begin with.

An observer receives every tool call after it ran, which is your audit point for logging and metrics:

AIService agent = new ToolCallingAIService(ai, ToolRegistry.of(orderTools), 4, invocation -> {
    if (invocation.hasFailed()) {
        logger.log(WARNING, () -> "Tool " + invocation.toolName() + " failed");
    }
});

A tool which throws, or whose arguments cannot be converted, is reported back to the AI rather than aborting the call, so it can correct itself and try again. This self-correction is the main thing the loop buys you over a single call. What the AI is told is deliberately bounded: an argument it got wrong is quoted back verbatim, while an exception thrown by the tool itself is reduced to a generic line and logged. A stack trace, a SQL error or a constraint message must never reach the AI and be repeated to a user. Your observer still receives the real exception. An Error is not reported back at all but rethrown, because it is not something the AI can work around.

The synchronous chat methods invoke the tools on the calling thread, so they observe its transaction, scope and security context. The asynchronous ones invoke them on whichever thread completes the provider call, where none of that applies. Use the synchronous ones for tools which touch a database or a scoped bean.

Limits

Tool use and a typed result each want the one response schema a provider call carries, so they take a turn each. The loop runs on its own schema, and the turn which would have answered is asked again for your type instead, with every tool result still in front of it:

public record Delivery(String carrier, LocalDate estimated) {}

Delivery delivery = agent.chat("Where is order 42?", Delivery.class);

That costs one extra call, and only when you ask for a type: five tool calls take six provider calls, and seven when the conversation has memory, as the answer it records is then a call of its own. Streaming cannot be combined with tools at all, because the tool which the AI picks is only known once its reply is complete, so chatStream on a tool calling service throws UnsupportedOperationException rather than quietly answering without the tools.

Further, the AI calls one tool per turn rather than several at once, there is no way to force a specific tool, and arguments are converted from strings rather than typed per tool on the wire. Those three need the provider's native function calling, which stays out of scope for now.

Installation

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

Maven users:

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

@AITool was introduced in 1.6 and it works fine, but 1.6.1 has further improved the tool naming so that it has better support for duplicate/overloaded methods.

Minimum requirements are unchanged: Java 17 and Jakarta EE 11 or MicroProfile 7. JSON-P is the only required dependency, and CDI, EL and MP Config remain optional.

Wednesday, July 15, 2026

OmniHai grows a backbone

OmniHai 1.5 is out! After 1.1 gave the library ears, 1.2 a voice, 1.3 the ability to step outside and browse the web, and 1.4 taught it to count the cost, 1.5 gives it a backbone.

Talking to a remote AI provider means talking to something that occasionally says no. Rate limits, a provider that is briefly down, a connection that drops halfway. None of that is your fault, and none of it should reach your users as a stack trace. Until now you had to manually wrap your own retry loop around every call. OmniHai 1.5 ships two ready-to-use resilience decorators so you never have to write that loop anymore.

Retry

Wrap any service in RetryingAIService and transient failures are retried for you. It triggers on an HTTP 429 rate limit, an HTTP 503 unavailable, and transient I/O, with exponential backoff and full jitter between attempts.

AIService resilient = new RetryingAIService(service); // 3 attempts, sensible defaults

That is the whole change. The wrapped service keeps its exact API, so every caller downstream stays the same. Need other numbers? Reach for the builder.

AIService tuned = RetryingAIService.newBuilder(service)
    .maxAttempts(5)
    .initialBackoff(Duration.ofSeconds(1))
    .maxBackoff(Duration.ofSeconds(20))
    .maxDuration(Duration.ofMinutes(1))
    .build();

It never retries a deterministic error. A bad request or an authentication failure would fail the same way on the second attempt, so retrying it only wastes time and tokens. You can override the condition with retryOn(...) when your case is different.

Failover

Retrying the same provider helps when the provider is merely busy. It does not help when the provider is down. For that there is FailoverAIService, which tries a primary service and then falls back to alternates in order, on those same transient failures.

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

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

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

When OpenAI is rate limiting you, the call quietly lands on Anthropic instead. Your code asked one question and got one answer; which provider answered it is an operational detail, not an application concern.

Composing them

Both are pure decorators built on InterceptingAIServiceWrapper, so they wrap the entire service surface: chat, image, audio, moderation, synchronous and asynchronous alike. And because they are decorators, they compose. Retry each provider a few times before giving up on it and failing over to the next.

AIService resilient = new FailoverAIService(
    new RetryingAIService(gpt),
    new RetryingAIService(claude));

No CDI magic, no configuration file, no framework to buy into. Just constructors that take an AIService and return an AIService. Stack them in whatever order your situation asks for.

Streaming and partial results

Retrying a plain chat call is easy; you just call it again. Retrying a streaming call is not, because the first attempt may already have handed a dozen tokens to your consumer. A blind second attempt would replay the stream from the start and leave the consumer with a duplicated prefix. Rather than corrupt your output in silence, the decorator throws an AIStreamAbortedException, with the original failure as its cause. This exception is terminal and is never retried nor failed over.

If you do want a partially consumed stream re-attempted, hand in a ResettableConsumer as your token consumer. It carries a second handler next to the token handler, invoked right before each new attempt, so it can discard what it accumulated and let the retry start from a clean slate.

var response = new StringBuilder();
AIService resilient = new RetryingAIService(service);

resilient.chatStream("Explain the Jakarta EE security model.", ResettableConsumer.of(
    token -> response.append(token),           // append every token as it streams in
    (cause, attempt) -> response.setLength(0)  // a retry is starting; drop the partial output
));

The token handler is your normal streaming consumer. The reset handler receives the failure that triggered the re-attempt and the number of the attempt about to start, and clears the buffer so the fresh stream does not stack on top of the old one.

Refreshed default models

As always the per-provider default models have moved forward to the current generation.

ProviderDefault model
OpenAIgpt-5.6-terra
Anthropicclaude-sonnet-5
Google AIgemini-3.5-flash
xAIgrok-4.5
Mistral AImistral-medium-3-5
Meta AImuse-spark-1.1
Azure OpenAIgpt-5.5

Meta moved the most. It retired the old Llama endpoint and now serves Muse Spark through the OpenAI-compatible Meta Model API at https://api.meta.ai/v1. OmniHai follows: the default is now muse-spark-1.1, and the provider rides the shared OpenAI text handler instead of its own. If you pinned the old Llama-4-Maverick-17B-128E-Instruct-FP8 model or the api.llama.com endpoint, update those.

This generation also changed the rules. The newest Claude and Fable models (Opus 4.7 and up, Sonnet 5 and up, Fable 5 and up) dropped the classic sampling knobs; send them a temperature or a legacy thinking budget and they answer with an HTTP 400. OmniHai now knows this through supportsSamplingParameters() and simply omits those fields for such models, steering them with the ReasoningEffort from 1.4 instead. Older models keep the legacy behavior. You set your options the same way you always did; OmniHai sends whatever the target model still accepts.

Getting 1.5

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

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

OmniHai still needs only Java 17 and Jakarta EE 11 or MicroProfile 7, with JSON-P required and CDI, EL and MP Config optional. No new dependencies; the resilience decorators are plain Java.

Give it a try

Wrap your existing service in a RetryingAIService, or chain a couple of providers behind a FailoverAIService, and watch the transient failures stop reaching your users. As always, feedback and contributions are welcome on GitHub. If you run into anything, open an issue. Pull requests are welcome too. :)

Monday, April 20, 2026

OmniHai counts the cost

OmniHai 1.4 is out! After 1.1 gave the library ears, 1.2 a voice, and 1.3 the ability to step outside and browse the web, 1.4 teaches it to count. Token usage becomes actual money, runaway spend can be capped, reasoning effort is now dial-able across providers, and ChatOptions knows how to serialize itself to portable JSON.

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

Cost Calculation

1.3 introduced ChatUsage so you could see how many tokens a call consumed. Useful, but tokens are not what the invoice at the end of the month is denominated in. 1.4 closes that gap with ChatPricing and ChatCost.

Attach a pricing to your ChatOptions, make a call, read back the cost:

ChatPricing pricing = new ChatPricing(
    new BigDecimal("3.00"),       // input price per 1M tokens
    new BigDecimal("0.30"),       // cached-input price per 1M tokens (optional)
    new BigDecimal("15.00"),      // output price per 1M tokens (includes reasoning)
    Currency.getInstance("USD")); // optional; purely for presentation.

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

String response = service.chat("Explain quantum computing", options);

ChatCost cost = options.getLastCost();
System.out.println("Input cost:        " + cost.inputCost());
System.out.println("Cached input cost: " + cost.cachedInputCost());
System.out.println("Output cost:       " + cost.outputCost());
System.out.println("Total cost:        " + cost.totalCost() + " " + cost.currency());

Prices are expressed per one million tokens to match how providers publish their rate sheets. There are deliberately no built-in rate presets; provider rates drift and differ per model tier, so you look up the current numbers for your chosen model and pass them in. The optional currency is passed through to ChatCost for display; it does not affect any arithmetic, so use whatever unit you supplied the prices in.

The cachedInputTokenPrice is optional. When null, cached tokens are billed at the regular input rate. Set it explicitly to reflect the provider's cache-read discount (Anthropic charges roughly 10% of the input rate for cache reads, OpenAI and Google roughly 25%). Reasoning tokens are always billed at the output rate, consistent with how providers invoice them.

If you want the full positional constructor to be a bit less ceremonial, there are two factory methods:

ChatPricing simple = ChatPricing.of(new BigDecimal("3.00"), new BigDecimal("15.00"));
ChatPricing withCache = ChatPricing.of(new BigDecimal("3.00"), new BigDecimal("0.30"), new BigDecimal("15.00"));

And if you have a ChatUsage in hand and want the cost ad-hoc without configuring options at all:

ChatCost cost = usage.calculateCost(pricing);

One caveat worth mentioning up front: this is a simplified three-tier scheme (base input, cached input, output) that covers the common case. Provider-specific billing axes like Anthropic's 5-minute and 1-hour cache-write premiums are not modeled and may cause under-counting for workloads that rely heavily on explicit prompt caching. For strict accuracy, reconcile against the provider's own billing API. For "roughly what did that call cost me" it is good enough.

Budget Cap

Cost visibility is nice. Cost protection is nicer. 1.4 also lets you attach a cumulative-cost ceiling alongside the pricing so runaway spend on a given ChatOptions instance gets stopped rather than logged after the fact:

ChatOptions options = ChatOptions.newBuilder()
    .pricing(pricing, new BigDecimal("1.00")) // hard stop at $1.00
    .build();

while (hasMoreWork()) {
    try {
        service.chat(next(), options);
    } catch (AIBudgetExceededException e) {
        log.warn("Spent {} of {} {} — stopping", e.getTotalCost(), e.getMaxTotalCost(), e.getCurrency());
        break;
    }
}

The cap is checked before each call using the accumulated ChatOptions.getTotalCost(). It is a soft ceiling: the call that pushes the running total at or over the cap still completes and is billed; the next call is refused with AIBudgetExceededException. That keeps the behavior predictable; the alternative of estimating an upcoming call's cost before dispatching it would require knowing the output token count in advance, which of course you don't.

After you have caught the exception, you can call options.resetBudget() to zero the counter and start a fresh window on the same instance, or switch to a different ChatOptions instance, or even fail over to a different AIService (e.g. a cheaper model) to continue processing.

Cached Input Tokens

While we are on the subject of prompt caches, ChatUsage has gained a fourth field: cachedInputTokens().

ChatUsage usage = options.getLastUsage();
System.out.println("Input tokens:         " + usage.inputTokens());
System.out.println("Cached input tokens:  " + usage.cachedInputTokens()); // subset of inputTokens
System.out.println("Output tokens:        " + usage.outputTokens());
System.out.println("Reasoning tokens:     " + usage.reasoningTokens());   // subset of outputTokens
System.out.println("Total tokens:         " + usage.totalTokens());

It reports the subset of input tokens that was served from the provider's prompt cache. This is the number that drives the cheaper cachedInputCost on ChatCost, and it is useful on its own too; a low cache-hit ratio on a workload that should mostly be reused content is a good signal that your system prompts are drifting or the provider's cache TTL has elapsed. As with the other fields, a value of -1 means the provider did not report it.

Reasoning Effort

Modern frontier models (GPT-5, Claude extended thinking, Gemini thinking, Grok reasoning) all let you tune how many tokens they should spend on internal reasoning before answering. The knobs are called different things across providers; in OmniHai they live behind a single enum:

ChatOptions options = ChatOptions.newBuilder()
    .reasoningEffort(ReasoningEffort.HIGH)
    .build();

String answer = service.chat("Prove the Pythagorean theorem.", options);

The available levels are AUTO (the default, defers to the provider's own default), NONE (actively disable reasoning where supported, for minimum cost and latency), LOW (~20% of budget), MEDIUM (~50% of budget), HIGH (~80% of budget), and XHIGH (~95% of budget). Providers that do not support a given level map to the closest equivalent, so you can leave the same ChatOptions in place while switching the underlying provider.

Higher levels typically improve answer quality on hard problems (math, multi-step planning, non-trivial code) at the cost of more tokens and latency. On trivial prompts they just spend money without any measurable upside, so do not set HIGH or XHIGH as the default for all your calls :) Keep in mind that a higher effort may also require a correspondingly higher maxTokens to avoid truncated responses.

Portable JSON for ChatOptions

ChatOptions has been Serializable since day one, which is enough to stash it in an HTTP session. For portable storage, REST payloads, JSON columns, audit logs, or cross-service transport, Java serialization is not what you want. 1.4 adds an explicit JSON form:

String json = options.toJson();
ChatOptions restored = ChatOptions.fromJson(json);

All user-facing settings are included: system prompt, JSON schema, temperature, maxTokens, reasoning effort, topP, web search location, pricing, maxTotalCost, maxHistory, and the full conversation history (including any recorded uploaded file references). Null or unset fields are omitted for a compact payload. Runtime state, the last usage and the cumulative total cost, is deliberately not serialized; a restored instance starts with a fresh zero total cost counter.

Round-tripping a shared default constant (DEFAULT, CREATIVE, DETERMINISTIC) yields a mutable copy, equivalent to calling copy(). That way you do never accidentally end up with a restored instance that still rejects mutations because it was derived from an immutable template.

Default Models

Under the hood, default model identifiers per provider have been refreshed to match the current state of technology. The exact identifiers are documented on the GitHub README. If you were relying on the provider default, you get the newer model automatically on upgrade; if you were pinning a specific model, nothing changes for you.

Getting 1.4

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

Maven users:

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

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.

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, 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!