Inference providers from a plugin
A plugin can bring a model API of its own. The user then creates an AI of the type Extension, chooses the
provider of the plugin in the AI dialog, and the AI works like an OpenAI-compatible one: books use it, it is shared and
limited, its usage is counted. The bundled
Google Gemini plugin is the
worked example,
flowchart LR
Plugin["plugin<br/>ExtensionAIFactory"] -->|registerExtensionFactory| IS["InferenceServices"]
Dialog["AIDialog<br/>type Extension"] -->|getExtensionFactories| IS
Dialog -->|createForm| Form["ExtensionAIForm<br/>(tab of the dialog)"]
Form -->|apply| AI["ExtensionAI<br/>extensionId + settings"]
Gen["generation, summaries..."] -->|forAI| IS
IS -->|createInferenceService| Plugin
Plugin --> Svc["InferenceService<br/>(cached in the AI)"]The pieces
The extension id is stored in every AI of the provider and never changes: use the symbolic name of the bundle
(the Google plugin uses google-genai).
Registering
Register the factory in onExtensionLoad and take it back in onExtensionUnload, like the other registrations
(see Plugin basics):
@Configurable
public class MyExtension implements MarginaliaExtension {
@Autowired
private InferenceServices inferenceServices;
private MyFactory factory;
@Override
public void onExtensionLoad(Bundle bundle, OsgiService parentService, ExtensionService extensionService) {
factory = new MyFactory(inferenceServices, parentService.createLogger(bundle));
inferenceServices.registerExtensionFactory(factory); // IllegalStateException when the id is taken
}
@Override
public void onExtensionUnload(Bundle bundle, OsgiService parentService, ExtensionService extensionService) {
inferenceServices.unregisterExtensionFactory(factory);
factory.close(); // HTTP clients, threads
}
}
The settings form
createForm is called by the AI dialog each time the user picks the provider (and when an existing AI is edited), with
the ExtensionAI being edited, or a new one that only has the extension id. The form reads the AI's
getSettings() and builds its fields (a FormLayout is the usual root; use @Configurable(preConstruction = true) to
get Localization into the constructor). The dialog:
-
calls
validate()before it saves or tests, and does not go on when it returnsfalse- the form tells the user why (a notification); -
calls
apply(ai)on the AI it saves, and on a throw-away copy (a deep copy of the attributes with the values of the dialog, no id) when the user clicks Test Connection, which runstestConnection()of the service the factory creates for the copy. A form that lists models (a Refresh button) can create a service for such a copy itself and close it afterwards.
The general settings of the AI (name, context and response size, jailbreak, reasoning) stay with the dialog; the
service reads them from the AI (getMaxContext(), getEnabledReasoning(), getReasoningEffort()...) and the
protocol (TokenLimits).
Secrets
The attributes are not encrypted. Keep an API key with inferenceServices.protectSecret(key) (AES with the
installation key, the same as the API key column of an OpenAI-compatible AI) and read it with revealSecret, which
returns null for a key from another installation. Never show a stored key: disable the field and offer a Reset
button, as the bundled forms do.
The inference service
createInferenceService(ai) returns an InferenceService. InferenceServices.forAI calls it once and keeps the
service in the AI (ExtensionAI.getInferenceServiceFactory() remembers which factory made it), exactly as for an
OpenAI-compatible AI. Saving the AI in the dialog clears the cache (setAiType), so new settings reach the next service.
Read the settings in the constructor: the service is not rebuilt per request.
The core calls a service for many things (the generation, summaries, other plugins), so implement all of
InferenceService that your API can do and throw UnsupportedOperationException for the rest, as the defaults do.
What a service has to take care of itself, because nothing above it does:
Tokenizers for OpenAI-compatible AIs
A plugin that is not a provider can still bring a tokenizer for the AIs of the type OpenAI-compatible (a custom endpoint, a
model whose vocabulary tiktoken does not know). Implement ExtensionTokenizer (getId, getName, countTokens(TokenizerKey, String), optionally accepts(TokenizerKey)) and call TokenizerService.registerTokenizer in onExtensionLoad,
unregisterTokenizer in onExtensionUnload.
-
The tokenizer is listed in the Tokenizer selector of the AI dialog (
TokenizerService.getChoices, idext:<id>) and is used by the AIs whose user chose it (OpenAICompatible.tokenizer, an extended attribute; null is automatic). Nothing is ever sent to it for an AI that did not choose it. -
It sees the
TokenizerKeyonly:key.id()is the normalized endpoint and the model (http://host:8080#model), the API key and the other settings of the AI never reach it. -
accepts(key)is only a suggestion: the dialog preselects the first accepting tokenizer when the user creates an AI and has not chosen one. Existing AIs are never changed by it. -
An exception from
countTokensmakes the application count by the local estimate for that call. It is called often and in the middle of the work, so keep it quick. Inside a read-write transactionTokenizerServicedoes not call it at all (it counts approximately, the transaction holds the database write lock). -
When the plugin is unloaded, the AIs that chose it keep the choice (the dialog shows it as not loaded) and count by the local estimate until it is loaded again.
A plugin that provides AIs does not use this: it counts in its InferenceService, or gives the application its own
resolver with ExtensionAIFactory.createTokenizerResolver() (see Tokens above).
When the plugin is not loaded
AIs are rows in the database: the AI of an unloaded plugin stays in the list (AITypes.label shows its provider name, or
Extension: <id>) and works again when the plugin is loaded. In between:
-
InferenceServices.forAI(ai)returnsnulland drops a service cached from the unloaded plugin; so it does when the plugin was loaded again meanwhile, because the cached service was made by the old factory: the next call asks the new one. -
forAIOrThrow(ai)throwsInferenceExceptionof the typeEXTENSION_UNAVAILABLE("The extension that provides this AI is not loaded...") and is what the generation steps and the summaries call. Code that has a sensible fallback (the token count inStorySupport) keeps usingforAIand itsnull. -
The AI dialog opens such an AI with a notice instead of the form, saves the general settings and leaves the settings of the plugin untouched.
Unregistering does not stop the services that exist. Close what they hold in onExtensionUnload (the Google factory
keeps its services in a weak set and closes their clients) and let a closed service fail with
EXTENSION_UNAVAILABLE.
The AI dialog
AIDialog offers the type Extension only while a factory is registered (or when the AI being edited is one).
The type of an existing AI cannot be changed, because the type decides the entity class. For the type Extension the
dialog shows a Provider selector - preselected when only one plugin provides AIs, read-only when editing - and, under
it, the form of the chosen provider and the Test Connection button. The methods that do this
(addExtensionSettings, currentExtensionAI, testExtensionConnection...) are @PublicAPI like the rest of the dialog.
The Google Gemini plugin
marginalia/plugins/google-genai uses the
Google Gen AI Java SDK:
Two things in it are worth copying:
-
Thought signatures. Gemini 3 refuses the next round of a function call unless the model's previous answer comes back with its thought signatures. The service therefore keeps the model's own
Content(with the signature parts, without the thinking summaries) and puts it in front of the results of the calls, instead of rebuilding the turn from theLLMToolCalls. -
Cancellation. The SDK hides its HTTP call. Every request carries a header with the id of the run, an OkHttp interceptor stores the
Callunder it (and removes the header before the request leaves), and theCancellationTokencancels that call, so a generation stops at once even if Google is slow to send the next chunk.
Packaging a library only the plugin needs
The SDK is not a dependency of the application. The plugin's pom.xml lists it as a normal dependency and embeds it:
<Embed-Dependency>*;scope=compile|runtime;inline=false</Embed-Dependency>
<Embed-Transitive>true</Embed-Transitive>
<Import-Package>!com.github.enerccio.marginalia.extensions.googlegenai.*</Import-Package>
maven-bundle-plugin puts the JARs inside the bundle and adds them to Bundle-ClassPath. The framework is started with
org.osgi.framework.bootdelegation=* and the application's class loader as the parent, so a class the application
has wins over an embedded copy (the bundle looks in its own JARs only for what the application does not have). Hence:
-
libraries the application ships already (OkHttp, Jackson, Kotlin, Gson, slf4j, jspecify) are
exclusionsof the SDK andprovidedfor compiling - the SDK then runs on the application's versions (OkHttp 4.12, Jackson 2.22), which must stay compatible with the ones the SDK was built for; -
nothing is imported (
Import-Packagelists only the negated own package), so no package of an embedded library has to be exported by the application; -
the JAR is large (about 14 MB: the SDK, Guava, Protobuf, google-auth).
A provider that sends the story to a service somebody else runs should say so where the administrator and the users read: the README of the plugin (and its user page) states that the story text, the lore, the summaries and the files of agents go to Google's servers, under Google's terms, and that sharing such an AI sends the text of everybody who uses it.
The plugin has unit tests for the pure parts (GoogleGenAiMessagesTest, GoogleGenAiErrorsTest,
GoogleGenAiSettingsTest); the core has ExtensionAIFactoryTest, ExtensionAICrudTest, ExtensionAIGenerationTest and
AIDialogExtensionTest with a fake factory, which is the way to test your own provider's contract without a plugin.
The Anthropic Claude plugin
marginalia/plugins/anthropic uses the Anthropic Java SDK and has the
same classes as the Google plugin (AnthropicActivator, AnthropicExtension, AnthropicFactory, AnthropicSettings,
AnthropicForm, AnthropicInferenceService, AnthropicMessages, AnthropicErrors). It is packaged the same way: the SDK
(anthropic-java, about 36 MB with the model classes) is embedded, and what the application has (OkHttp, Jackson, Kotlin, the JSON
schema generator, slf4j) is excluded. Only the OpenTelemetry API and Standard Webhooks, which the SDK needs and the application
does not, come along. Where it differs from the Google plugin:
-
Cancellation needs no interceptor. The SDK closes the HTTP call when the
StreamResponseis closed, also while another thread waits in a read, so theCancellationTokencloses the stream of the run. -
Everything is streamed,
inferandroundtoo (the SDK refuses a long non-streamed request); the pieces of aroundare shown throughInferenceRoundCallback.onChunkwhile it is written. -
Signed thinking. The next round of a function call has to carry the thinking blocks of the answer with their signatures. Inside one request the service sends the whole
Messageback (ContentBlock.toParam()); an agent rebuilds its history from the calls, so the blocks are encoded into theLLMToolCallsignature of the first call (AnthropicMessages.encodeThinking) and put back in front of the text when the history is sent again. -
No sampling parameters. The API version the SDK sends refuses
temperature,top_pandtop_k; the protocol's values are ignored. Thinking isadaptivewith a summarized display for Claude 4.6 and later, a token budget for the earlier models (AnthropicInferenceService.BUDGET_THINKING), andoutput_config.effortcarries the effort. With reasoning off nothinkingparameter is sent, because some models refusedisabled. -
Conversation shape. System messages in the middle become user messages; results of calls come first in their user message; the payload has to start and end with the user, because the models after Claude 4.5 refuse a prefill. Anything else is an
InferenceExceptionof the typeBAD_REQUESTthrown before the request is sent, nothing is added to the prompt. -
Tests.
AnthropicServiceTestruns the real SDK againstFakeAnthropicServer(a JDKHttpServerthat streams scripted server-sent events): chunks, usage, the request bodies, the function loop with signed thinking,inferwith a schema,round, the error statuses, and a stalled stream that is cancelled. That is the way to test a provider without an API key.