DEV Community

Priyanka-Chettri
Priyanka-Chettri

Posted on

Provider Abstraction for LLMs: From One Provider to Many

While building an application with LLMs, the most straightforward thing to do is call the provider directly.

Let's say I'm using OpenAI.

My application needs to generate a heading from a conversation.

I could simply write:

@Injectable()
export class ConversationService {
  async generateHeading(conversation: string) {
    const response = await openai.responses.create({
      model: "...",
      input: `
        Generate a short heading for this conversation:

        ${conversation}
      `,
    });

    return response.output_text;
  }
}
Enter fullscreen mode Exit fullscreen mode

And there's absolutely nothing wrong with this if my application is small and I know I'm only going to use OpenAI.

But let's say the application grows.

Now I have multiple places using an LLM:

ConversationService
        ↓
OpenAI SDK

BondService
        ↓
OpenAI SDK

SummaryService
        ↓
OpenAI SDK
Enter fullscreen mode Exit fullscreen mode

My application is slowly becoming coupled to OpenAI.

Then one day I decide:

What if I want to try Anthropic instead?

Now I have a problem.

Anthropic doesn't necessarily expose the same SDK methods as OpenAI.

So code like:

openai.responses.create(...)
Enter fullscreen mode Exit fullscreen mode

would have to change.

And if OpenAI-specific code is spread throughout my application, switching providers means changing multiple services.

But if I think about it, my business logic doesn't actually care about OpenAI.

ConversationService just wants:

"Generate a heading."
Enter fullscreen mode Exit fullscreen mode

BondService might just want:

"Generate structured data."
Enter fullscreen mode Exit fullscreen mode

And another service might want:

"Stream some text."
Enter fullscreen mode Exit fullscreen mode

Those are my application's requirements.

How OpenAI, Anthropic, or Gemini perform them is an implementation detail.

And this is where provider abstraction starts to make sense.


Defining What My Application Needs

Instead of letting every service understand OpenAI's SDK, I can define a common interface.

export interface LLMProvider {
  generate(prompt: string): Promise<string>;

  generateStructured<T>(
    prompt: string,
    schema: unknown,
  ): Promise<T>;
}
Enter fullscreen mode Exit fullscreen mode

I'm essentially saying:

Any LLM provider that wants to work with my application must provide these capabilities.

Now my application has its own language:

provider.generate(prompt);
Enter fullscreen mode Exit fullscreen mode

instead of being built around a vendor's language:

openai.responses.create(...)
Enter fullscreen mode Exit fullscreen mode

Different Providers Can Implement the Same Requirement

OpenAI can implement my interface:

export class OpenAIProvider implements LLMProvider {
  async generate(prompt: string): Promise<string> {
    // Translate generate() into the OpenAI SDK call
    const response = await openai.responses.create({
      model: "...",
      input: prompt,
    });

    return response.output_text;
  }
}
Enter fullscreen mode Exit fullscreen mode

Anthropic can implement the same interface:

export class AnthropicProvider implements LLMProvider {
  async generate(prompt: string): Promise<string> {
    // Translate generate() into the Anthropic SDK call
    // ...
    return "...";
  }
}
Enter fullscreen mode Exit fullscreen mode

Now I have:

                     LLMProvider
                      generate()
                      /       \
                     /         \
                    ↓           ↓
           OpenAIProvider   AnthropicProvider
                 ↓                ↓
            OpenAI SDK       Anthropic SDK
Enter fullscreen mode Exit fullscreen mode

My application cares about:

generate()
Enter fullscreen mode Exit fullscreen mode

The provider cares about:

How do I make that happen using this particular vendor?
Enter fullscreen mode Exit fullscreen mode

That is the basic idea behind provider abstraction.


This Is Where Dependency Inversion Appears

At this point, something interesting has happened to our architecture.

Originally we had:

ConversationService
        ↓
OpenAIProvider
Enter fullscreen mode Exit fullscreen mode

