How to Implement a Custom Provider Adapter for a Non-OpenAI API in FreeLLMAPI
To implement a custom provider adapter in FreeLLMAPI, extend the BaseProvider abstract class, implement the three required methods (chatCompletion, streamChatCompletion, and validateKey), translate the upstream API responses into OpenAI-compatible formats, and register the provider in server/src/providers/index.ts.
FreeLLMAPI (from the tashfeenahmed/freellmapi repository) unifies disparate LLM backends behind a single provider interface. By implementing a custom adapter, you can integrate any RESTful LLM service—even those with unique request formats or authentication schemes—into the proxy without modifying the core routing or quota-tracking logic.
Understanding the BaseProvider Contract
The foundation of every provider in tashfeenahmed/freellmapi is the BaseProvider abstract class defined in server/src/providers/base.ts. This contract enforces three mandatory methods:
chatCompletion: Handles synchronous, non-streaming requests.streamChatCompletion: Handles server-sent event (SSE) streaming responses.validateKey: Verifies API key validity against the upstream service.
BaseProvider also provides utility methods for HTTP handling: fetchWithTimeout for aborted requests, makeId for generating unique identifiers, and readSseStream for parsing SSE streams. Reusing these utilities ensures consistent timeout behavior and error handling across all providers.
Step-by-Step Implementation
Create a Subclass of BaseProvider
Begin by creating a new TypeScript file in server/src/providers/. Your class must extend BaseProvider and define the platform identifier (used for routing and quota tracking) and a human-readable name.
import { BaseProvider, providerHttpError } from './base.js';
import type { ChatMessage, ChatCompletionResponse, ChatCompletionChunk, CompletionOptions } from '@freellmapi/shared/types.js';
import type { QuotaObservationContext } from '../services/provider-quota.js';
export class MyCoolProvider extends BaseProvider {
readonly platform = 'mycool' as const;
readonly name = 'My Cool Service';
private readonly baseUrl: string;
private readonly extraHeaders: Record<string, string>;
constructor(opts: { baseUrl: string; extraHeaders?: Record<string, string> }) {
super();
this.baseUrl = opts.baseUrl;
this.extraHeaders = opts.extraHeaders ?? {};
}
// Required methods implemented below...
}
Translate Request and Response Formats
The core responsibility of a custom provider adapter is protocol translation. In chatCompletion, construct the payload expected by your upstream API, then map the response into the OpenAI-compatible ChatCompletionResponse shape.
async chatCompletion(
apiKey: string,
messages: ChatMessage[],
modelId: string,
options?: CompletionOptions,
quotaContext?: QuotaObservationContext,
): Promise<ChatCompletionResponse> {
// Build provider-specific payload
const payload = {
model: modelId,
history: messages,
temperature: options?.temperature,
maxTokens: options?.max_tokens,
};
// Execute with timeout handling
const res = await this.fetchWithTimeout(`${this.baseUrl}/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
...this.extraHeaders,
},
body: JSON.stringify(payload),
});
// Handle quota observation (optional but recommended)
recordQuotaObservationsFromResponse(res, {
platform: this.platform,
keyId: quotaContext?.keyId,
modelId,
quotaPoolKey: quotaContext?.quotaPoolKey,
endpoint: 'chat/completions',
});
// Error handling
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw providerHttpError(res, `${this.name} API error ${res.status}: ${err.error?.message ?? res.statusText}`);
}
// Map upstream response to OpenAI format
const upstream = await res.json();
return {
id: this.makeId(),
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: modelId,
choices: [{
index: 0,
message: {
role: 'assistant',
content: upstream.replyText, // Map field names appropriately
},
finish_reason: 'stop',
}],
usage: {
prompt_tokens: upstream.usage?.promptTokens ?? 0,
completion_tokens: upstream.usage?.completionTokens ?? 0,
total_tokens: upstream.usage?.totalTokens ?? 0,
},
};
}
Handle Streaming Responses
For streaming support, implement streamChatCompletion as an async generator yielding ChatCompletionChunk objects. If your upstream API uses SSE, delegate to this.readSseStream() from the base class. For non-standard streaming protocols (like Google's Gemini), implement custom parsing logic similar to the pattern found in server/src/providers/google.ts.
async *streamChatCompletion(
apiKey: string,
messages: ChatMessage[],
modelId: string,
options?: CompletionOptions,
): AsyncGenerator<ChatCompletionChunk> {
const res = await this.fetchWithTimeout(`${this.baseUrl}/chat/stream`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ model: modelId, messages }),
});
if (!res.ok) {
throw providerHttpError(res, `${this.name} streaming error ${res.status}`);
}
// Re-use generic SSE parser if compatible
yield* this.readSseStream(res);
}
Implement Key Validation
The validateKey method performs a lightweight health check. Return true for valid credentials, false for invalid ones. Most implementations issue a GET request to a /models or /status endpoint and check for 401/403 status codes.
async validateKey(apiKey: string): Promise<boolean> {
const res = await this.fetchWithTimeout(`${this.baseUrl}/status`, {
method: 'GET',
headers: { Authorization: `Bearer ${apiKey}` },
});
return res.status !== 401 && res.status !== 403;
}
Registering Your Custom Provider
After implementation, expose the provider to the router by registering it in server/src/providers/index.ts:
import { MyCoolProvider } from './my-cool-provider.js';
register(new MyCoolProvider({
baseUrl: 'https://api.mycoolservice.com/v1',
extraHeaders: { 'X-Custom-Header': 'value' },
}));
For dynamic, user-supplied endpoints, the repository already contains a resolver pattern. When platform === 'custom', the code constructs a fresh OpenAICompatProvider on-the-fly. Replace this with your custom class if the endpoint is not OpenAI-compatible:
if (platform === 'custom' && baseUrl) {
return new MyCoolProvider({ baseUrl: baseUrl.trim() });
}
Handling Keyless Authentication
Some services offer anonymous tiers or use IP-based authentication. To support this, set this.keyless = true in your constructor. When this flag is enabled, the proxy layer omits the Authorization header from requests to the upstream service.
constructor(opts: { baseUrl: string }) {
super();
this.baseUrl = opts.baseUrl;
this.keyless = true; // No API key required
}
Complete Code Example
Here is a minimal, production-ready provider file template:
// server/src/providers/my-cool-provider.ts
import type { ChatMessage, ChatCompletionResponse, ChatCompletionChunk, CompletionOptions } from '@freellmapi/shared/types.js';
import { BaseProvider, providerHttpError } from './base.js';
import { recordQuotaObservationsFromResponse, type QuotaObservationContext } from '../services/provider-quota.js';
export class MyCoolProvider extends BaseProvider {
readonly platform = 'mycool' as const;
readonly name = 'My Cool Service';
private readonly baseUrl: string;
constructor(opts: { baseUrl: string }) {
super();
this.baseUrl = opts.baseUrl;
}
async chatCompletion(
apiKey: string,
messages: ChatMessage[],
modelId: string,
options?: CompletionOptions,
quotaContext?: QuotaObservationContext,
): Promise<ChatCompletionResponse> {
const payload = { model: modelId, messages, temperature: options?.temperature };
const res = await this.fetchWithTimeout(`${this.baseUrl}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify(payload),
});
if (!res.ok) throw providerHttpError(res, `${this.name} error`);
const data = await res.json();
return {
id: this.makeId(),
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: modelId,
choices: [{ index: 0, message: { role: 'assistant', content: data.text }, finish_reason: 'stop' }],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
};
}
async *streamChatCompletion(apiKey: string, messages: ChatMessage[], modelId: string): AsyncGenerator<ChatCompletionChunk> {
const res = await this.fetchWithTimeout(`${this.baseUrl}/stream`, { /* ... */ });
yield* this.readSseStream(res);
}
async validateKey(apiKey: string): Promise<boolean> {
const res = await this.fetchWithTimeout(`${this.baseUrl}/status`, { headers: { Authorization: `Bearer ${apiKey}` } });
return res.ok;
}
}
Summary
- Extend
BaseProviderfromserver/src/providers/base.tsto create a new adapter. - Implement three abstract methods:
chatCompletion,streamChatCompletion, andvalidateKey. - Translate protocols by mapping upstream request/response formats to OpenAI-compatible types defined in
shared/types.ts. - Reuse base utilities like
fetchWithTimeoutandreadSseStreamfor consistent HTTP and SSE handling. - Register the provider in
server/src/providers/index.tsusing theregister()function. - Set
this.keyless = truefor services that do not require API key authentication.
Frequently Asked Questions
What is the minimum number of methods I must implement in a custom provider?
You must implement three abstract methods: chatCompletion for synchronous responses, streamChatCompletion for streaming (can return an empty generator if unsupported), and validateKey for credential verification. The base class provides HTTP utilities like fetchWithTimeout and readSseStream to simplify these implementations.
How do I handle APIs that return formats different from OpenAI?
Map the upstream response fields to the OpenAI-compatible ChatCompletionResponse structure in your chatCompletion method. For complex transformations—such as Google's Gemini multimodal responses—refer to server/src/providers/google.ts as a reference for translating nested or differently named fields into the standard choices, message, and usage schema.
Can I support both custom static providers and dynamic user-provided endpoints?
Yes. Register static providers with register(new YourProvider()) in server/src/providers/index.ts. For dynamic endpoints, modify the resolver logic in the same file where platform === 'custom' to instantiate your provider class with a user-supplied baseUrl, similar to how OpenAICompatProvider is constructed on-the-fly.
Where should I place quota tracking logic for my custom provider?
Call recordQuotaObservationsFromResponse() within your chatCompletion method after receiving the upstream response, as shown in the code skeleton. Pass the res object along with platform, keyId, and modelId from the QuotaObservationContext to enable automatic quota tracking and rate-limiting without manual intervention.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →