Blog

Luis Majano

April 03, 2026

Spread the word


Share your thoughts

BoxLang AI 3.0 Series Β· Part 5 of 7


Vendor lock-in is the silent killer of AI projects. You pick OpenAI, build everything against the OpenAI API, and then GPT-5 launches at three times the price. Or a competitor launches a model that's faster for your use case. Or you need to self-host for compliance. Or your client is on AWS and wants Bedrock.

Every time the answer to "can we switch providers?" is "it would take months," something went wrong architecturally.

BoxLang AI was designed from the start to eliminate this problem. One API, one set of BIFs, 17 providers β€” and 3.0 makes the architecture underneath significantly more robust with a proper capability system, a cleaner provider hierarchy, and type-safe capability checking that prevents cryptic runtime crashes.


πŸ—ΊοΈ The Full Provider Matrix

BoxLang AI 3.0 supports 17 providers out of the box:

ProviderChat & StreamToolsEmbeddingsStructured Output
AWS Bedrockβœ…βœ…βœ…βœ…
Claude (Anthropic)βœ…βœ…βŒβœ…
Cohereβœ…βœ…βœ…βœ…
DeepSeekβœ…βœ…βœ…βœ…
Docker Model Runnerβœ…βœ…βœ…βœ…
Geminiβœ…Coming Soonβœ…βœ…
Grokβœ…βœ…βœ…βœ…
Groqβœ…βœ…βœ…βœ…
HuggingFaceβœ…βœ…βœ…βœ…
Mistralβœ…βœ…βœ…βœ…
MiniMaxβœ…βœ…βœ…βœ…
Ollamaβœ…βœ…βœ…βœ…
OpenAIβœ…βœ…βœ…βœ… (Native)
OpenAI-Compatibleβœ…βœ…βœ…βœ…
OpenRouterβœ…βœ…βœ…βœ…
Perplexityβœ…βœ…βŒβœ…
Voyage AIβŒβŒβœ… (Specialized)❌

Your BoxLang code doesn't change between any of these. Switch providers with a single config change.


πŸ—οΈ The Provider Hierarchy

The architecture is built around three layers:

IAiService (interface β€” identity + capabilities)
  └── BaseService (abstract β€” HTTP transport, logging, lifecycle hooks)
        β”œβ”€β”€ OpenAIService (OpenAI API format β€” most providers extend this)
        β”‚     β”œβ”€β”€ ClaudeService
        β”‚     β”œβ”€β”€ DeepSeekService
        β”‚     β”œβ”€β”€ GrokService
        β”‚     β”œβ”€β”€ GroqService
        β”‚     β”œβ”€β”€ HuggingFaceService
        β”‚     β”œβ”€β”€ MiniMaxService
        β”‚     β”œβ”€β”€ MistralService
        β”‚     β”œβ”€β”€ OpenAICompatibleService
        β”‚     β”œβ”€β”€ OpenRouterService
        β”‚     └── PerplexityService
        └── (Direct BaseService extensions β€” custom API formats)
              β”œβ”€β”€ BedrockService
              β”œβ”€β”€ CohereService
              β”œβ”€β”€ DockerModelRunnerService
              β”œβ”€β”€ GeminiService
              β”œβ”€β”€ OllamaService
              └── VoyageService

The split between BaseService and OpenAIService is one of the most important refactors in 3.0. Before, the "base" class was OpenAI-specific code that every other provider either inherited awkwardly or had to override entirely. Now BaseService is a true provider-agnostic foundation, and OpenAIService is where the OpenAI-format-specific logic lives.


🎯 IAiService β€” The Trimmed Interface

The base interface now declares only what's universal across all providers:

// From IAiService.bx
interface {

    // Identity
    function getName();

    // Configuration
    IAiService function configure( required any options );

    // Capability discovery
    array   function getCapabilities();
    boolean function hasCapability( required string capability );

}

That's it. No chat(). No embeddings(). No operation methods at all. Those live in capability interfaces β€” because not every provider supports every operation.


πŸ›‘οΈ The Capability System