Our higher-level application code directly depended on a concrete implementation.

After introducing LLMProvider, we have:

ConversationService
        ↓
LLMService
        ↓
LLMProvider
        ↑
OpenAIProvider
Enter fullscreen mode Exit fullscreen mode

Our higher-level code now depends on an abstraction rather than directly depending on OpenAI.

This is the idea behind the Dependency Inversion Principle.

High-level code should depend on abstractions rather than concrete implementations.

I didn't introduce an interface because someone told me:

"You must use the Dependency Inversion Principle."

I introduced it because I didn't want my application tightly coupled to OpenAI.

The design principle is simply a name for what this architecture is achieving.


But How Does LLMService Get an OpenAIProvider?

Now we have another question.

Suppose LLMService looks like this:

@Injectable()
export class LLMService {
  constructor(
    private readonly provider: LLMProvider,
  ) {}

  generate(prompt: string) {
    return this.provider.generate(prompt);
  }
}
Enter fullscreen mode Exit fullscreen mode

LLMService isn't doing this:

const provider = new OpenAIProvider();
Enter fullscreen mode Exit fullscreen mode

Instead, we're saying:

constructor(
  private readonly provider: LLMProvider,
) {}
Enter fullscreen mode Exit fullscreen mode

In other words:

LLMService, don't create your own dependency. Someone else will give it to you.

That idea is Dependency Injection.

Without DI

LLMService
    ↓
new OpenAIProvider()


With DI

OpenAIProvider
    ↓
injected into
    ↓
LLMService
Enter fullscreen mode Exit fullscreen mode

NestJS has a Dependency Injection container that handles this for us.

But now we run into another problem.

And this is where Symbols finally become relevant.

But There Is One Problem: Interfaces Don't Exist at Runtime

So far, our LLMService looks something like this:

@Injectable()
export class LLMService {
  constructor(
    private readonly provider: LLMProvider,
  ) {}

  generate(prompt: string) {
    return this.provider.generate(prompt);
  }
}
Enter fullscreen mode Exit fullscreen mode

Conceptually, this is exactly what we want.

We're saying:

LLMService doesn't care whether it gets OpenAI, Anthropic, or something else. Just give it something that follows the LLMProvider contract.

But now we run into an interesting TypeScript + NestJS problem.


TypeScript Understands Interfaces, JavaScript Doesn't

We defined:

export interface LLMProvider {
  generate(prompt: string): Promise<string>;
}
Enter fullscreen mode Exit fullscreen mode

TypeScript understands this perfectly.

It can check:

class OpenAIProvider implements LLMProvider {
  async generate(prompt: string): Promise<string> {
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

If OpenAIProvider forgets to implement generate(), TypeScript can complain.

So during development:

LLMProvider interface
        ↓
TypeScript understands it
        ↓
Checks our code
Enter fullscreen mode Exit fullscreen mode

But TypeScript doesn't run directly in production.

It gets compiled/transpiled into JavaScript.

And JavaScript does not have TypeScript's interface construct.

So something like:

interface LLMProvider {
  generate(prompt: string): Promise<string>;
}

class OpenAIProvider implements LLMProvider {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

roughly becomes:

class OpenAIProvider {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

The interface is gone.

TYPESCRIPT

LLMProvider       ✅
OpenAIProvider    ✅

        ↓
    compilation

JAVASCRIPT

LLMProvider       ❌
OpenAIProvider    ✅
Enter fullscreen mode Exit fullscreen mode

This is intentional.

The interface was only needed for type checking while developing the application.


Why Is That a Problem for NestJS?

Because NestJS Dependency Injection happens at runtime.

When the application is actually running, Nest needs to know:

Which dependency should I put inside LLMService?

For classes, this is often straightforward because classes exist at runtime.

For example:

constructor(
  private readonly userService: UserService,
) {}
Enter fullscreen mode Exit fullscreen mode

UserService is a class.

The class still exists in JavaScript at runtime, so Nest can use it as a DI token.

But:

constructor(
  private readonly provider: LLMProvider,
) {}
Enter fullscreen mode Exit fullscreen mode

is different.

LLMProvider is an interface.

By the time NestJS is running:

LLMProvider
     ↓
doesn't exist
Enter fullscreen mode Exit fullscreen mode

So Nest effectively has no runtime identifier it can use to answer:

"What exactly should I inject here?"
Enter fullscreen mode Exit fullscreen mode

We need to give Nest something that does exist at runtime.

And that's where Symbols come in.


Enter Symbols

JavaScript has a primitive called Symbol.

const LLM_PROVIDER = Symbol("LLM_PROVIDER");
Enter fullscreen mode Exit fullscreen mode

Every Symbol is a unique runtime value.

For example:

const a = Symbol("LLM_PROVIDER");
const b = Symbol("LLM_PROVIDER");

console.log(a === b); // false
Enter fullscreen mode Exit fullscreen mode

Even though both have the same description, they are two different values.

So we can create:

export const LLM_PROVIDER = Symbol("LLM_PROVIDER");
Enter fullscreen mode Exit fullscreen mode

and use this Symbol as a runtime identifier for our dependency.

Now we have two similarly named things, but they have completely different jobs:

LLMProvider
    ↓
TypeScript interface
    ↓
Defines WHAT a provider must be able to do
    ↓
Compile-time only


LLM_PROVIDER
    ↓
JavaScript Symbol
    ↓
Identifies the dependency for NestJS
    ↓
Exists at runtime
Enter fullscreen mode Exit fullscreen mode

Connecting the Symbol to OpenAIProvider

Now we tell Nest:

{
  provide: LLM_PROVIDER,
  useClass: OpenAIProvider,
}
Enter fullscreen mode Exit fullscreen mode

You can almost read this as plain English:

For the dependency identified by LLM_PROVIDER, use OpenAIProvider.

Nest's DI container now conceptually has:

DI CONTAINER

KEY                         IMPLEMENTATION
────────────────────────────────────────────
LLM_PROVIDER   ──────────→  OpenAIProvider
Enter fullscreen mode Exit fullscreen mode

The Symbol is essentially the key.

The provider class is the value/implementation associated with that key.


Now LLMService Can Ask for That Dependency

We can write:

@Injectable()
export class LLMService {
  constructor(
    @Inject(LLM_PROVIDER)
    private readonly provider: LLMProvider,
  ) {}

  generate(prompt: string) {
    return this.provider.generate(prompt);
  }
}
Enter fullscreen mode Exit fullscreen mode

This one constructor parameter actually contains two separate concepts:

@Inject(LLM_PROVIDER)
Enter fullscreen mode Exit fullscreen mode

is for NestJS.

It says:

At runtime, find whatever dependency is registered under this Symbol.

While:

provider: LLMProvider
Enter fullscreen mode Exit fullscreen mode

is for TypeScript.

It says:

During development/type checking, make sure this variable follows the LLMProvider contract.

So:

@Inject(LLM_PROVIDER)
        ↓
      NestJS
        ↓
Runtime lookup
        ↓
LLM_PROVIDER
        ↓
OpenAIProvider


provider: LLMProvider
        ↓
    TypeScript
        ↓
Type checking
        ↓
Must satisfy the LLMProvider contract
Enter fullscreen mode Exit fullscreen mode

At runtime, the object stored inside provider is actually an instance of:

OpenAIProvider
Enter fullscreen mode Exit fullscreen mode

Therefore:

this.provider.generate(prompt);
Enter fullscreen mode Exit fullscreen mode

actually executes:

OpenAIProvider.generate(prompt);
Enter fullscreen mode Exit fullscreen mode

Why a Symbol Instead of Just a String?

Technically, NestJS also allows other values such as strings to be used as injection tokens.

We could have something like:

"LLM_PROVIDER"
Enter fullscreen mode Exit fullscreen mode

But strings can accidentally have the same value:

const a = "LLM_PROVIDER";
const b = "LLM_PROVIDER";

a === b; // true
Enter fullscreen mode Exit fullscreen mode

Symbols are unique:

const a = Symbol("LLM_PROVIDER");
const b = Symbol("LLM_PROVIDER");

a === b; // false
Enter fullscreen mode Exit fullscreen mode

That makes Symbols useful as unique dependency identifiers.

One important detail is that we must reuse the same Symbol instance.

For example:

// llm.tokens.ts

export const LLM_PROVIDER = Symbol("LLM_PROVIDER");
Enter fullscreen mode Exit fullscreen mode

Then import it wherever we need it:

import { LLM_PROVIDER } from "./llm.tokens";
Enter fullscreen mode Exit fullscreen mode

We should not create a new:

Symbol("LLM_PROVIDER")
Enter fullscreen mode Exit fullscreen mode

in every file because every call to Symbol() creates a different value.


So Where Should the Symbol Live?

A simple project structure could be:

llm/
├── llm-provider.interface.ts
├── llm.tokens.ts
├── llm.service.ts
├── llm.module.ts
└── providers/
    ├── openai.provider.ts
    └── anthropic.provider.ts
Enter fullscreen mode Exit fullscreen mode

Inside:

llm.tokens.ts
Enter fullscreen mode Exit fullscreen mode

we define:

export const LLM_PROVIDER = Symbol("LLM_PROVIDER");
Enter fullscreen mode Exit fullscreen mode

Then llm.module.ts registers it:

@Module({
  providers: [
    {
      provide: LLM_PROVIDER,
      useClass: OpenAIProvider,
    },
    LLMService,
  ],
})
export class LLMModule {}
Enter fullscreen mode Exit fullscreen mode

And llm.service.ts asks Nest for it:

@Injectable()
export class LLMService {
  constructor(
    @Inject(LLM_PROVIDER)
    private readonly provider: LLMProvider,
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

So the complete connection becomes:

                    LLMProvider
                 TypeScript contract
                        ↑
                        │ implements
                        │
                 OpenAIProvider
                        ↑
                        │ registered as
                        │
               LLM_PROVIDER Symbol
                        ↑
                        │ @Inject(...)
                        │
                    LLMService
Enter fullscreen mode Exit fullscreen mode

The interface tells TypeScript what the provider should look like.

The Symbol tells NestJS how to find that provider at runtime.

And OpenAIProvider contains the actual implementation.

What If I Want to Use Multiple LLM Providers?

So far, we've designed our application so that the underlying provider can be changed without changing our business logic.

For example, today I could register:

{
  provide: LLM_PROVIDER,
  useClass: OpenAIProvider,
}
Enter fullscreen mode Exit fullscreen mode

and tomorrow change it to:

{
  provide: LLM_PROVIDER,
  useClass: AnthropicProvider,
}
Enter fullscreen mode Exit fullscreen mode

Our LLMService doesn't have to change.

But there's another scenario.

What if I don't want to switch providers?

What if I want to use multiple providers at the same time?

For example:

Generate titles     → OpenAI
Complex analysis    → Anthropic
Another task        → Gemini
Enter fullscreen mode Exit fullscreen mode

Now I need more than one provider available inside my application.


Registering Multiple Providers

Instead of having one token:

export const LLM_PROVIDER = Symbol("LLM_PROVIDER");
Enter fullscreen mode Exit fullscreen mode

I could have separate tokens:

export const OPENAI_PROVIDER = Symbol("OPENAI_PROVIDER");
export const ANTHROPIC_PROVIDER = Symbol("ANTHROPIC_PROVIDER");
Enter fullscreen mode Exit fullscreen mode

And register both:

@Module({
  providers: [
    {
      provide: OPENAI_PROVIDER,
      useClass: OpenAIProvider,
    },
    {
      provide: ANTHROPIC_PROVIDER,
      useClass: AnthropicProvider,
    },
    LLMService,
  ],
})
export class LLMModule {}
Enter fullscreen mode Exit fullscreen mode

Nest's DI container now conceptually has:

OPENAI_PROVIDER
      ↓
OpenAIProvider


ANTHROPIC_PROVIDER
      ↓
AnthropicProvider
Enter fullscreen mode Exit fullscreen mode

Both still follow the same contract:

LLMProvider
Enter fullscreen mode Exit fullscreen mode

So I can inject both:

@Injectable()
export class LLMService {
  constructor(
    @Inject(OPENAI_PROVIDER)
    private readonly openai: LLMProvider,

    @Inject(ANTHROPIC_PROVIDER)
    private readonly anthropic: LLMProvider,
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

Now my application has multiple implementations of the same capability.

Which introduces another question:

How do I decide which implementation should handle a particular request?

This is where the Strategy Pattern can come into the picture.


The Strategy Pattern

Let's say both providers can generate text:

                LLMProvider
                 generate()
                 /       \
                ↓         ↓
        OpenAIProvider  AnthropicProvider
Enter fullscreen mode Exit fullscreen mode

But I want to choose between them depending on the task.

I could create a simple selection mechanism:

type ProviderName = "openai" | "anthropic";

@Injectable()
export class LLMService {
  constructor(
    @Inject(OPENAI_PROVIDER)
    private readonly openai: LLMProvider,

    @Inject(ANTHROPIC_PROVIDER)
    private readonly anthropic: LLMProvider,
  ) {}

  private getProvider(provider: ProviderName): LLMProvider {
    if (provider === "openai") {
      return this.openai;
    }

    return this.anthropic;
  }

  generate(
    prompt: string,
    provider: ProviderName,
  ) {
    const selectedProvider = this.getProvider(provider);

    return selectedProvider.generate(prompt);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now:

llmService.generate(prompt, "openai");
Enter fullscreen mode Exit fullscreen mode

uses:

OpenAIProvider
Enter fullscreen mode Exit fullscreen mode

while:

llmService.generate(prompt, "anthropic");
Enter fullscreen mode Exit fullscreen mode

uses:

AnthropicProvider
Enter fullscreen mode Exit fullscreen mode

Conceptually:

                    LLMService
                        ↓
                 Choose provider
                   /         \
                  ↓           ↓
          OpenAIProvider   AnthropicProvider
                  ↓           ↓
             OpenAI SDK   Anthropic SDK
Enter fullscreen mode Exit fullscreen mode

The different providers are different strategies for performing the same operation.

That's the basic idea behind the Strategy Pattern.


Putting Everything Together

What started as:

openai.responses.create(...)
Enter fullscreen mode Exit fullscreen mode

inside my business logic eventually led to:

                    Business Service
                          ↓
                      LLMService
                          ↓
                  Choose Provider
                    /         \
                   ↓           ↓
           OpenAIProvider   AnthropicProvider
                   ↓           ↓
              OpenAI SDK   Anthropic SDK
Enter fullscreen mode Exit fullscreen mode

with:

LLMProvider
    ↓
Defines the common contract


Dependency Inversion
    ↓
Business logic depends on the abstraction,
not a specific provider


Dependency Injection
    ↓
Providers are supplied to our services


Symbols
    ↓
Give NestJS runtime identifiers for
interface-based dependencies


Adapter Pattern
    ↓
Hides differences between provider APIs


Strategy Pattern
    ↓
Lets us choose between provider implementations
when we actually need multiple strategies
Enter fullscreen mode Exit fullscreen mode

One important thing though: having multiple providers doesn't automatically mean I need the Strategy Pattern.

If one service always uses OpenAI and another service always uses Anthropic, simply injecting the correct provider may be enough.

Strategy becomes useful when the provider itself needs to be selected dynamically based on the task, configuration, cost, fallback logic, or some other condition.

Top comments (0)