Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions ai/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# AI Plugin for Apache OFBiz

LangChain4j integration that exposes AI/LLM capabilities as standard OFBiz services.

Apache JIRA: https://issues.apache.org/jira/browse/OFBIZ-13408

## What this plugin does

The AI plugin connects OFBiz to any OpenAI-compatible chat model via LangChain4j 1.8.0.
It provides two callable OFBiz services — `aiGenerate` for free-text responses and
`aiGenerateStructured` for JSON-schema-constrained structured output — that any other
service or Groovy script can call without depending on LangChain4j directly.

## Architecture

| Layer | Class | Role |
|---|---|---|
| Container | `AiContainer` | Reads `ai.properties` at startup, builds the `ChatModel`, calls `AiFactory.setChatModel()` |
| Singleton | `AiFactory` | Holds the live `ChatModel` instance; throws `IllegalStateException` if not initialized |
| Utility | `AiWorker` | Static `generate()` and `generateStructured()` methods; handles message conversion and JSON schema mapping |
| Services | `AiServices` | Thin OFBiz service wrappers that delegate to `AiWorker` and return standard service result maps |

## Prerequisites

- Java 17 or later
- OFBiz trunk
- An API key from OpenAI or a compatible provider (Ollama, Groq, Together, Azure OpenAI)

## Installation

1. The plugin is already placed at `plugins/ai/` inside the OFBiz source tree.
2. Copy the properties template and fill in your values:
```
cp plugins/ai/config/ai.properties.template plugins/ai/config/ai.properties
```
If no template exists, create `plugins/ai/config/ai.properties` from the table below.
3. Start OFBiz normally. The container will log:
```
AI plugin initialized: provider=openai model=gpt-4o-mini
```
If the API key is missing or placeholder, startup continues but the plugin logs an error and skips initialization.

## Configuration

Edit `plugins/ai/config/ai.properties` (this file is gitignored — never commit API keys).

| Property | Description | Default |
|---|---|---|
| `ai.provider` | Provider name. Currently used for logging; the `openai` engine handles all OpenAI-compatible endpoints. | `openai` |
| `ai.model` | Model name passed to the provider. | `gpt-4o-mini` |
| `ai.baseUrl` | Base URL override. Leave empty for the OpenAI default. Set for local or third-party endpoints. | _(empty)_ |
| `ai.apiKey` | Your API key. **Required.** | _(none)_ |
| `ai.timeout` | Request timeout in seconds. | `60` |

## Multiple providers

Setting `ai.baseUrl` makes the plugin work with any OpenAI-compatible endpoint:

| Provider | `ai.baseUrl` |
|---|---|
| OpenAI (default) | _(leave empty)_ |
| Ollama | `http://localhost:11434` |
| Groq | `https://api.groq.com/openai/v1` |
| Together AI | `https://api.together.xyz/v1` |
| Azure OpenAI | your Azure endpoint URL |

## Usage

### Generate free-text (from a Groovy service)

```groovy
import org.apache.ofbiz.ai.AiWorker

def messages = [
[role: "system", content: "You are a helpful assistant."],
[role: "user", content: "Summarize this order in one sentence."]
]

String response = AiWorker.generate(dctx, messages)
```

### Generate structured output

Schema values can be a plain type string (`"string"`, `"number"`, `"integer"`, `"boolean"`,
`"array"`, `"object"`) or a map with `type` and optional `properties`/`items` for nested shapes.

```groovy
import org.apache.ofbiz.ai.AiWorker

def messages = [
[role: "user", content: "Extract the product name and price from: 'Widget Pro costs \$49.99'"]
]

def schema = [
productName: "string",
price: "number"
]

Map result = AiWorker.generateStructured(dctx, messages, schema)
// result == [productName: "Widget Pro", price: 49.99]
```

Both methods throw `GeneralException` on failure; callers should catch it and return
`ServiceUtil.returnError()` as appropriate.

## Available services

| Service | IN | OUT | Description |
|---|---|---|---|
| `aiGenerate` | `messages` (List, required)<br>`configName` (String, optional) | `response` (String) | Free-text generation |
| `aiGenerateStructured` | `messages` (List, required)<br>`schema` (Map, required)<br>`configName` (String, optional) | `result` (Map) | Structured JSON output |

Each message in the `messages` list is a `Map` with keys `role` (`system`, `user`, or `assistant`) and `content` (String).

## Smoke test

With OFBiz running and a valid API key configured, invoke `aiSmokeTest` from the
webtools service runner:

```
https://localhost:8443/webtools/control/main
→ Service Engine → Run Service → aiSmokeTest
```

A successful run logs:
```
AI smoke test response: Hello
```

## Adding new providers

To support a provider that is not OpenAI-compatible (e.g., Anthropic native, Amazon Bedrock),
add a new `case` to the `switch (provider)` block in `AiContainer.java`. Instantiate the
provider's LangChain4j `ChatModel` builder, call `AiFactory.setChatModel(chatModel)`, and
add the corresponding `dev.langchain4j:langchain4j-<provider>` dependency to `build.gradle`.
No changes to `AiFactory`, `AiWorker`, or `AiServices` are needed.
25 changes: 25 additions & 0 deletions ai/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

dependencies {
pluginLibsCompile 'dev.langchain4j:langchain4j:1.8.0'
pluginLibsCompile 'dev.langchain4j:langchain4j-anthropic:1.8.0'
pluginLibsCompile 'dev.langchain4j:langchain4j-ollama:1.8.0'
pluginLibsCompile 'dev.langchain4j:langchain4j-open-ai:1.8.0'
}
38 changes: 38 additions & 0 deletions ai/config/ai.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# AI Plugin configuration
# This file is gitignored — never commit API keys.
#
# Supported providers: openai | anthropic | ollama
# To switch provider: uncomment the desired block, comment out the others.

# -------------------------------------------------
# Provider: openai
# Models: gpt-4o, gpt-4o-mini, gpt-4-turbo, o1-mini
# Docs: https://platform.openai.com/docs/models
# -------------------------------------------------
ai.provider=openai
ai.model=gpt-4o-mini
ai.apiKey=REPLACE_WITH_YOUR_API_KEY
ai.baseUrl=
ai.timeout=60

# -------------------------------------------------
# Provider: anthropic
# Models: claude-opus-4-5, claude-sonnet-4-5, claude-3-5-haiku-20241022
# Docs: https://docs.anthropic.com/en/docs/about-claude/models
# -------------------------------------------------
#ai.provider=anthropic
#ai.model=claude-3-5-haiku-20241022
#ai.apiKey=REPLACE_WITH_YOUR_API_KEY
#ai.baseUrl=
#ai.timeout=60

# -------------------------------------------------
# Provider: ollama (local, no API key required)
# Models: llama3.2, llama3.1, mistral, gemma3
# Docs: https://ollama.com/library
# -------------------------------------------------
#ai.provider=ollama
#ai.model=llama3.2
#ai.apiKey=
#ai.baseUrl=http://localhost:11434
#ai.timeout=120
25 changes: 25 additions & 0 deletions ai/groovyScripts/AiStructuredTest.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import org.apache.ofbiz.base.util.Debug
import org.apache.ofbiz.ai.AiWorker

final String MODULE = 'AiStructuredTest.groovy'

def messages = [
[role: "user", content: "Return a greeting with a single word."]
]

def schema = [
word: "string"
]

try {
Map result = AiWorker.generateStructured(dctx, messages, schema)
if (!result || !result.containsKey("word")) {
Debug.logError("AI structured smoke test failed: response missing 'word' key. Got: " + result, MODULE)
return error("AI structured smoke test failed: missing 'word' key in response")
}
Debug.logInfo("AI structured smoke test response: " + result, MODULE)
return success("AI structured smoke test passed: " + result)
} catch (Exception e) {
Debug.logError(e, "AI structured smoke test failed", MODULE)
return error("AI structured smoke test failed: " + e.getMessage())
}
17 changes: 17 additions & 0 deletions ai/groovyScripts/AiTest.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import org.apache.ofbiz.base.util.Debug
import org.apache.ofbiz.ai.AiWorker