The capability system is the architectural anchor of 3.0's multi-provider story. It answers the question "what can this provider actually do?" at the type level, not at runtime.

Two capability interfaces define the available operations:

// From IAiChatService.bx
interface extends="IAiService" {
    function chat( required AiChatRequest chatRequest, numeric interactionCount = 0 );
    function chatStream( required AiChatRequest chatRequest, required function callback, numeric interactionCount = 0 );
}

// From IAiEmbeddingsService.bx
interface extends="IAiService" {
    function embeddings( required AiEmbeddingRequest embeddingRequest );
}

A provider that supports both chat and embeddings implements both:

class extends="OpenAIService" implements="IAiChatService,IAiEmbeddingsService" {
    // implements chat(), chatStream(), embeddings()
}

A provider that only supports embeddings (like Voyage AI) implements only one:

class extends="BaseService" implements="IAiEmbeddingsService" {
    // implements embeddings() only β€” no chat, no stream
}

Runtime Capability Detection

BaseService uses isInstanceOf() to detect implemented interfaces β€” which means capability detection is always in sync with the implements declarations with nothing to maintain manually:

// From BaseService.bx β€” getCapabilities()
public array function getCapabilities() {
    var caps = []
    if ( isInstanceOf( this, "IAiChatService" ) ) {
        caps.append( "chat" )
        caps.append( "stream" )
    }
    if ( isInstanceOf( this, "IAiEmbeddingsService" ) ) {
        caps.append( "embeddings" )
    }
    if ( isInstanceOf( this, "IAudioService" ) ) {
        caps.append( "transcribe" )
        caps.append( "speak" )
    }
    return caps
}

Querying Capabilities

// Runtime introspection
service = aiService( "voyage" )
println( service.getCapabilities() )          // [ "embeddings" ]
println( service.hasCapability( "chat" ) )    // false
println( service.hasCapability( "embeddings" ) ) // true

service = aiService( "openai" )
println( service.getCapabilities() )          // [ "chat", "stream", "embeddings" ]
println( service.hasCapability( "chat" ) )    // true

Enforced at the BIF Level

aiChat(), aiChatStream(), and aiEmbed() all check provider capabilities before calling and throw a clear UnsupportedCapability exception if the requirement isn't met:

// This throws immediately β€” Voyage has no chat capability
aiChat( "Hello?", provider: "voyage" )
// UnsupportedCapability: Provider 'voyage' does not support 'chat'. Supported: ["embeddings"]

// This throws immediately β€” Claude has no embeddings capability
aiEmbed( "some text", provider: "claude" )
// UnsupportedCapability: Provider 'claude' does not support 'embeddings'. Supported: ["chat", "stream"]

No more cryptic 404s or malformed response errors when you call the wrong operation on the wrong provider.


πŸ”§ BaseService β€” The Transport Layer

BaseService owns everything that's truly provider-agnostic:

  • HTTP transport β€” sendChatRequest(), sendStreamRequest(), sendEmbeddingRequest()
  • Lifecycle events β€” fires onAIChatRequest, onAIChatResponse, onAIEmbedRequest, onAIEmbedResponse, onAIRateLimitHit, onAIError
  • Logging β€” request/response logging with detailed, human-readable log messages
  • Configuration β€” merges module defaults, provider-specific config, and per-request options
  • Pre/post hooks β€” preRequest() and postResponse() for provider-specific normalization

The pre/post hook pattern is worth understanding. Instead of overriding the entire sendChatRequest() method to add a custom header or normalize a response, providers override two lightweight hooks:

// Override in concrete providers to mutate the outgoing request
private struct function preRequest(
    required AiBaseRequest aiRequest,
    required struct dataPacket,
    required string operation  // "chat" | "stream" | "embeddings"
) {
    // Add a provider-specific header, rename a field, whatever
    return arguments.dataPacket
}

// Override in concrete providers to normalize the response
private any function postResponse(
    required AiBaseRequest aiRequest,
    required struct dataPacket,
    required any result,
    required string operation
) {
    // Normalize the response shape to match expectations
    return arguments.result
}

This keeps the HTTP transport code in BaseService and isolates provider-specific behavior in tiny, focused overrides.


