What You Actually Inherit When You Extend BaseProvider
What You Actually Inherit When You Extend BaseProvider — companion deep-dive for the NeuroLink blog with architectural detail and code examples.
We designed NeuroLink’s BaseProvider because every new model integration felt like starting from scratch. Adding Anthropic’s Claude 3.5 Sonnet shouldn’t require re-writing authentication, context management, and tool handling that we’d already perfected for OpenAI’s GPT-4o and Google’s Gemini on Vertex AI. Before BaseProvider, each new provider was a bespoke, monolithic class. The result was a constant drift in features, inconsistent error handling, and a testing matrix that was impossible to manage. The BaseProvider establishes a core contract: you implement the handful of methods unique to your target API, and you inherit a battle-tested engine for everything else.
This post is a deep-dive into that contract. We’ll look at the abstract methods you must override, the rich suite of concrete functionality you get for free, and how this architecture lets us add a new provider in hours, not weeks.
The Core Contract: What You Must Implement
When you extend BaseProvider, you are signing a contract. Your new class must provide implementations for a handful of abstract methods that define the provider’s unique identity and core generation logic. Everything else is optional.
Here are the non-negotiables:
getProviderName(): A simple string to identify the provider (e.g., “anthropic”, “openai”).getDefaultModel(): The default model ID to use if the user doesn’t specify one.getAISDKModel(): Returns the underlying AI SDK’s model object.formatProviderError(): Translates provider-specific API errors into a standardizedErrorformat.executeStream(): The big one. This is where you write the actual code to call the provider’s streaming generation API.
A skeleton for a new provider looks like this. Note that this is the entire required surface area.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { BaseProvider, AIProviderName, StreamOptions, StreamResult } from '@juspay/neurolink';
import { LanguageModel } from 'ai';
export class MyNewProvider extends BaseProvider {
protected getProviderName(): AIProviderName {
return 'mynewprovider';
}
protected getDefaultModel(): string {
return 'super-model-v1';
}
protected async getAISDKModel(): Promise<LanguageModel> {
// Fetch or construct the model object required by the
// underlying 'ai' package, then return it.
throw new Error('Not implemented: return the ai-SDK LanguageModel');
}
protected formatProviderError(error: unknown): Error {
// Cast 'unknown' to the provider's specific error type
// and extract the meaningful message.
const message = (error as any)?.response?.data?.error?.message || 'Unknown provider error';
return new Error(message);
}
protected async executeStream(
options: StreamOptions,
): Promise<StreamResult> {
// 1. Get provider-specific credentials.
// 2. Transform the generic 'options' into the provider's
// native request body format.
// 3. Call the provider's streaming API.
// 4. Build and return a StreamResult (stream + usage + metadata).
throw new Error('Not implemented: stream from your provider API');
}
}
That’s the deal. You handle the specifics of authenticating, formatting a request for your provider, and interpreting its errors. In return, you get a massive head start on building a production-ready integration.
The stream vs. executeStream Call Chain
A common point of confusion is the relationship between the public stream() method and the protected executeStream() method you have to implement. A developer calls provider.stream(), but the provider-specific logic lives in executeStream(). What happens in between?
The BaseProvider’s public stream() method is a conductor orchestrating a complex sequence of operations that you, the provider implementer, don’t have to worry about. It wraps the core executeStream() call with critical lifecycle hooks, context preparation, error handling, and analytics.
The flow looks something like this:
graph TD
subgraph BaseProvider
A(public stream) --> B[assemble + filter tools];
B --> C["your executeStream()"];
C --> D[wrap result with lifecycle callbacks];
D --> E(return StreamResult);
end
subgraph Your Provider
C
end
style C fill:#c9f,stroke:#333,stroke-width:2px
Before your executeStream() is ever called, BaseProvider has already assembled the available tools (via getToolsForStream), applied any tool filtering, and started tracing. After your method returns its StreamResult, it wraps that result with the lifecycle callbacks and routes any failure through your formatProviderError — wrapped by handleProviderError in the error path. This observability is fundamental to our strategy, which you can read about in OpenTelemetry for AI: Tracing Every Token Through Your Pipeline.
The public stream() method is the unified entry point; your executeStream() is the unique, provider-specific plug-in.
What You Get For Free: A Tour of Inherited Power
Implementing five methods is the price of admission. The payoff is inheriting a suite of functionality that represents thousands of hours of engineering effort.
Unified
generate()andstream()APIs: Implement the handful of abstract methods andBaseProvidergives you BOTH entry points —generate()for a single non-streaming result andstream()for token-by-token output. They build on the provider methods you implement, so callers get a consistent interface for both modes without you writing either entry point.Automatic Lifecycle Callbacks: The
wrapStreamWithLifecycleCallbacksmethod is the engine for our instrumentation and analytics. It ensures every generation emits consistentonChunk,onFinish, andonErrorevents. You don’t write a single line of code for this.Robust Error Handling: The default
handleProviderErrorlogic inBaseProvidercatches common network issues, timeouts, and other problems before they even reach your provider-specific code. When an error does come from the provider API, it’s routed through yourformatProviderErrormethod to create a clean, standardized error message for the end user.1 2 3 4 5 6 7 8
// Illustrative shape. Real providers (e.g. OpenAIProvider) return // TYPED errors — ProviderError, RateLimitError, AuthenticationError — // not a bare Error, after inspecting the provider's error payload. public formatProviderError(error: unknown): Error { const message = (error as { message?: string })?.message ?? String(error); return new Error(`Provider error: ${message}`); }
Full Tool & Function Calling Support:
BaseProvidercontains all the logic for managing tool use. It callsgetToolsForStreamto assemble available tools, usesapplyToolFilteringto respect user-provided constraints, and automatically synthesizes the response if the model requests a tool call.Automatic Context Management: The
buildMessagesmethod is a sophisticated piece of logic that takes a high-levelStreamOptionsobject and constructs the exactmessagesarray a provider expects. It correctly orders system prompts, user messages, AI responses, and tool calls, managing the complexities of conversation history for you.Image and Video Generation: The
handleVideoGenerationandexecuteImageGenerationmethods provide standardized entry points for multi-modal generation. If your provider supports these, you can hook into these flows rather than inventing your own.Embedding Endpoints: You get
embed()andembedMany()out of the box.BaseProviderprovides a base implementation that will throw a “not supported” error, but you can easily override it by implementing the provider-specificcallEmbeddingsmethod.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
// src/lib/providers/openAI.ts export class OpenAIProvider extends OpenAIChatCompletionsProvider { // ... protected override async executeImageGeneration( options: ImageGenerationOptions, ): Promise<ImageGenerationResult> { // ... } private async callEmbeddings( texts: string[], modelName: string, ): Promise<number[][]> { // ... } // ... }
This inherited functionality is a core part of our strategy for scaling AI development. It lets us focus on the unique capabilities of new models, knowing that the foundation is stable, observable, and consistent. This consistency is also critical for higher-level features like Dynamic Model Selection: Routing AI Requests at Runtime.
The Provider as a Pluggable Component
The power of this pattern is clear when you look at our real-world providers. The AnthropicProvider and GoogleVertexProvider both extend BaseProvider, but their implementations of the core contract are tailored to their respective platforms.
For example, the GoogleVertexProvider has a massively complex generate method because it has to support both Gemini models and proxied Anthropic models on Vertex AI. It contains specialized logic like executeNativeGemini3Generate and executeNativeAnthropicGenerate.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/lib/providers/googleVertex.ts
export class GoogleVertexProvider extends BaseProvider {
// ...
async generate(
optionsOrPrompt: TextGenerationOptions | string,
): Promise<TextGenerationResult> {
// ... complex logic to delegate to the correct native SDK
if (isAnthropicModel) {
return this.executeNativeAnthropicGenerate(options);
} else {
return this.executeNativeGemini3Generate(options);
}
}
// ...
}
In contrast, the AnthropicProvider is much simpler because it only targets the Anthropic API. Its methods, like getAuthHeaders, are direct and focused.
By enforcing a small, stable contract via BaseProvider, we allow for this diversity in implementation while ensuring that from the outside, every provider behaves predictably. This is how we can build complex systems on top of a rapidly changing ecosystem of AI models, and it’s a key reason our testing strategy, detailed in How We Test NeuroLink: 20 Continuous Test Suites and Counting, is even feasible. The base class provides the seams and intercepts needed to validate behavior across all providers.
Five Methods In, A Platform Out
You write five methods. You get a platform.
Four of them just describe your provider. executeStream does the real work. That is the whole contract.
In return, BaseProvider runs the lifecycle. It builds the context. It fires the callbacks. It catches the errors. It records the metrics. Your code never touches that machinery, and it never has to.
Here is what lands in your class the moment you extend it:
generate()— the non-streaming path, run for you byBaseProvider.wrapStreamWithLifecycleCallbacks— theonChunk,onFinish, andonErrorevents.handleProviderError— network, timeout, and rate-limit recovery.applyToolFilteringandgetToolsForStream— the whole tool-use pipeline.embed()andembedMany()— embeddings, ready to override.
None of that is yours to write. You inherit it all on day one.
This is also why our providers stay small. The AnthropicProvider is a few hundred lines. The GoogleVertexProvider runs larger, but only because Vertex hosts two model families at once. Neither one re-implements the lifecycle. Both lean on the base class for it.
That asymmetry is the point. The shared code is large. The per-provider code is small. The base class carries the weight, so each integration stays light.
It also means bugs get fixed once. A retry fix in handleProviderError reaches every provider at the same time. You do not chase the same fix across five files. You change the base class, and it lands everywhere.
The win is speed. A new model lands in hours. The chassis is already built, tested, and observable. You just plug in the part that is genuinely new.
Related posts:
