Custom AI Providers¶
A custom AI provider is a Java plugin that lets 3forge Enterprise reach a model backend the built-in providers do not cover.
Overview¶
The built-in AI providers (anthropic, openai, gemini, xai) cover the major hosted model APIs. When you need to reach an internal LLM gateway, a locally-hosted model, or a bespoke wire format, you can write your own provider and register it exactly like a built-in.
A custom provider makes that a first-class option in the Ask AI chatbot and in session.promptAi.
Java Interface¶
Implement com.f1.ami.amicommon.ai.AmiAiProviderPlugin. It extends Enterprise's AmiPlugin, so the plugin framework handles discovery and instantiation.
Note
The interface and every request/response type it deals in (AmiAiRequest, AmiAiResponse, AmiAiMessage, AmiAiStreamHandler, AmiAiCancelToken, …) all live in the shared amicommon module. A provider jar can be built against amicommon alone, with no other Enterprise internals needed on the compile path.
Key Points¶
getPluginId()is the identity. It must equal the id used inami.web.ai.providersand in theami.web.ai.<id>.*property namespace. Startup fails if they disagree.- No API key is required. Registration comes purely from listing the provider (see below). Your
init()reads whatever configuration it needs; a gateway that authenticates by URL or network position simply never reads anapi_key. - Configuration is read in
init()from absoluteami.web.ai.<id>.*keys via the suppliedPropertyController. completeandstreamare the only calls made.streambacks the chat window;completebacks one-shot calls such assession.promptAi. Implement both.
Request and Response Types¶
Everything a provider deals in lives in com.f1.ami.amicommon.ai. All of these types are plain immutable value objects (constructor + getters). A provider's job is to translate between them and its backend's wire format.
The Request: AmiAiRequest¶
Passed to both complete and stream. It carries the full state the model needs for one call:
| Field | Type | Meaning |
|---|---|---|
getModel() |
String |
Model id to call (already resolved from the provider's model list) |
getSystemPrompt() |
String |
System prompt. Sent as the provider's system field, not as a message |
getMessages() |
List<AmiAiMessage> |
The conversation so far, oldest first |
getTools() |
List<AmiAiToolDefinition> |
Tools the model may call. Empty for a plain completion (e.g: session.promptAi) |
getMaxTokens() |
int |
Upper bound on tokens to generate for this call |
AmiAiMessage: A Conversation Turn¶
Roles are "user" and "assistant". A message is one of 3 shapes (check with isToolUseTurn() / isToolResultTurn()):
| Variant | How to build | Carries |
|---|---|---|
| Text turn | new AmiAiMessage(role, content) |
getContent() |
| Assistant tool-use turn | AmiAiMessage.toolUseTurn(list) |
getToolUses() |
| User tool-result turn | AmiAiMessage.toolResultTurn(list) |
getToolResults() |
Map these onto your backend's message model: text turns become normal user/assistant messages, tool-use turns become the assistant's function-call blocks, and tool-result turns become the function-result blocks fed back on the next call.
AmiAiToolDefinition: A Tool the Model May Call¶
| Field | Type | Meaning |
|---|---|---|
getName() |
String |
Tool name |
getDescription() |
String |
What the tool does (shown to the model) |
getInputSchema() |
Map<String,Object> |
JSON Schema object describing the tool's parameters |
Translate each into your backend's tool/function-declaration format. If your backend does not support tools, you can ignore this list, but then the agentic chatbot cannot call tools through your provider.
The Synchronous Result: AmiAiResponse¶
What complete(...) must return:
| Field | Type | Meaning |
|---|---|---|
getStopReason() |
AmiAiStopReason |
Why generation stopped (see below) |
getText() |
String |
The assistant's text output (may be empty if it only called tools) |
getToolUses() |
List<AmiAiToolUse> |
Tool calls the model requested (empty if none) |
getInputTokens() |
int |
Prompt tokens billed |
getOutputTokens() |
int |
Completion tokens billed |
The Streaming Contract: AmiAiStreamHandler¶
What stream(...) drives. As data arrives from the backend, call these callbacks on the calling I/O thread:
| Callback | When to call it |
|---|---|
onToken(String delta) |
Each text delta as it streams in |
onToolUse(AmiAiToolUse toolUse) |
Once per complete tool-use block the model emits |
onComplete(AmiAiStopReason reason, AmiAiTokenStats stats) |
Exactly once, when the turn finishes successfully |
onError(Exception e) |
If the API call fails or the stream breaks |
The contract: stream any number of onToken / onToolUse calls, then terminate the turn with exactly one onComplete or one onError, never both, and never neither. stats may be AmiAiTokenStats.ZERO if your backend does not report usage.
AmiAiToolUse and AmiAiToolResult¶
A tool call requested by the model, and the result you feed back next turn:
AmiAiToolUse |
AmiAiToolResult |
|||
|---|---|---|---|---|
getId() |
call id | getToolUseId() |
id of the matching tool-use | |
getName() |
tool name | getToolName() |
tool name (Gemini needs it) | |
getInput() |
Map<String,Object> args |
getContent() |
result text | |
isError() |
whether it is an error result |
AmiAiStopReason¶
An enum: TOOL_USE (model wants to call tools), END_TURN (complete response), MAX_TOKENS (hit the limit), ERROR, CANCELLED (aborted mid-stream), MAX_THINK. A provider normally returns TOOL_USE, END_TURN, or MAX_TOKENS.
AmiAiTokenStats¶
Per-call token usage reported on onComplete. The 4 fields are disjoint and sum to the billable total:
| Field | Meaning |
|---|---|
getInputTokens() |
Non-cached prompt tokens |
getOutputTokens() |
Completion tokens |
getCacheReadTokens() |
Prompt tokens served from cache |
getCacheWriteTokens() |
Prompt tokens written to cache (Anthropic only; others 0) |
Subtract any cached portion from the raw input count before populating this, so the fields stay disjoint. Use AmiAiTokenStats.ZERO when usage is unavailable.
Cancellation: AmiAiCancelToken¶
The cancellable stream(request, handler, cancelToken) overload receives a token so the chat's stop button can abort a live stream. As soon as you open the HTTP connection, register it:
Another thread calling cancelToken.cancel() then disconnects the socket, which unblocks your stream read at EOF. Check cancelToken.isCancelled() to distinguish an intentional cancel from a real error (suppress the expected read failure and finish with onComplete(AmiAiStopReason.CANCELLED, stats)).
Streaming Skeleton¶
Registering the Provider¶
Package the class as a .jar, place it on the Web component's classpath (or in a plugin bundle), and add its id, or its fully-qualified class name, to ami.web.ai.providers in the Web component's local.properties. Then supply whatever ami.web.ai.<id>.* configuration your init() reads:
A custom provider can be mixed with built-ins, and the first entry is the default:
Note
Referencing the provider by fully-qualified class name works too, which is useful when you would rather not rely on classpath discovery of the @AmiPluginAttribute id:
See Ask AI Chatbot for how the provider list and per-provider properties are consumed.