βš™οΈ Provider Configuration

Every provider auto-detects its API key from environment variables using a convention: <PROVIDER>_API_KEY. So OPENAI_API_KEY, CLAUDE_API_KEY, GEMINI_API_KEY, GROQ_API_KEY, etc. β€” you never commit keys to source control.

Full provider configuration in boxlang.json:

{
    "modules": {
        "bxai": {
            "settings": {
                "provider": "openai",
                "defaultParams": {
                    "model": "gpt-4o",
                    "temperature": 0.7,
                    "max_tokens": 2000
                },
                "providers": {
                    "openai": {
                        "params": { "model": "gpt-4o", "temperature": 0.7 },
                        "options": { "timeout": 60 }
                    },
                    "claude": {
                        "params": { "model": "claude-sonnet-4-5-20251001" }
                    },
                    "ollama": {
                        "params": { "model": "qwen2.5:0.5b-instruct" },
                        "options": { "baseUrl": "http://localhost:11434" }
                    }
                }
            }
        }
    }
}

Provider-specific params override the global defaultParams. Per-request params override provider params. The merge order is predictable and deterministic.


πŸ”€ Custom Base URLs

All senders in BaseService now accept a baseUrl override β€” making it trivial to use proxies, self-hosted endpoints, and OpenAI-compatible APIs:

// Via config
model = aiModel( provider: "openai", options: { baseUrl: "http://my-proxy/v1" } )

// Via module settings
"providers": {
    "openai": {
        "options": { "baseUrl": "https://api.mycompany.com/openai-proxy/v1" }
    }
}

// Local Ollama
model = aiModel( provider: "ollama", options: { baseUrl: "http://my-ollama-server:11434" } )

This is how you use any OpenAI-compatible API β€” LM Studio, vLLM, LocalAI, Amazon Bedrock with proxy, etc. β€” without writing a custom provider class.


🏠 Ollama β€” Local AI, Zero API Cost

Ollama deserves a special mention. With BoxLang AI, running fully local AI is as simple as:

# Install Ollama
# Pull a model
ollama pull llama3.2

# Configure BoxLang AI
{
    "modules": {
        "bxai": {
            "settings": {
                "provider": "ollama",
                "defaultParams": { "model": "llama3.2" }
            }
        }
    }
}
// Your code doesn't change at all
answer = aiChat( "What is BoxLang?" )

The same code that runs against OpenAI runs against your local Ollama instance. Switch back by changing the provider in config. This is the zero-vendor-lock-in promise in practice.

Docker Compose setup for development teams that want a shared Ollama instance is included in the repo β€” docker-compose-ollama.yml sets up both the Ollama service and auto-pulls models on first run.


πŸ€— New in 3.0: HuggingFace Embeddings

HuggingFaceService now supports embeddings via the HuggingFace Inference API β€” useful for semantic search, RAG pipelines, and clustering workflows where you want to use community-hosted models:

embeddings = aiEmbed(
    [ "BoxLang is a modern JVM language", "AI is transforming software development" ],
    provider : "huggingface",
    options  : { apiKey: "${Setting: HUGGINGFACE_API_KEY not found}" }
)

The service uses the OpenAI-compatible router endpoint at router.huggingface.co/v1, so any HuggingFace model exposed through their inference API works out of the box.


πŸ—οΈ Building a Custom Provider

If you need a provider that BoxLang AI doesn't support yet, extending the framework is straightforward. For any provider that uses the OpenAI API format (most do), extend OpenAIService and override just what's different:

// MyCustomProvider.bx
import bxModules.bxai.models.providers.OpenAIService;
import bxModules.bxai.models.providers.capabilities.IAiChatService;
import bxModules.bxai.models.providers.capabilities.IAiEmbeddingsService;

class extends="OpenAIService" implements="IAiChatService,IAiEmbeddingsService" {

    function init() {
        variables.name          = "my-provider"
        variables.chatURL       = "https://api.myprovider.com/v1/chat/completions"
        variables.embeddingsURL = "https://api.myprovider.com/v1/embeddings"
        variables.params        = { model: "my-model-v1" }
        return this
    }

