AiProviderAccessor

AiProviderAccessor is the plugin-facing way to call platform-managed LLM providers. It lets a plugin issue chat completions without ever touching LlmProviderFactory, tenant resolution, secret loading, or provider-specific protocol code — the host keeps those concerns inside the platform boundary, and the accessor exposes a stable, side-effect-aware surface.

This is the SPI behind every AI-native AuraBoot capability you may already know: Aura Bot, ChatBI, Agent workflows, RAG augmentation, and command-side enrichment in plugin handlers. If your plugin needs to talk to an LLM, this is the contract — not Spring autowiring.

Concept

The accessor is delivered to plugin code as a host-bridge interface, the same pattern used by FileAccessor and BackgroundAccessor. Plugins do not autowire LlmProviderFactory — instead they declare an AiProviderAccessor dependency and receive the host-bound instance at command execution time, via the well-known CommandContext.settings key:

String SETTINGS_KEY = "__aiProviderAccessor";

The host implementation (LlmProviderAccessorImpl) resolves the call against the current tenant context: which llm_config profile applies, which provider credentials are reachable, which cloud_config overrides are active, which rate-limit and entitlement guards apply, and how the upstream payload should be shaped per-provider.

This split is what makes plugin AI calls AI-safe by construction. The plugin describes intent; the platform decides identity, secrets, and protocol.

Interface signature

package com.auraboot.framework.plugin.extension;

public interface AiProviderAccessor {

    String SETTINGS_KEY = "__aiProviderAccessor";

    ChatResponse chat(ChatRequest request) throws Exception;

    record ChatRequest(
            String useCase,
            String providerProfileCode,
            String providerCode,
            String modelName,
            String systemPrompt,
            List<Message> messages,
            int maxTokens,
            Map<String, Object> metadata
    ) {}

    record Message(String role, String content) {
        public static Message user(String content);
        public static Message assistant(String content);
    }

    record ChatResponse(
            String providerCode,
            String modelName,
            String text,
            int inputTokens,
            int outputTokens,
            int totalTokens,
            String rawResponseJson
    ) {}
}

Records are immutable; helper constructors on Message cover the two most common cases. rawResponseJson is provided for diagnostic and audit purposes — do not parse it for business logic, use the typed text / inputTokens / outputTokens fields.

Resolving the accessor inside a command handler

@Extension
public class SummarizeIssueHandler implements CommandHandlerExtension {

    @Override
    public String getCommandType() {
        return "crm:case_summarize";
    }

    @Override
    public Object execute(CommandContext context) throws Exception {
        AiProviderAccessor llm = context.aiProviderAccessor();

        var req = new AiProviderAccessor.ChatRequest(
            "crm.case.summarize",                              // useCase
            null,                                              // providerProfileCode (let host pick)
            null,                                              // providerCode (let host pick)
            null,                                              // modelName (let host pick)
            "Summarize the customer case in one paragraph.",   // systemPrompt
            List.of(AiProviderAccessor.Message.user(
                String.valueOf(context.payload().get("caseDescription")))),
            512,                                               // maxTokens
            Map.of("auditCorrelationId", context.recordId())   // metadata
        );

        AiProviderAccessor.ChatResponse resp = llm.chat(req);
        return Map.of("summary", resp.text());
    }
}

Three things to notice:

  • No Spring autowiring. The plugin never sees LlmProviderFactory, OpenAiClient, AnthropicClient, etc.
  • useCase is required. It controls which llm_config profile, rate-limit bucket, and audit category the call belongs to. If you omit it the platform falls back to a default profile that may be more restrictive.
  • Profile / provider / model can be null. Leaving them null lets the platform resolve the best available option for the current tenant. Set them only when the plugin has a hard reason (e.g. a hosted enterprise model required by contract).

When to set providerProfileCode / providerCode / modelName

FieldSet it whenLeave null when
providerProfileCodeThe plugin contracts a specific llm_config profile (e.g. a privacy-constrained tenant-owned key set)You want host policy + cost optimization
providerCodeA use-case is locked to a specific provider for contractual reasonsYou want failover and provider rotation
modelNameYou need a specific model version for reproducibility or capabilitiesYou want the host's recommended model for the profile

In most plugins, leave all three null. Set useCase and let the platform pick.

Error semantics

chat(...) throws checked Exception because LLM calls cross network, secrets, and provider boundaries — failures must be observable to callers. The interface declares a single generic throws Exception; it does not expose fine-grained exception types, and the specific failure reason is carried in the exception message / cause by the host. Common failure semantics:

  • Entitlement missing — The current tenant has no entitlement for this useCase (e.g. the required Aura Bot tier is not purchased). Fail the command and surface to the user; do not retry silently.
  • Provider unavailable — The upstream provider returned a 5xx / timeout. Re-throwing aborts the command pipeline.
  • Invalid provider profileproviderProfileCode is not registered for the tenant. Fix configuration; do not work around.
  • Rate limit hit — The tenant or use-case bucket is exhausted. Surface to the user; do not implement plugin-side retry loops (the platform owns rate-limit logic).

Catch only what you can recover. Wrapping Exception and converting to a generic message is an anti-pattern — it hides entitlement and config drift from the platform's monitoring surface.

Audit and correlation

Every call is logged to the platform's LLM audit trail, keyed by useCase, providerCode, modelName, inputTokens, outputTokens, and the correlation field you supply in metadata (e.g. auditCorrelationId). Put the current record key context.recordId() into metadata so the LLM call can be joined to the business record that triggered it in the audit trail — that is the intent of the audit join.

Sensitive prompt content is masked in the audit store per the platform's redaction policy. Plugins should not duplicate this — do not log full prompts from your own handler.

Common pitfalls

PatternWhy it breaks
Autowiring LlmProviderFactory directlyBypasses tenant + entitlement + audit; will not pass platform code review
Hard-coding providerCode = "openai"Locks the plugin out of customer-owned model deployments
Re-throwing wrapped as RuntimeException("LLM failed")Loses entitlement vs. provider-error distinction
Calling chat(...) inside a tight loop without backoffTrips the platform rate limit; the accessor's host-side backoff is not unlimited
Storing rawResponseJson for downstream parsingProvider response shapes change; use typed fields

Relationship to other SPIs

  • CommandHandlerExtension — How to participate in the Command Pipeline; this is where chat() is typically called.
  • FileAccessor — Same host-bridge pattern for file bytes.
  • BackgroundAccessor — Same host-bridge pattern for background-task system fields.
  • Agent Readiness — How agentHint / riskLevel / idempotent on the command surface relate to LLM calls made inside a command.

Enterprise note — Multi-tenant LLM budget caps, prompt-provenance signing, cross-tenant Agent Control Plane orchestration, and Observability Pro per-call latency dashboards are commercial-only capabilities. The open-source AiProviderAccessor interface is identical across editions; the operational surfaces wrapped around it differ.

Next steps