final String MODULE = 'AiTest.groovy'

def messages = [
[role: "user", content: "Say hello in one word."]
]

try {
String response = AiWorker.generate(dctx, messages)
Debug.logInfo("AI smoke test response: " + response, MODULE)
return success("AI smoke test passed: " + response)
} catch (Exception e) {
Debug.logError(e, "AI smoke test failed", MODULE)
return error("AI smoke test failed: " + e.getMessage())
}
32 changes: 32 additions & 0 deletions ai/ofbiz-component.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->

<ofbiz-component name="ai" enabled="true"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://ofbiz.apache.org/dtds/ofbiz-component.xsd">
<resource-loader name="main" type="component"/>

<classpath type="dir" location="config"/>

<service-resource type="model" loader="main" location="servicedef/services.xml"/>

<container name="ai-container" loaders="main" class="org.apache.ofbiz.ai.container.AiContainer"/>

</ofbiz-component>
51 changes: 51 additions & 0 deletions ai/servicedef/services.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<services xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation=
"https://ofbiz.apache.org/dtds/services.xsd">

<service name="aiGenerate" engine="java"
location="org.apache.ofbiz.ai.AiServices" invoke="generate">
<description>Generate a text response from an AI model</description>
<attribute name="messages" mode="IN" type="List" optional="false"/>
<attribute name="configName" mode="IN" type="String" optional="true"/>
<attribute name="response" mode="OUT" type="String" optional="false"/>
</service>

<service name="aiGenerateStructured" engine="java"
location="org.apache.ofbiz.ai.AiServices" invoke="generateStructured">
<description>Generate a structured Map response from an AI model</description>
<attribute name="messages" mode="IN" type="List" optional="false"/>
<attribute name="schema" mode="IN" type="Map" optional="false"/>
<attribute name="configName" mode="IN" type="String" optional="true"/>
<attribute name="result" mode="OUT" type="Map" optional="false"/>
</service>

<service name="aiSmokeTest" engine="groovy"
location="component://ai/groovyScripts/AiTest.groovy"
invoke="">
<description>Smoke test for the AI plugin — calls aiGenerate with a test message</description>
</service>

<service name="aiSmokeTestStructured" engine="groovy"
location="component://ai/groovyScripts/AiStructuredTest.groovy"
invoke="">
<description>Smoke test for generateStructured — expects Map with word key</description>
</service>

</services>
60 changes: 60 additions & 0 deletions ai/src/main/java/org/apache/ofbiz/ai/AiServices.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*******************************************************************************
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*******************************************************************************/
package org.apache.ofbiz.ai;

import java.util.List;
import java.util.Map;

import org.apache.ofbiz.base.util.Debug;
import org.apache.ofbiz.base.util.GeneralException;
import org.apache.ofbiz.base.util.UtilGenerics;
import org.apache.ofbiz.service.DispatchContext;
import org.apache.ofbiz.service.ServiceUtil;

public class AiServices {

private static final String MODULE = AiServices.class.getName();

public static Map<String, Object> generate(DispatchContext dctx, Map<String, Object> context) {
List<Map<String, Object>> messages = UtilGenerics.cast(context.get("messages"));
try {
String response = AiWorker.generate(dctx, messages);
Map<String, Object> result = ServiceUtil.returnSuccess();
result.put("response", response);
return result;
} catch (GeneralException e) {
Debug.logError(e, e.getMessage(), MODULE);
return ServiceUtil.returnError(e.getMessage());
}
}

public static Map<String, Object> generateStructured(DispatchContext dctx, Map<String, Object> context) {
List<Map<String, Object>> messages = UtilGenerics.cast(context.get("messages"));
Map<String, Object> schema = UtilGenerics.cast(context.get("schema"));
try {
Map<String, Object> aiResult = AiWorker.generateStructured(dctx, messages, schema);
Map<String, Object> result = ServiceUtil.returnSuccess();
result.put("result", aiResult);
return result;
} catch (GeneralException e) {
Debug.logError(e, e.getMessage(), MODULE);
return ServiceUtil.returnError(e.getMessage());
}
}
}
Loading