    // Override configure() if you need non-standard auth
    IAiService function configure( required any options ) {
        super.configure( arguments.options )
        // Add any provider-specific header (e.g. x-api-version)
        variables.headers[ "x-api-version" ] = "2026-01"
        return this
    }

}

For providers with fully custom API formats (like Claude's or Gemini's native APIs), extend BaseService directly and implement the capability interfaces you need β€” you own the full chat(), chatStream(), and embeddings() implementations.

Register your custom provider via the onMissingAiProvider event:

// In Application.bx or a module's onLoad
bxEvents.listen( "onMissingAiProvider", ( data ) => {
    if ( data.provider == "my-provider" ) {
        data.service = new MyCustomProvider().configure( data.options )
    }
} )

πŸ“’ The Event System

Every operation through BaseService fires BoxLang global events you can intercept for monitoring, logging, billing, and custom behavior:

EventWhen
onAIChatRequestHTTP request about to be sent
onAIChatResponseResponse received and deserialized
onAIEmbedRequestEmbedding request about to be sent
onAIEmbedResponseEmbedding response received
onAIRateLimitHit429 status code received
onAIErrorAny error in an AI operation
onAITokenCountToken usage data available (prompt + completion + total)
beforeAIModelInvokeBefore AiModel.run() calls the service
afterAIModelInvokeAfter AiModel.run() returns

The onAITokenCount event includes tenantId and usageMetadata for multi-tenant billing β€” you can attribute every token to a specific customer, project, or cost center:

bxEvents.listen( "onAITokenCount", ( data ) => {
    billing.record(
        tenantId       : data.tenantId,
        provider       : data.provider,
        model          : data.model,
        promptTokens   : data.promptTokens,
        completionTokens: data.completionTokens,
        usageMetadata  : data.usageMetadata
    )
} )

πŸ”„ Switching Providers in Practice

To drive the point home β€” here's what switching from OpenAI to Claude looks like in your code:

Config change:

// Before
{ "provider": "openai" }

// After
{ "provider": "claude" }

Code change:

(none)

Your aiChat(), aiEmbed(), aiAgent(), and aiModel() calls are all identical. The provider-specific formatting, authentication, and response normalization live entirely inside the provider classes β€” your application code never sees it.


🎯 Wrapping Up the Series

Over these five posts, we've covered the full depth of BoxLang AI 3.0:

  • Part 1 β€” AI Skills System: versioned, composable knowledge blocks that end prompt drift
  • Part 2 β€” Tool Ecosystem: BaseTool, ClosureTool, the Global Registry, and now@bxai
  • Part 3 β€” Multi-Agent Orchestration: hierarchy trees, stateless agents, per-call identity routing
  • Part 4 β€” Middleware: six built-in classes, the hook lifecycle, and FlightRecorderMiddleware for CI
  • Part 5 β€” Provider Architecture: 17 providers, the capability system, and zero-vendor-lock-in design

The common thread across all five: BoxLang AI is designed so that the hard parts β€” lifecycle management, observability, multi-tenancy, provider compatibility β€” are handled by the framework. Your code stays focused on what you're building.


Get Started

# Install via CommandBox
install bx-ai@3.0.0

# Or for OS/CLI applications
install-bx-module bx-ai

πŸ“– Full Documentation πŸ“¦ ForgeBox Package πŸŽ“ AI BootCamp πŸ› Report Issues πŸ’¬ Community Slack πŸ’Ό BoxLang+ Plans


Thank you to the entire Ortus team and everyone in the BoxLang community who contributed to 3.0. This is the release we're most proud of β€” and we're just getting started. πŸ™

Add Your Comment

Recent Entries

How to Develop AI Agents Using BoxLang AI: A Practical Guide

How to Develop AI Agents Using BoxLang AI: A Practical Guide

AI agents are transforming how we build software. Unlike traditional chatbots that just answer questions, agents can reason about what tools they need, decide when to use them, chain multiple actions together, and remember what happened earlier in a conversation.

Luis Majano
Luis Majano
April 03, 2026